mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-03-25 23:39:00 +03:00
Per room block unverified devices
This commit is contained in:
parent
0b7e52e60b
commit
e9b33f6234
16 changed files with 376 additions and 45 deletions
changelog.d
library/ui-strings/src/main/res/values
matrix-sdk-android/src
androidTest/java/org/matrix/android/sdk/internal/crypto
main/java/org/matrix/android/sdk
vector/src/main
1
changelog.d/6725.bugfix
Normal file
1
changelog.d/6725.bugfix
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add option to only send to verified devices per room (web parity)
|
|
@ -1234,6 +1234,9 @@
|
||||||
<string name="encryption_import_import">Import</string>
|
<string name="encryption_import_import">Import</string>
|
||||||
<string name="encryption_never_send_to_unverified_devices_title">Encrypt to verified sessions only</string>
|
<string name="encryption_never_send_to_unverified_devices_title">Encrypt to verified sessions only</string>
|
||||||
<string name="encryption_never_send_to_unverified_devices_summary">Never send encrypted messages to unverified sessions from this session.</string>
|
<string name="encryption_never_send_to_unverified_devices_summary">Never send encrypted messages to unverified sessions from this session.</string>
|
||||||
|
<string name="encryption_never_send_to_unverified_devices_in_room">Never send encrypted messages to unverified sessions in this room.</string>
|
||||||
|
<string name="some_devices_will_not_be_able_to_decrypt">⚠ There are unverified devices in this room, they won’t be able to decrypt messages you send.</string>
|
||||||
|
<string name="room_settings_global_blacklist_unverified_info_text">🔒 You have enable encrypt to verified sessions only for all rooms in Security Settings.</string>
|
||||||
<plurals name="encryption_import_room_keys_success">
|
<plurals name="encryption_import_room_keys_success">
|
||||||
<item quantity="one">%1$d/%2$d key imported with success.</item>
|
<item quantity="one">%1$d/%2$d key imported with success.</item>
|
||||||
<item quantity="other">%1$d/%2$d keys imported with success.</item>
|
<item quantity="other">%1$d/%2$d keys imported with success.</item>
|
||||||
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* 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 org.matrix.android.sdk.internal.crypto
|
||||||
|
|
||||||
|
import androidx.test.filters.LargeTest
|
||||||
|
import org.amshove.kluent.shouldBe
|
||||||
|
import org.junit.FixMethodOrder
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
import org.junit.runners.MethodSorters
|
||||||
|
import org.matrix.android.sdk.InstrumentedTest
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
|
||||||
|
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
|
||||||
|
import org.matrix.android.sdk.common.CommonTestHelper.Companion.runCryptoTest
|
||||||
|
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
@FixMethodOrder(MethodSorters.JVM)
|
||||||
|
@LargeTest
|
||||||
|
class E2eeTestConfig : InstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBlacklistUnverifiedDefault() = runCryptoTest(context()) { cryptoTestHelper, _ ->
|
||||||
|
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
|
||||||
|
|
||||||
|
cryptoTestData.firstSession.cryptoService().getGlobalBlacklistUnverifiedDevices() shouldBe false
|
||||||
|
cryptoTestData.firstSession.cryptoService().isRoomBlacklistUnverifiedDevices(cryptoTestData.roomId) shouldBe false
|
||||||
|
cryptoTestData.secondSession!!.cryptoService().getGlobalBlacklistUnverifiedDevices() shouldBe false
|
||||||
|
cryptoTestData.secondSession!!.cryptoService().isRoomBlacklistUnverifiedDevices(cryptoTestData.roomId) shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCantDecryptIfGlobalUnverified() = runCryptoTest(context()) { cryptoTestHelper, testHelper ->
|
||||||
|
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
|
||||||
|
|
||||||
|
cryptoTestData.firstSession.cryptoService().setGlobalBlacklistUnverifiedDevices(true)
|
||||||
|
|
||||||
|
val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
|
||||||
|
val sentMessage = testHelper.sendTextMessage(roomAlicePOV, "you are blocked", 1).first()
|
||||||
|
|
||||||
|
val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
// ensure other received
|
||||||
|
testHelper.waitWithLatch { latch ->
|
||||||
|
testHelper.retryPeriodicallyWithLatch(latch) {
|
||||||
|
roomBobPOV.timelineService().getTimelineEvent(sentMessage.eventId) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cryptoTestHelper.ensureCannotDecrypt(listOf(sentMessage.eventId), cryptoTestData.secondSession!!, cryptoTestData.roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCanDecryptIfGlobalUnverifiedAndUserTrusted() = runCryptoTest(context()) { cryptoTestHelper, testHelper ->
|
||||||
|
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
|
||||||
|
|
||||||
|
cryptoTestHelper.initializeCrossSigning(cryptoTestData.firstSession)
|
||||||
|
cryptoTestHelper.initializeCrossSigning(cryptoTestData.secondSession!!)
|
||||||
|
|
||||||
|
cryptoTestHelper.verifySASCrossSign(cryptoTestData.firstSession, cryptoTestData.secondSession!!, cryptoTestData.roomId)
|
||||||
|
|
||||||
|
cryptoTestData.firstSession.cryptoService().setGlobalBlacklistUnverifiedDevices(true)
|
||||||
|
|
||||||
|
val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
|
||||||
|
val sentMessage = testHelper.sendTextMessage(roomAlicePOV, "you can read", 1).first()
|
||||||
|
|
||||||
|
val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
// ensure other received
|
||||||
|
testHelper.waitWithLatch { latch ->
|
||||||
|
testHelper.retryPeriodicallyWithLatch(latch) {
|
||||||
|
roomBobPOV.timelineService().getTimelineEvent(sentMessage.eventId) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cryptoTestHelper.ensureCanDecrypt(
|
||||||
|
listOf(sentMessage.eventId),
|
||||||
|
cryptoTestData.secondSession!!,
|
||||||
|
cryptoTestData.roomId,
|
||||||
|
listOf(sentMessage.getLastMessageContent()!!.body)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCantDecryptIfPerRoomUnverified() = runCryptoTest(context()) { cryptoTestHelper, testHelper ->
|
||||||
|
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
|
||||||
|
|
||||||
|
val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
|
||||||
|
val beforeMessage = testHelper.sendTextMessage(roomAlicePOV, "you can read", 1).first()
|
||||||
|
|
||||||
|
val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
// ensure other received
|
||||||
|
testHelper.waitWithLatch { latch ->
|
||||||
|
testHelper.retryPeriodicallyWithLatch(latch) {
|
||||||
|
roomBobPOV.timelineService().getTimelineEvent(beforeMessage.eventId) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cryptoTestHelper.ensureCanDecrypt(
|
||||||
|
listOf(beforeMessage.eventId),
|
||||||
|
cryptoTestData.secondSession!!,
|
||||||
|
cryptoTestData.roomId,
|
||||||
|
listOf(beforeMessage.getLastMessageContent()!!.body)
|
||||||
|
)
|
||||||
|
|
||||||
|
cryptoTestData.firstSession.cryptoService().setRoomBlacklistUnverifiedDevices(cryptoTestData.roomId, true)
|
||||||
|
|
||||||
|
val afterMessage = testHelper.sendTextMessage(roomAlicePOV, "you are blocked", 1).first()
|
||||||
|
|
||||||
|
cryptoTestHelper.ensureCannotDecrypt(
|
||||||
|
listOf(afterMessage.eventId),
|
||||||
|
cryptoTestData.secondSession!!,
|
||||||
|
cryptoTestData.roomId,
|
||||||
|
MXCryptoError.ErrorType.KEYS_WITHHELD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ import org.matrix.android.sdk.api.session.events.model.Event
|
||||||
import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent
|
import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent
|
||||||
import org.matrix.android.sdk.api.util.Optional
|
import org.matrix.android.sdk.api.util.Optional
|
||||||
import org.matrix.android.sdk.internal.crypto.model.SessionInfo
|
import org.matrix.android.sdk.internal.crypto.model.SessionInfo
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig
|
||||||
|
|
||||||
interface CryptoService {
|
interface CryptoService {
|
||||||
|
|
||||||
|
@ -61,6 +62,8 @@ interface CryptoService {
|
||||||
|
|
||||||
fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean
|
fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean
|
||||||
|
|
||||||
|
fun getLiveBlacklistUnverifiedDevices(roomId: String): LiveData<Boolean>
|
||||||
|
|
||||||
fun setWarnOnUnknownDevices(warn: Boolean)
|
fun setWarnOnUnknownDevices(warn: Boolean)
|
||||||
|
|
||||||
fun setDeviceVerification(trustLevel: DeviceTrustLevel, userId: String, deviceId: String)
|
fun setDeviceVerification(trustLevel: DeviceTrustLevel, userId: String, deviceId: String)
|
||||||
|
@ -77,6 +80,8 @@ interface CryptoService {
|
||||||
|
|
||||||
fun setGlobalBlacklistUnverifiedDevices(block: Boolean)
|
fun setGlobalBlacklistUnverifiedDevices(block: Boolean)
|
||||||
|
|
||||||
|
fun getLiveGlobalCryptoConfig(): LiveData<GlobalCryptoConfig>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable key gossiping.
|
* Enable or disable key gossiping.
|
||||||
* Default is true.
|
* Default is true.
|
||||||
|
@ -112,7 +117,7 @@ interface CryptoService {
|
||||||
|
|
||||||
suspend fun exportRoomKeys(password: String): ByteArray
|
suspend fun exportRoomKeys(password: String): ByteArray
|
||||||
|
|
||||||
fun setRoomBlacklistUnverifiedDevices(roomId: String)
|
fun setRoomBlacklistUnverifiedDevices(roomId: String, enable: Boolean)
|
||||||
|
|
||||||
fun getCryptoDeviceInfo(userId: String, deviceId: String?): CryptoDeviceInfo?
|
fun getCryptoDeviceInfo(userId: String, deviceId: String?): CryptoDeviceInfo?
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 org.matrix.android.sdk.api.session.crypto
|
||||||
|
|
||||||
|
data class GlobalCryptoConfig(
|
||||||
|
val globalBlacklistUnverifiedDevices: Boolean,
|
||||||
|
val globalEnableKeyGossiping: Boolean,
|
||||||
|
val enableKeyForwardingOnInvite: Boolean,
|
||||||
|
)
|
|
@ -87,6 +87,7 @@ import org.matrix.android.sdk.internal.crypto.model.MXKey.Companion.KEY_SIGNED_C
|
||||||
import org.matrix.android.sdk.internal.crypto.model.SessionInfo
|
import org.matrix.android.sdk.internal.crypto.model.SessionInfo
|
||||||
import org.matrix.android.sdk.internal.crypto.model.toRest
|
import org.matrix.android.sdk.internal.crypto.model.toRest
|
||||||
import org.matrix.android.sdk.internal.crypto.repository.WarnOnUnknownDeviceRepository
|
import org.matrix.android.sdk.internal.crypto.repository.WarnOnUnknownDeviceRepository
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig
|
||||||
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
|
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
|
||||||
import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceTask
|
import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceTask
|
||||||
import org.matrix.android.sdk.internal.crypto.tasks.GetDeviceInfoTask
|
import org.matrix.android.sdk.internal.crypto.tasks.GetDeviceInfoTask
|
||||||
|
@ -1163,6 +1164,10 @@ internal class DefaultCryptoService @Inject constructor(
|
||||||
return cryptoStore.getGlobalBlacklistUnverifiedDevices()
|
return cryptoStore.getGlobalBlacklistUnverifiedDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getLiveGlobalCryptoConfig(): LiveData<GlobalCryptoConfig> {
|
||||||
|
return cryptoStore.getLiveGlobalCryptoConfig()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells whether the client should encrypt messages only for the verified devices
|
* Tells whether the client should encrypt messages only for the verified devices
|
||||||
* in this room.
|
* in this room.
|
||||||
|
@ -1171,30 +1176,18 @@ internal class DefaultCryptoService @Inject constructor(
|
||||||
* @param roomId the room id
|
* @param roomId the room id
|
||||||
* @return true if the client should encrypt messages only for the verified devices.
|
* @return true if the client should encrypt messages only for the verified devices.
|
||||||
*/
|
*/
|
||||||
// TODO add this info in CryptoRoomEntity?
|
|
||||||
override fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean {
|
override fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean {
|
||||||
return roomId?.let { cryptoStore.getRoomsListBlacklistUnverifiedDevices().contains(it) }
|
return roomId?.let { cryptoStore.getBlacklistUnverifiedDevices(roomId) }
|
||||||
?: false
|
?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the room black-listing for unverified devices.
|
* A live status regarding sharing keys for unverified devices in this room.
|
||||||
*
|
*
|
||||||
* @param roomId the room id
|
* @return Live status
|
||||||
* @param add true to add the room id to the list, false to remove it.
|
|
||||||
*/
|
*/
|
||||||
private fun setRoomBlacklistUnverifiedDevices(roomId: String, add: Boolean) {
|
override fun getLiveBlacklistUnverifiedDevices(roomId: String): LiveData<Boolean> {
|
||||||
val roomIds = cryptoStore.getRoomsListBlacklistUnverifiedDevices().toMutableList()
|
return cryptoStore.getLiveBlacklistUnverifiedDevices(roomId)
|
||||||
|
|
||||||
if (add) {
|
|
||||||
if (roomId !in roomIds) {
|
|
||||||
roomIds.add(roomId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
roomIds.remove(roomId)
|
|
||||||
}
|
|
||||||
|
|
||||||
cryptoStore.setRoomsListBlacklistUnverifiedDevices(roomIds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1202,8 +1195,8 @@ internal class DefaultCryptoService @Inject constructor(
|
||||||
*
|
*
|
||||||
* @param roomId the room id
|
* @param roomId the room id
|
||||||
*/
|
*/
|
||||||
override fun setRoomBlacklistUnverifiedDevices(roomId: String) {
|
override fun setRoomBlacklistUnverifiedDevices(roomId: String, enable: Boolean) {
|
||||||
setRoomBlacklistUnverifiedDevices(roomId, true)
|
cryptoStore.blackListUnverifiedDevicesInRoom(roomId, enable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -424,7 +424,7 @@ internal class MXMegolmEncryption(
|
||||||
// an m.new_device.
|
// an m.new_device.
|
||||||
val keys = deviceListManager.downloadKeys(userIds, false)
|
val keys = deviceListManager.downloadKeys(userIds, false)
|
||||||
val encryptToVerifiedDevicesOnly = cryptoStore.getGlobalBlacklistUnverifiedDevices() ||
|
val encryptToVerifiedDevicesOnly = cryptoStore.getGlobalBlacklistUnverifiedDevices() ||
|
||||||
cryptoStore.getRoomsListBlacklistUnverifiedDevices().contains(roomId)
|
cryptoStore.getBlacklistUnverifiedDevices(roomId)
|
||||||
|
|
||||||
val devicesInRoom = DeviceInRoomInfo()
|
val devicesInRoom = DeviceInRoomInfo()
|
||||||
val unknownDevices = MXUsersDevicesMap<CryptoDeviceInfo>()
|
val unknownDevices = MXUsersDevicesMap<CryptoDeviceInfo>()
|
||||||
|
|
|
@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.store
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagedList
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig
|
||||||
import org.matrix.android.sdk.api.session.crypto.NewSessionListener
|
import org.matrix.android.sdk.api.session.crypto.NewSessionListener
|
||||||
import org.matrix.android.sdk.api.session.crypto.OutgoingKeyRequest
|
import org.matrix.android.sdk.api.session.crypto.OutgoingKeyRequest
|
||||||
import org.matrix.android.sdk.api.session.crypto.OutgoingRoomKeyRequestState
|
import org.matrix.android.sdk.api.session.crypto.OutgoingRoomKeyRequestState
|
||||||
|
@ -120,11 +121,26 @@ internal interface IMXCryptoStore {
|
||||||
fun getRoomsListBlacklistUnverifiedDevices(): List<String>
|
fun getRoomsListBlacklistUnverifiedDevices(): List<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the rooms ids list in which the messages are not encrypted for the unverified devices.
|
* A live status regarding sharing keys for unverified devices in this room.
|
||||||
*
|
*
|
||||||
* @param roomIds the room ids list
|
* @return Live status
|
||||||
*/
|
*/
|
||||||
fun setRoomsListBlacklistUnverifiedDevices(roomIds: List<String>)
|
fun getLiveBlacklistUnverifiedDevices(roomId: String): LiveData<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell if unverified devices should be blacklisted when sending keys.
|
||||||
|
*
|
||||||
|
* @return true if should not send keys to unverified devices
|
||||||
|
*/
|
||||||
|
fun getBlacklistUnverifiedDevices(roomId: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define if encryption keys should be sent to unverified devices in this room.
|
||||||
|
*
|
||||||
|
* @param roomId the roomId
|
||||||
|
* @param blacklist if true will not send keys to unverified devices
|
||||||
|
*/
|
||||||
|
fun blackListUnverifiedDevicesInRoom(roomId: String, blacklist: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current keys backup version.
|
* Get the current keys backup version.
|
||||||
|
@ -516,6 +532,9 @@ internal interface IMXCryptoStore {
|
||||||
fun getCrossSigningPrivateKeys(): PrivateKeysInfo?
|
fun getCrossSigningPrivateKeys(): PrivateKeysInfo?
|
||||||
fun getLiveCrossSigningPrivateKeys(): LiveData<Optional<PrivateKeysInfo>>
|
fun getLiveCrossSigningPrivateKeys(): LiveData<Optional<PrivateKeysInfo>>
|
||||||
|
|
||||||
|
fun getGlobalCryptoConfig(): GlobalCryptoConfig
|
||||||
|
fun getLiveGlobalCryptoConfig(): LiveData<GlobalCryptoConfig>
|
||||||
|
|
||||||
fun saveBackupRecoveryKey(recoveryKey: String?, version: String?)
|
fun saveBackupRecoveryKey(recoveryKey: String?, version: String?)
|
||||||
fun getKeyBackupRecoveryKeyInfo(): SavedKeyBackupKeyInfo?
|
fun getKeyBackupRecoveryKeyInfo(): SavedKeyBackupKeyInfo?
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ import org.matrix.android.sdk.api.util.toOptional
|
||||||
import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper
|
import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper
|
||||||
import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper
|
import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper
|
||||||
import org.matrix.android.sdk.internal.crypto.model.OutboundGroupSessionWrapper
|
import org.matrix.android.sdk.internal.crypto.model.OutboundGroupSessionWrapper
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig
|
||||||
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
|
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
|
||||||
import org.matrix.android.sdk.internal.crypto.store.db.mapper.CrossSigningKeysMapper
|
import org.matrix.android.sdk.internal.crypto.store.db.mapper.CrossSigningKeysMapper
|
||||||
import org.matrix.android.sdk.internal.crypto.store.db.mapper.MyDeviceLastSeenInfoEntityMapper
|
import org.matrix.android.sdk.internal.crypto.store.db.mapper.MyDeviceLastSeenInfoEntityMapper
|
||||||
|
@ -445,6 +446,38 @@ internal class RealmCryptoStore @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getGlobalCryptoConfig(): GlobalCryptoConfig {
|
||||||
|
return doWithRealm(realmConfiguration) { realm ->
|
||||||
|
realm.where<CryptoMetadataEntity>().findFirst()
|
||||||
|
?.let {
|
||||||
|
GlobalCryptoConfig(
|
||||||
|
globalBlacklistUnverifiedDevices = it.globalBlacklistUnverifiedDevices,
|
||||||
|
globalEnableKeyGossiping = it.globalEnableKeyGossiping,
|
||||||
|
enableKeyForwardingOnInvite = it.enableKeyForwardingOnInvite
|
||||||
|
)
|
||||||
|
} ?: GlobalCryptoConfig(false, false, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLiveGlobalCryptoConfig(): LiveData<GlobalCryptoConfig> {
|
||||||
|
val liveData = monarchy.findAllMappedWithChanges(
|
||||||
|
{ realm: Realm ->
|
||||||
|
realm
|
||||||
|
.where<CryptoMetadataEntity>()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GlobalCryptoConfig(
|
||||||
|
globalBlacklistUnverifiedDevices = it.globalBlacklistUnverifiedDevices,
|
||||||
|
globalEnableKeyGossiping = it.globalEnableKeyGossiping,
|
||||||
|
enableKeyForwardingOnInvite = it.enableKeyForwardingOnInvite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Transformations.map(liveData) {
|
||||||
|
it.firstOrNull() ?: GlobalCryptoConfig(false, false, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) {
|
override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) {
|
||||||
Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null}, ${usk != null}, ${ssk != null}")
|
Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null}, ${usk != null}, ${ssk != null}")
|
||||||
doRealmTransaction(realmConfiguration) { realm ->
|
doRealmTransaction(realmConfiguration) { realm ->
|
||||||
|
@ -1053,25 +1086,6 @@ internal class RealmCryptoStore @Inject constructor(
|
||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setRoomsListBlacklistUnverifiedDevices(roomIds: List<String>) {
|
|
||||||
doRealmTransaction(realmConfiguration) {
|
|
||||||
// Reset all
|
|
||||||
it.where<CryptoRoomEntity>()
|
|
||||||
.findAll()
|
|
||||||
.forEach { room ->
|
|
||||||
room.blacklistUnverifiedDevices = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable those in the list
|
|
||||||
it.where<CryptoRoomEntity>()
|
|
||||||
.`in`(CryptoRoomEntityFields.ROOM_ID, roomIds.toTypedArray())
|
|
||||||
.findAll()
|
|
||||||
.forEach { room ->
|
|
||||||
room.blacklistUnverifiedDevices = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRoomsListBlacklistUnverifiedDevices(): List<String> {
|
override fun getRoomsListBlacklistUnverifiedDevices(): List<String> {
|
||||||
return doWithRealm(realmConfiguration) {
|
return doWithRealm(realmConfiguration) {
|
||||||
it.where<CryptoRoomEntity>()
|
it.where<CryptoRoomEntity>()
|
||||||
|
@ -1083,6 +1097,37 @@ internal class RealmCryptoStore @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getLiveBlacklistUnverifiedDevices(roomId: String): LiveData<Boolean> {
|
||||||
|
val liveData = monarchy.findAllMappedWithChanges(
|
||||||
|
{ realm: Realm ->
|
||||||
|
realm.where<CryptoRoomEntity>()
|
||||||
|
.equalTo(CryptoRoomEntityFields.ROOM_ID, roomId)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
it.blacklistUnverifiedDevices
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Transformations.map(liveData) {
|
||||||
|
it.firstOrNull() ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBlacklistUnverifiedDevices(roomId: String): Boolean {
|
||||||
|
return doWithRealm(realmConfiguration) { realm ->
|
||||||
|
realm.where<CryptoRoomEntity>()
|
||||||
|
.equalTo(CryptoRoomEntityFields.ROOM_ID, roomId)
|
||||||
|
.findFirst()
|
||||||
|
?.blacklistUnverifiedDevices ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun blackListUnverifiedDevicesInRoom(roomId: String, blacklist: Boolean) {
|
||||||
|
doRealmTransaction(realmConfiguration) { realm ->
|
||||||
|
CryptoRoomEntity.getById(realm, roomId)
|
||||||
|
?.blacklistUnverifiedDevices = blacklist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getDeviceTrackingStatuses(): Map<String, Int> {
|
override fun getDeviceTrackingStatuses(): Map<String, Int> {
|
||||||
return doWithRealm(realmConfiguration) {
|
return doWithRealm(realmConfiguration) {
|
||||||
it.where<UserEntity>()
|
it.where<UserEntity>()
|
||||||
|
|
|
@ -35,7 +35,7 @@ data class RoomProfileViewState(
|
||||||
val recommendedRoomVersion: String? = null,
|
val recommendedRoomVersion: String? = null,
|
||||||
val canUpgradeRoom: Boolean = false,
|
val canUpgradeRoom: Boolean = false,
|
||||||
val isTombstoned: Boolean = false,
|
val isTombstoned: Boolean = false,
|
||||||
val canUpdateRoomState: Boolean = false
|
val canUpdateRoomState: Boolean = false,
|
||||||
) : MavericksState {
|
) : MavericksState {
|
||||||
|
|
||||||
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)
|
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)
|
||||||
|
|
|
@ -28,6 +28,7 @@ sealed class RoomSettingsAction : VectorViewModelAction {
|
||||||
data class SetRoomHistoryVisibility(val visibility: RoomHistoryVisibility) : RoomSettingsAction()
|
data class SetRoomHistoryVisibility(val visibility: RoomHistoryVisibility) : RoomSettingsAction()
|
||||||
data class SetRoomJoinRule(val roomJoinRule: RoomJoinRules) : RoomSettingsAction()
|
data class SetRoomJoinRule(val roomJoinRule: RoomJoinRules) : RoomSettingsAction()
|
||||||
data class SetRoomGuestAccess(val guestAccess: GuestAccess) : RoomSettingsAction()
|
data class SetRoomGuestAccess(val guestAccess: GuestAccess) : RoomSettingsAction()
|
||||||
|
data class SetEncryptToVerifiedDeviceOnly(val enable: Boolean) : RoomSettingsAction()
|
||||||
|
|
||||||
object Save : RoomSettingsAction()
|
object Save : RoomSettingsAction()
|
||||||
object Cancel : RoomSettingsAction()
|
object Cancel : RoomSettingsAction()
|
||||||
|
|
|
@ -22,6 +22,7 @@ import im.vector.app.core.epoxy.dividerItem
|
||||||
import im.vector.app.core.epoxy.profiles.buildProfileAction
|
import im.vector.app.core.epoxy.profiles.buildProfileAction
|
||||||
import im.vector.app.core.epoxy.profiles.buildProfileSection
|
import im.vector.app.core.epoxy.profiles.buildProfileSection
|
||||||
import im.vector.app.core.resources.StringProvider
|
import im.vector.app.core.resources.StringProvider
|
||||||
|
import im.vector.app.core.ui.list.genericFooterItem
|
||||||
import im.vector.app.core.ui.list.verticalMarginItem
|
import im.vector.app.core.ui.list.verticalMarginItem
|
||||||
import im.vector.app.core.utils.DimensionConverter
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
import im.vector.app.features.form.formEditTextItem
|
import im.vector.app.features.form.formEditTextItem
|
||||||
|
@ -30,6 +31,8 @@ import im.vector.app.features.form.formSwitchItem
|
||||||
import im.vector.app.features.home.AvatarRenderer
|
import im.vector.app.features.home.AvatarRenderer
|
||||||
import im.vector.app.features.home.room.detail.timeline.format.RoomHistoryVisibilityFormatter
|
import im.vector.app.features.home.room.detail.timeline.format.RoomHistoryVisibilityFormatter
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
|
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
|
||||||
|
import me.gujun.android.span.span
|
||||||
import org.matrix.android.sdk.api.session.room.model.GuestAccess
|
import org.matrix.android.sdk.api.session.room.model.GuestAccess
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
|
import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
|
||||||
import org.matrix.android.sdk.api.util.toMatrixItem
|
import org.matrix.android.sdk.api.util.toMatrixItem
|
||||||
|
@ -52,6 +55,7 @@ class RoomSettingsController @Inject constructor(
|
||||||
fun onHistoryVisibilityClicked()
|
fun onHistoryVisibilityClicked()
|
||||||
fun onJoinRuleClicked()
|
fun onJoinRuleClicked()
|
||||||
fun onToggleGuestAccess()
|
fun onToggleGuestAccess()
|
||||||
|
fun setEncryptedToVerifiedDevicesOnly(enabled: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
var callback: Callback? = null
|
var callback: Callback? = null
|
||||||
|
@ -145,5 +149,53 @@ class RoomSettingsController @Inject constructor(
|
||||||
id("guestAccessDivider")
|
id("guestAccessDivider")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security
|
||||||
|
buildProfileSection(stringProvider.getString(R.string.room_profile_section_security))
|
||||||
|
|
||||||
|
data.globalCryptoConfig.invoke()?.let { globalConfig ->
|
||||||
|
if (globalConfig.globalBlacklistUnverifiedDevices) {
|
||||||
|
genericFooterItem {
|
||||||
|
id("globalConfig")
|
||||||
|
centered(false)
|
||||||
|
text(
|
||||||
|
span {
|
||||||
|
+host.stringProvider.getString(R.string.room_settings_global_blacklist_unverified_info_text)
|
||||||
|
apply {
|
||||||
|
if (data.unverifiedDevicesInTheRoom.invoke() == true) {
|
||||||
|
+"\n"
|
||||||
|
+host.stringProvider.getString(R.string.some_devices_will_not_be_able_to_decrypt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.toEpoxyCharSequence()
|
||||||
|
)
|
||||||
|
itemClickAction {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// per room setting is available
|
||||||
|
val shouldBlockUnverified = data.encryptToVerifiedDeviceOnly.invoke()
|
||||||
|
formSwitchItem {
|
||||||
|
id("send_to_unverified")
|
||||||
|
enabled(shouldBlockUnverified != null)
|
||||||
|
title(host.stringProvider.getString(R.string.encryption_never_send_to_unverified_devices_in_room))
|
||||||
|
|
||||||
|
switchChecked(shouldBlockUnverified ?: false)
|
||||||
|
|
||||||
|
apply {
|
||||||
|
if (shouldBlockUnverified == true && data.unverifiedDevicesInTheRoom.invoke() == true) {
|
||||||
|
summary(
|
||||||
|
host.stringProvider.getString(R.string.some_devices_will_not_be_able_to_decrypt)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
summary(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener { value ->
|
||||||
|
host.callback?.setEncryptedToVerifiedDevicesOnly(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -199,6 +199,10 @@ class RoomSettingsFragment :
|
||||||
viewModel.handle(RoomSettingsAction.SetRoomGuestAccess(toggled))
|
viewModel.handle(RoomSettingsAction.SetRoomGuestAccess(toggled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setEncryptedToVerifiedDevicesOnly(enabled: Boolean) {
|
||||||
|
viewModel.handle(RoomSettingsAction.SetEncryptToVerifiedDeviceOnly(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onImageReady(uri: Uri?) {
|
override fun onImageReady(uri: Uri?) {
|
||||||
uri ?: return
|
uri ?: return
|
||||||
viewModel.handle(
|
viewModel.handle(
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
package im.vector.app.features.roomprofile.settings
|
package im.vector.app.features.roomprofile.settings
|
||||||
|
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
@ -25,8 +26,12 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
|
||||||
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
||||||
import im.vector.app.core.platform.VectorViewModel
|
import im.vector.app.core.platform.VectorViewModel
|
||||||
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
|
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
|
||||||
|
import im.vector.app.features.session.coroutineScope
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -37,6 +42,8 @@ import org.matrix.android.sdk.api.session.events.model.EventType
|
||||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||||
import org.matrix.android.sdk.api.session.getRoom
|
import org.matrix.android.sdk.api.session.getRoom
|
||||||
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
|
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
|
||||||
|
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
|
||||||
|
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent
|
import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomGuestAccessContent
|
import org.matrix.android.sdk.api.session.room.model.RoomGuestAccessContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent
|
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent
|
||||||
|
@ -83,6 +90,39 @@ class RoomSettingsViewModel @AssistedInject constructor(
|
||||||
canUpgradeToRestricted = couldUpgradeToRestricted
|
canUpgradeToRestricted = couldUpgradeToRestricted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.cryptoService().getLiveBlacklistUnverifiedDevices(initialState.roomId)
|
||||||
|
.asFlow()
|
||||||
|
.execute {
|
||||||
|
copy(encryptToVerifiedDeviceOnly = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.cryptoService().getLiveGlobalCryptoConfig()
|
||||||
|
.asFlow()
|
||||||
|
.execute {
|
||||||
|
copy(globalCryptoConfig = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val flowRoom = room.flow()
|
||||||
|
session.cryptoService().getLiveBlacklistUnverifiedDevices(initialState.roomId)
|
||||||
|
.asFlow()
|
||||||
|
.flatMapLatest {
|
||||||
|
if (it) {
|
||||||
|
flowRoom.liveRoomMembers(roomMemberQueryParams { memberships = Membership.activeMemberships() })
|
||||||
|
.map { it.map { it.userId } }
|
||||||
|
.flatMapLatest {
|
||||||
|
session.cryptoService().getLiveCryptoDeviceInfo(it).asFlow()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flowOf(emptyList())
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
it.isNotEmpty()
|
||||||
|
}.execute {
|
||||||
|
copy(
|
||||||
|
unverifiedDevicesInTheRoom = it
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeState() {
|
private fun observeState() {
|
||||||
|
@ -212,6 +252,7 @@ class RoomSettingsViewModel @AssistedInject constructor(
|
||||||
is RoomSettingsAction.SetRoomGuestAccess -> handleSetGuestAccess(action)
|
is RoomSettingsAction.SetRoomGuestAccess -> handleSetGuestAccess(action)
|
||||||
is RoomSettingsAction.Save -> saveSettings()
|
is RoomSettingsAction.Save -> saveSettings()
|
||||||
is RoomSettingsAction.Cancel -> cancel()
|
is RoomSettingsAction.Cancel -> cancel()
|
||||||
|
is RoomSettingsAction.SetEncryptToVerifiedDeviceOnly -> setEncryptToVerifiedDeviceOnly(action.enable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,6 +274,12 @@ class RoomSettingsViewModel @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setEncryptToVerifiedDeviceOnly(enabled: Boolean) {
|
||||||
|
session.coroutineScope.launch {
|
||||||
|
session.cryptoService().setRoomBlacklistUnverifiedDevices(room.roomId, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleSetAvatarAction(action: RoomSettingsAction.SetAvatarAction) {
|
private fun handleSetAvatarAction(action: RoomSettingsAction.SetAvatarAction) {
|
||||||
setState {
|
setState {
|
||||||
deletePendingAvatar(this)
|
deletePendingAvatar(this)
|
||||||
|
|
|
@ -27,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.model.GuestAccess
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility
|
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
|
import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig
|
||||||
|
|
||||||
data class RoomSettingsViewState(
|
data class RoomSettingsViewState(
|
||||||
val roomId: String,
|
val roomId: String,
|
||||||
|
@ -45,7 +46,10 @@ data class RoomSettingsViewState(
|
||||||
val showSaveAction: Boolean = false,
|
val showSaveAction: Boolean = false,
|
||||||
val actionPermissions: ActionPermissions = ActionPermissions(),
|
val actionPermissions: ActionPermissions = ActionPermissions(),
|
||||||
val supportsRestricted: Boolean = false,
|
val supportsRestricted: Boolean = false,
|
||||||
val canUpgradeToRestricted: Boolean = false
|
val canUpgradeToRestricted: Boolean = false,
|
||||||
|
val encryptToVerifiedDeviceOnly: Async<Boolean> = Uninitialized,
|
||||||
|
val globalCryptoConfig: Async<GlobalCryptoConfig> = Uninitialized,
|
||||||
|
val unverifiedDevicesInTheRoom: Async<Boolean> = Uninitialized,
|
||||||
) : MavericksState {
|
) : MavericksState {
|
||||||
|
|
||||||
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)
|
constructor(args: RoomProfileArgs) : this(roomId = args.roomId)
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
android:background="?android:colorBackground"
|
android:background="?android:colorBackground"
|
||||||
android:foreground="?attr/selectableItemBackground"
|
android:foreground="?attr/selectableItemBackground"
|
||||||
android:minHeight="@dimen/item_form_min_height"
|
android:minHeight="@dimen/item_form_min_height"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
tools:viewBindingIgnore="true">
|
tools:viewBindingIgnore="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
Loading…
Add table
Reference in a new issue