Merge develop into the branch.

This commit is contained in:
onurays 2020-03-26 14:39:50 +03:00
commit 12429d8091
112 changed files with 2951 additions and 916 deletions

View file

@ -3,12 +3,16 @@ Changes in RiotX 0.19.0 (2020-XX-XX)
Features ✨:
- Cross-Signing | Support SSSS secret sharing (#944)
- Cross-Signing | Verify new session from existing session (#1134)
Improvements 🙌:
- Verification DM / Handle concurrent .start after .ready (#794)
- Reimplementation of multiple attachment picker
Bugfix 🐛:
- Missing avatar/displayname after verification request message (#841)
- Crypto | RiotX sometimes rotate the current device keys (#1170)
- RiotX can't restore cross signing keys saved by web in SSSS (#1174)
Translations 🗣:
-

View file

@ -119,7 +119,7 @@ dependencies {
implementation "ru.noties.markwon:core:$markwon_version"
// Image
implementation 'androidx.exifinterface:exifinterface:1.1.0'
implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
implementation 'id.zelory:compressor:3.0.0'
// Database

View file

@ -266,8 +266,8 @@ class CommonTestHelper(context: Context) {
* @param latch
* @throws InterruptedException
*/
fun await(latch: CountDownLatch) {
assertTrue(latch.await(TestConstants.timeOutMillis, TimeUnit.MILLISECONDS))
fun await(latch: CountDownLatch, timout: Long? = TestConstants.timeOutMillis) {
assertTrue(latch.await(timout ?: TestConstants.timeOutMillis, TimeUnit.MILLISECONDS))
}
fun retryPeriodicallyWithLatch(latch: CountDownLatch, condition: (() -> Boolean)) {
@ -282,10 +282,10 @@ class CommonTestHelper(context: Context) {
}
}
fun waitWithLatch(block: (CountDownLatch) -> Unit) {
fun waitWithLatch(timout: Long? = TestConstants.timeOutMillis, block: (CountDownLatch) -> Unit) {
val latch = CountDownLatch(1)
block(latch)
await(latch)
await(latch, timout)
}
// Transform a method with a MatrixCallback to a synchronous method

View file

@ -19,6 +19,12 @@ package im.vector.matrix.android.internal.crypto.gossiping
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.SasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationMethod
import im.vector.matrix.android.api.session.crypto.verification.VerificationService
import im.vector.matrix.android.api.session.crypto.verification.VerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
@ -28,7 +34,11 @@ import im.vector.matrix.android.common.TestConstants
import im.vector.matrix.android.internal.crypto.GossipingRequestState
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestState
import im.vector.matrix.android.internal.crypto.crosssigning.DeviceTrustLevel
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.android.internal.crypto.model.rest.UserPasswordAuth
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
@ -174,4 +184,85 @@ class KeyShareTests : InstrumentedTest {
mTestHelper.signOutAndClose(aliceSession)
mTestHelper.signOutAndClose(aliceSession2)
}
@Test
fun test_ShareSSSSSecret() {
val aliceSession1 = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true))
mTestHelper.doSync<Unit> {
aliceSession1.cryptoService().crossSigningService()
.initializeCrossSigning(UserPasswordAuth(
user = aliceSession1.myUserId,
password = TestConstants.PASSWORD
), it)
}
val aliceSession2 = mTestHelper.logIntoAccount(aliceSession1.myUserId, SessionTestParams(true))
val aliceVerificationService1 = aliceSession1.cryptoService().verificationService()
val aliceVerificationService2 = aliceSession2.cryptoService().verificationService()
// force keys download
mTestHelper.doSync<MXUsersDevicesMap<CryptoDeviceInfo>> {
aliceSession1.cryptoService().downloadKeys(listOf(aliceSession1.myUserId), true, it)
}
mTestHelper.doSync<MXUsersDevicesMap<CryptoDeviceInfo>> {
aliceSession2.cryptoService().downloadKeys(listOf(aliceSession2.myUserId), true, it)
}
var session1ShortCode: String? = null
var session2ShortCode: String? = null
aliceVerificationService1.addListener(object : VerificationService.Listener {
override fun transactionUpdated(tx: VerificationTransaction) {
Log.d("#TEST", "AA: tx incoming?:${tx.isIncoming} state ${tx.state}")
if (tx is SasVerificationTransaction) {
if (tx.state == VerificationTxState.OnStarted) {
(tx as IncomingSasVerificationTransaction).performAccept()
}
if (tx.state == VerificationTxState.ShortCodeReady) {
session1ShortCode = tx.getDecimalCodeRepresentation()
tx.userHasVerifiedShortCode()
}
}
}
})
aliceVerificationService2.addListener(object : VerificationService.Listener {
override fun transactionUpdated(tx: VerificationTransaction) {
Log.d("#TEST", "BB: tx incoming?:${tx.isIncoming} state ${tx.state}")
if (tx is SasVerificationTransaction) {
if (tx.state == VerificationTxState.ShortCodeReady) {
session2ShortCode = tx.getDecimalCodeRepresentation()
tx.userHasVerifiedShortCode()
}
}
}
})
val txId: String = "m.testVerif12"
aliceVerificationService2.beginKeyVerification(VerificationMethod.SAS, aliceSession1.myUserId, aliceSession1.sessionParams.credentials.deviceId
?: "", txId)
mTestHelper.waitWithLatch { latch ->
mTestHelper.retryPeriodicallyWithLatch(latch) {
aliceSession1.cryptoService().getDeviceInfo(aliceSession1.myUserId, aliceSession2.sessionParams.credentials.deviceId ?: "")?.isVerified == true
}
}
assertNotNull(session1ShortCode)
Log.d("#TEST", "session1ShortCode: $session1ShortCode")
assertNotNull(session2ShortCode)
Log.d("#TEST", "session2ShortCode: $session2ShortCode")
assertEquals(session1ShortCode, session2ShortCode)
// SSK and USK private keys should have been shared
mTestHelper.waitWithLatch(60_000) { latch ->
mTestHelper.retryPeriodicallyWithLatch(latch) {
Log.d("#TEST", "CAN XS :${ aliceSession2.cryptoService().crossSigningService().getMyCrossSigningKeys()}")
aliceSession2.cryptoService().crossSigningService().canCrossSign()
}
}
}
}

View file

@ -16,6 +16,7 @@
package im.vector.matrix.android.api.session.content
import android.net.Uri
import android.os.Parcelable
import androidx.exifinterface.media.ExifInterface
import kotlinx.android.parcel.Parcelize
@ -29,8 +30,7 @@ data class ContentAttachmentData(
val width: Long? = 0,
val exifOrientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
val name: String? = null,
val queryUri: String,
val path: String,
val queryUri: Uri,
private val mimeType: String?,
val type: Type
) : Parcelable {

View file

@ -60,6 +60,8 @@ interface VerificationService {
roomId: String,
localId: String? = LocalEcho.createLocalEchoId()): PendingVerificationRequest
fun cancelVerificationRequest(request: PendingVerificationRequest)
/**
* Request a key verification from another user using toDevice events.
*/

View file

@ -140,7 +140,7 @@ internal class DefaultCryptoService @Inject constructor(
private val crossSigningService: DefaultCrossSigningService,
//
private val incomingRoomKeyRequestManager: IncomingRoomKeyRequestManager,
private val incomingGossipingRequestManager: IncomingGossipingRequestManager,
//
private val outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
// Actions
@ -239,7 +239,7 @@ internal class DefaultCryptoService @Inject constructor(
override fun getDevicesList(callback: MatrixCallback<DevicesListResponse>) {
getDevicesTask
.configureWith {
this.executionThread = TaskThread.CRYPTO
// this.executionThread = TaskThread.CRYPTO
this.callback = callback
}
.executeBy(taskExecutor)
@ -317,7 +317,7 @@ internal class DefaultCryptoService @Inject constructor(
deviceListManager.invalidateAllDeviceLists()
deviceListManager.refreshOutdatedDeviceLists()
} else {
incomingRoomKeyRequestManager.processReceivedGossipingRequests()
incomingGossipingRequestManager.processReceivedGossipingRequests()
}
}.fold(
{
@ -376,7 +376,7 @@ internal class DefaultCryptoService @Inject constructor(
// Make sure we process to-device messages before generating new one-time-keys #2782
deviceListManager.refreshOutdatedDeviceLists()
oneTimeKeysUploader.maybeUploadOneTimeKeys()
incomingRoomKeyRequestManager.processReceivedGossipingRequests()
incomingGossipingRequestManager.processReceivedGossipingRequests()
}
}
}
@ -709,7 +709,7 @@ internal class DefaultCryptoService @Inject constructor(
// save audit trail
cryptoStore.saveGossipingEvent(event)
// Requests are stacked, and will be handled one by one at the end of the sync (onSyncComplete)
incomingRoomKeyRequestManager.onGossipingRequestEvent(event)
incomingGossipingRequestManager.onGossipingRequestEvent(event)
}
EventType.SEND_SECRET -> {
cryptoStore.saveGossipingEvent(event)
@ -729,30 +729,30 @@ internal class DefaultCryptoService @Inject constructor(
*/
private fun onRoomKeyEvent(event: Event) {
val roomKeyContent = event.getClearContent().toModel<RoomKeyContent>() ?: return
Timber.v("## onRoomKeyEvent() : type<${event.type}> , sessionId<${roomKeyContent.sessionId}>")
Timber.v("## GOSSIP onRoomKeyEvent() : type<${event.type}> , sessionId<${roomKeyContent.sessionId}>")
if (roomKeyContent.roomId.isNullOrEmpty() || roomKeyContent.algorithm.isNullOrEmpty()) {
Timber.e("## onRoomKeyEvent() : missing fields")
Timber.e("## GOSSIP onRoomKeyEvent() : missing fields")
return
}
val alg = roomDecryptorProvider.getOrCreateRoomDecryptor(roomKeyContent.roomId, roomKeyContent.algorithm)
if (alg == null) {
Timber.e("## onRoomKeyEvent() : Unable to handle keys for ${roomKeyContent.algorithm}")
Timber.e("## GOSSIP onRoomKeyEvent() : Unable to handle keys for ${roomKeyContent.algorithm}")
return
}
alg.onRoomKeyEvent(event, keysBackupService)
}
private fun onSecretSendReceived(event: Event) {
Timber.i("## onSecretSend() : onSecretSendReceived ${event.content?.get("sender_key")}")
Timber.i("## GOSSIP onSecretSend() : onSecretSendReceived ${event.content?.get("sender_key")}")
if (!event.isEncrypted()) {
// secret send messages must be encrypted
Timber.e("## onSecretSend() :Received unencrypted secret send event")
Timber.e("## GOSSIP onSecretSend() :Received unencrypted secret send event")
return
}
// Was that sent by us?
if (event.senderId != credentials.userId) {
Timber.e("## onSecretSend() : Ignore secret from other user ${event.senderId}")
Timber.e("## GOSSIP onSecretSend() : Ignore secret from other user ${event.senderId}")
return
}
@ -762,7 +762,7 @@ internal class DefaultCryptoService @Inject constructor(
.getOutgoingSecretKeyRequests().firstOrNull { it.requestId == secretContent.requestId }
if (existingRequest == null) {
Timber.i("## onSecretSend() : Ignore secret that was not requested: ${secretContent.requestId}")
Timber.i("## GOSSIP onSecretSend() : Ignore secret that was not requested: ${secretContent.requestId}")
return
}
@ -1111,7 +1111,7 @@ internal class DefaultCryptoService @Inject constructor(
* @param listener listener
*/
override fun addRoomKeysRequestListener(listener: GossipingRequestListener) {
incomingRoomKeyRequestManager.addRoomKeysRequestListener(listener)
incomingGossipingRequestManager.addRoomKeysRequestListener(listener)
}
/**
@ -1120,7 +1120,7 @@ internal class DefaultCryptoService @Inject constructor(
* @param listener listener
*/
override fun removeRoomKeysRequestListener(listener: GossipingRequestListener) {
incomingRoomKeyRequestManager.removeRoomKeysRequestListener(listener)
incomingGossipingRequestManager.removeRoomKeysRequestListener(listener)
}
/**

View file

@ -25,7 +25,9 @@ enum class GossipingRequestState {
NONE,
PENDING,
REJECTED,
ACCEPTING,
ACCEPTED,
FAILED_TO_ACCEPTED,
// USER_REJECTED,
UNABLE_TO_PROCESS,
CANCELLED_BY_REQUESTER,

View file

@ -0,0 +1,57 @@
/*
* 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
import androidx.work.BackoffPolicy
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequest
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.worker.startChain
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@SessionScope
internal class GossipingWorkManager @Inject constructor(
private val workManagerProvider: WorkManagerProvider
) {
inline fun <reified W : ListenableWorker> createWork(data: Data, startChain: Boolean): OneTimeWorkRequest {
return workManagerProvider.matrixOneTimeWorkRequestBuilder<W>()
.setConstraints(WorkManagerProvider.workConstraints)
.startChain(startChain)
.setInputData(data)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10_000L, TimeUnit.MILLISECONDS)
.build()
}
// Prevent sending queue to stay broken after app restart
// The unique queue id will stay the same as long as this object is instanciated
val queueSuffixApp = System.currentTimeMillis()
fun postWork(workRequest: OneTimeWorkRequest, policy: ExistingWorkPolicy = ExistingWorkPolicy.APPEND): Cancelable {
workManagerProvider.workManager
.beginUniqueWork(this::class.java.name + "_$queueSuffixApp", policy, workRequest)
.enqueue()
return CancelableWork(workManagerProvider.workManager, workRequest.id)
}
}

View file

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.crypto
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.auth.data.sessionId
import im.vector.matrix.android.api.crypto.MXCryptoConfig
import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
@ -27,16 +28,19 @@ import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.internal.crypto.model.rest.GossipingDefaultContent
import im.vector.matrix.android.internal.crypto.model.rest.GossipingToDeviceObject
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import timber.log.Timber
import javax.inject.Inject
@SessionScope
internal class IncomingRoomKeyRequestManager @Inject constructor(
internal class IncomingGossipingRequestManager @Inject constructor(
@SessionId private val sessionId: String,
private val credentials: Credentials,
private val cryptoStore: IMXCryptoStore,
private val cryptoConfig: MXCryptoConfig,
private val secretSecretCryptoProvider: ShareSecretCryptoProvider,
private val gossipingWorkManager: GossipingWorkManager,
private val roomDecryptorProvider: RoomDecryptorProvider) {
// list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
@ -51,6 +55,32 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
receivedGossipingRequests.addAll(cryptoStore.getPendingIncomingGossipingRequests())
}
// Recently verified devices (map of deviceId and timestamp)
private val recentlyVerifiedDevices = HashMap<String, Long>()
/**
* Called when a session has been verified.
* This information can be used by the manager to decide whether or not to fullfil gossiping requests
*/
fun onVerificationCompleteForDevice(deviceId: String) {
// For now we just keep an in memory cache
synchronized(recentlyVerifiedDevices) {
recentlyVerifiedDevices[deviceId] = System.currentTimeMillis()
}
}
private fun hasBeenVerifiedLessThanFiveMinutesFromNow(deviceId: String): Boolean {
val verifTimestamp: Long?
synchronized(recentlyVerifiedDevices) {
verifTimestamp = recentlyVerifiedDevices[deviceId]
}
if (verifTimestamp == null) return false
val age = System.currentTimeMillis() - verifTimestamp
return age < FIVE_MINUTES_IN_MILLIS
}
/**
* Called when we get an m.room_key_request event
* It must be called on CryptoThread
@ -58,7 +88,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
* @param event the announcement event.
*/
fun onGossipingRequestEvent(event: Event) {
Timber.v("## onGossipingRequestEvent type ${event.type} from user ${event.senderId}")
Timber.v("## GOSSIP onGossipingRequestEvent type ${event.type} from user ${event.senderId}")
val roomKeyShare = event.getClearContent().toModel<GossipingDefaultContent>()
val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
when (roomKeyShare?.action) {
@ -67,7 +97,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
IncomingSecretShareRequest.fromEvent(event)?.let {
if (event.senderId == credentials.userId && it.deviceId == credentials.deviceId) {
// ignore, it was sent by me as *
Timber.v("## onGossipingRequestEvent type ${event.type} ignore remote echo")
Timber.v("## GOSSIP onGossipingRequestEvent type ${event.type} ignore remote echo")
} else {
// save in DB
cryptoStore.storeIncomingGossipingRequest(it, ageLocalTs)
@ -78,7 +108,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
IncomingRoomKeyRequest.fromEvent(event)?.let {
if (event.senderId == credentials.userId && it.deviceId == credentials.deviceId) {
// ignore, it was sent by me as *
Timber.v("## onGossipingRequestEvent type ${event.type} ignore remote echo")
Timber.v("## GOSSIP onGossipingRequestEvent type ${event.type} ignore remote echo")
} else {
cryptoStore.storeIncomingGossipingRequest(it, ageLocalTs)
receivedGossipingRequests.add(it)
@ -92,7 +122,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
}
}
else -> {
Timber.e("## onGossipingRequestEvent() : unsupported action ${roomKeyShare?.action}")
Timber.e("## GOSSIP onGossipingRequestEvent() : unsupported action ${roomKeyShare?.action}")
}
}
}
@ -103,8 +133,6 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
* It must be called on CryptoThread
*/
fun processReceivedGossipingRequests() {
Timber.v("## processReceivedGossipingRequests()")
val roomKeyRequestsToProcess = receivedGossipingRequests.toList()
receivedGossipingRequests.clear()
for (request in roomKeyRequestsToProcess) {
@ -125,7 +153,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
}
receivedRequestCancellations?.forEach { request ->
Timber.v("## processReceivedGossipingRequests() : m.room_key_request cancellation $request")
Timber.v("## GOSSIP processReceivedGossipingRequests() : m.room_key_request cancellation $request")
// we should probably only notify the app of cancellations we told it
// about, but we don't currently have a record of that, so we just pass
// everything through.
@ -154,10 +182,10 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
val roomId = body!!.roomId
val alg = body.algorithm
Timber.v("## processIncomingRoomKeyRequest from $userId:$deviceId for $roomId / ${body.sessionId} id ${request.requestId}")
Timber.v("## GOSSIP processIncomingRoomKeyRequest from $userId:$deviceId for $roomId / ${body.sessionId} id ${request.requestId}")
if (userId == null || credentials.userId != userId) {
// TODO: determine if we sent this device the keys already: in
Timber.w("## processReceivedGossipingRequests() : Ignoring room key request from other user for now")
Timber.w("## GOSSIP processReceivedGossipingRequests() : Ignoring room key request from other user for now")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
@ -166,18 +194,18 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
// the keys for the requested events, and can drop the requests.
val decryptor = roomDecryptorProvider.getRoomDecryptor(roomId, alg)
if (null == decryptor) {
Timber.w("## processReceivedGossipingRequests() : room key request for unknown $alg in room $roomId")
Timber.w("## GOSSIP processReceivedGossipingRequests() : room key request for unknown $alg in room $roomId")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
if (!decryptor.hasKeysForKeyRequest(request)) {
Timber.w("## processReceivedGossipingRequests() : room key request for unknown session ${body.sessionId!!}")
Timber.w("## GOSSIP processReceivedGossipingRequests() : room key request for unknown session ${body.sessionId!!}")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
if (credentials.deviceId == deviceId && credentials.userId == userId) {
Timber.v("## processReceivedGossipingRequests() : oneself device - ignored")
Timber.v("## GOSSIP processReceivedGossipingRequests() : oneself device - ignored")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
@ -192,13 +220,13 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
val device = cryptoStore.getUserDevice(userId, deviceId!!)
if (device != null) {
if (device.isVerified) {
Timber.v("## processReceivedGossipingRequests() : device is already verified: sharing keys")
Timber.v("## GOSSIP processReceivedGossipingRequests() : device is already verified: sharing keys")
request.share?.run()
return
}
if (device.isBlocked) {
Timber.v("## processReceivedGossipingRequests() : device is blocked -> ignored")
Timber.v("## GOSSIP processReceivedGossipingRequests() : device is blocked -> ignored")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
@ -219,30 +247,30 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
private fun processIncomingSecretShareRequest(request: IncomingSecretShareRequest) {
val secretName = request.secretName ?: return Unit.also {
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
Timber.v("## processIncomingSecretShareRequest() : Missing secret name")
Timber.v("## GOSSIP processIncomingSecretShareRequest() : Missing secret name")
}
val userId = request.userId
if (userId == null || credentials.userId != userId) {
Timber.e("## processIncomingSecretShareRequest() : Ignoring secret share request from other users")
Timber.e("## GOSSIP processIncomingSecretShareRequest() : Ignoring secret share request from other users")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
val deviceId = request.deviceId
?: return Unit.also {
Timber.e("## processIncomingSecretShareRequest() : Malformed request, no ")
Timber.e("## GOSSIP processIncomingSecretShareRequest() : Malformed request, no ")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
}
val device = cryptoStore.getUserDevice(userId, deviceId)
?: return Unit.also {
Timber.e("## processIncomingSecretShareRequest() : Received secret share request from unknown device ${request.deviceId}")
Timber.e("## GOSSIP processIncomingSecretShareRequest() : Received secret share request from unknown device ${request.deviceId}")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
}
if (!device.isVerified || device.isBlocked) {
Timber.v("## processIncomingSecretShareRequest() : Ignoring secret share request from untrusted/blocked session $device")
Timber.v("## GOSSIP processIncomingSecretShareRequest() : Ignoring secret share request from untrusted/blocked session $device")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
return
}
@ -255,11 +283,20 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
USER_SIGNING_KEY_SSSS_NAME -> cryptoStore.getCrossSigningPrivateKeys()?.user
else -> null
}?.let { secretValue ->
// TODO check if locally trusted and not outdated
Timber.i("## processIncomingSecretShareRequest() : Sharing secret $secretName with $device locally trusted")
if (isDeviceLocallyVerified == true) {
secretSecretCryptoProvider.shareSecretWithDevice(request, secretValue)
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.ACCEPTED)
Timber.i("## GOSSIP processIncomingSecretShareRequest() : Sharing secret $secretName with $device locally trusted")
if (isDeviceLocallyVerified == true && hasBeenVerifiedLessThanFiveMinutesFromNow(deviceId)) {
val params = SendGossipWorker.Params(
sessionId = sessionId,
secretValue = secretValue,
request = request
)
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.ACCEPTING)
val workRequest = gossipingWorkManager.createWork<SendGossipWorker>(WorkerParamsFactory.toData(params), true)
gossipingWorkManager.postWork(workRequest)
} else {
Timber.v("## GOSSIP processIncomingSecretShareRequest() : Can't share secret $secretName with $device, verification too old")
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.REJECTED)
}
return
}
@ -269,7 +306,16 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
}
request.share = { secretValue ->
secretSecretCryptoProvider.shareSecretWithDevice(request, secretValue)
val params = SendGossipWorker.Params(
sessionId = userId,
secretValue = secretValue,
request = request
)
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.ACCEPTING)
val workRequest = gossipingWorkManager.createWork<SendGossipWorker>(WorkerParamsFactory.toData(params), true)
gossipingWorkManager.postWork(workRequest)
cryptoStore.updateGossipingRequestState(request, GossipingRequestState.ACCEPTED)
}
@ -304,7 +350,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
return
}
} catch (e: Exception) {
Timber.e(e, "## onRoomKeyRequest() failed")
Timber.e(e, "## GOSSIP onRoomKeyRequest() failed")
}
}
}
@ -323,7 +369,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
try {
listener.onRoomKeyRequestCancellation(request)
} catch (e: Exception) {
Timber.e(e, "## onRoomKeyRequestCancellation() failed")
Timber.e(e, "## GOSSIP onRoomKeyRequestCancellation() failed")
}
}
}
@ -340,4 +386,8 @@ internal class IncomingRoomKeyRequestManager @Inject constructor(
gossipingRequestListeners.remove(listener)
}
}
companion object {
private const val FIVE_MINUTES_IN_MILLIS = 5 * 60 * 1000
}
}

View file

@ -59,9 +59,6 @@ internal class MXOlmDevice @Inject constructor(
var deviceEd25519Key: String? = null
private set
// The OLM lib account instance.
private var olmAccount: OlmAccount? = null
// The OLM lib utility instance.
private var olmUtility: OlmUtility? = null
@ -86,19 +83,10 @@ internal class MXOlmDevice @Inject constructor(
init {
// Retrieve the account from the store
olmAccount = store.getAccount()
if (null == olmAccount) {
Timber.v("MXOlmDevice : create a new olm account")
// Else, create it
try {
olmAccount = OlmAccount()
store.storeAccount(olmAccount!!)
} catch (e: Exception) {
Timber.e(e, "MXOlmDevice : cannot initialize olmAccount")
}
} else {
Timber.v("MXOlmDevice : use an existing account")
try {
store.getOrCreateOlmAccount()
} catch (e: Exception) {
Timber.e(e, "MXOlmDevice : cannot initialize olmAccount")
}
try {
@ -109,13 +97,13 @@ internal class MXOlmDevice @Inject constructor(
}
try {
deviceCurve25519Key = olmAccount!!.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY]
deviceCurve25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY]
} catch (e: Exception) {
Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_IDENTITY_KEY} with error")
}
try {
deviceEd25519Key = olmAccount!!.identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY]
deviceEd25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY]
} catch (e: Exception) {
Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_FINGER_PRINT_KEY} with error")
}
@ -126,7 +114,7 @@ internal class MXOlmDevice @Inject constructor(
*/
fun getOneTimeKeys(): Map<String, Map<String, String>>? {
try {
return olmAccount!!.oneTimeKeys()
return store.getOlmAccount().oneTimeKeys()
} catch (e: Exception) {
Timber.e(e, "## getOneTimeKeys() : failed")
}
@ -138,14 +126,13 @@ internal class MXOlmDevice @Inject constructor(
* @return The maximum number of one-time keys the olm account can store.
*/
fun getMaxNumberOfOneTimeKeys(): Long {
return olmAccount?.maxOneTimeKeys() ?: -1
return store.getOlmAccount().maxOneTimeKeys()
}
/**
* Release the instance
*/
fun release() {
olmAccount?.releaseAccount()
olmUtility?.releaseUtility()
}
@ -157,7 +144,7 @@ internal class MXOlmDevice @Inject constructor(
*/
fun signMessage(message: String): String? {
try {
return olmAccount!!.signMessage(message)
return store.getOlmAccount().signMessage(message)
} catch (e: Exception) {
Timber.e(e, "## signMessage() : failed")
}
@ -170,8 +157,8 @@ internal class MXOlmDevice @Inject constructor(
*/
fun markKeysAsPublished() {
try {
olmAccount!!.markOneTimeKeysAsPublished()
store.storeAccount(olmAccount!!)
store.getOlmAccount().markOneTimeKeysAsPublished()
store.saveOlmAccount()
} catch (e: Exception) {
Timber.e(e, "## markKeysAsPublished() : failed")
}
@ -184,8 +171,8 @@ internal class MXOlmDevice @Inject constructor(
*/
fun generateOneTimeKeys(numKeys: Int) {
try {
olmAccount!!.generateOneTimeKeys(numKeys)
store.storeAccount(olmAccount!!)
store.getOlmAccount().generateOneTimeKeys(numKeys)
store.saveOlmAccount()
} catch (e: Exception) {
Timber.e(e, "## generateOneTimeKeys() : failed")
}
@ -205,7 +192,7 @@ internal class MXOlmDevice @Inject constructor(
try {
olmSession = OlmSession()
olmSession.initOutboundSession(olmAccount!!, theirIdentityKey, theirOneTimeKey)
olmSession.initOutboundSession(store.getOlmAccount(), theirIdentityKey, theirOneTimeKey)
val olmSessionWrapper = OlmSessionWrapper(olmSession, 0)
@ -245,7 +232,7 @@ internal class MXOlmDevice @Inject constructor(
try {
try {
olmSession = OlmSession()
olmSession.initInboundSessionFrom(olmAccount!!, theirDeviceIdentityKey, ciphertext)
olmSession.initInboundSessionFrom(store.getOlmAccount(), theirDeviceIdentityKey, ciphertext)
} catch (e: Exception) {
Timber.e(e, "## createInboundSession() : the session creation failed")
return null
@ -254,8 +241,8 @@ internal class MXOlmDevice @Inject constructor(
Timber.v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}")
try {
olmAccount!!.removeOneTimeKeys(olmSession)
store.storeAccount(olmAccount!!)
store.getOlmAccount().removeOneTimeKeys(olmSession)
store.saveOlmAccount()
} catch (e: Exception) {
Timber.e(e, "## createInboundSession() : removeOneTimeKeys failed")
}

View file

@ -17,27 +17,17 @@
package im.vector.matrix.android.internal.crypto
import androidx.work.BackoffPolicy
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequest
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.startChain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@SessionScope
@ -46,7 +36,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
private val cryptoStore: IMXCryptoStore,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val cryptoCoroutineScope: CoroutineScope,
private val workManagerProvider: WorkManagerProvider) {
private val gossipingWorkManager: GossipingWorkManager) {
/**
* Send off a room key request, if we haven't already done so.
@ -65,7 +55,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
cryptoStore.getOrAddOutgoingRoomKeyRequest(requestBody, recipients)?.let {
// Don't resend if it's already done, you need to cancel first (reRequest)
if (it.state == OutgoingGossipingRequestState.SENDING || it.state == OutgoingGossipingRequestState.SENT) {
Timber.v("## sendOutgoingRoomKeyRequest() : we already request for that session: $it")
Timber.v("## GOSSIP sendOutgoingRoomKeyRequest() : we already request for that session: $it")
return@launch
}
@ -82,7 +72,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
cryptoStore.getOrAddOutgoingSecretShareRequest(secretName, recipients)?.let {
// TODO check if there is already one that is being sent?
if (it.state == OutgoingGossipingRequestState.SENDING || it.state == OutgoingGossipingRequestState.SENT) {
Timber.v("## sendOutgoingRoomKeyRequest() : we already request for that session: $it")
Timber.v("## GOSSIP sendSecretShareRequest() : we already request for that session: $it")
return@launch
}
@ -123,7 +113,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
val req = cryptoStore.getOutgoingRoomKeyRequest(requestBody)
?: // no request was made for this key
return Unit.also {
Timber.v("## cancelRoomKeyRequest() Unknown request")
Timber.v("## GOSSIP cancelRoomKeyRequest() Unknown request")
}
sendOutgoingRoomKeyRequestCancellation(req, andResend)
@ -135,7 +125,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
* @param request the request
*/
private fun sendOutgoingGossipingRequest(request: OutgoingGossipingRequest) {
Timber.v("## sendOutgoingRoomKeyRequest() : Requesting keys $request")
Timber.v("## GOSSIP sendOutgoingRoomKeyRequest() : Requesting keys $request")
val params = SendGossipRequestWorker.Params(
sessionId = sessionId,
@ -143,8 +133,8 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
secretShareRequest = request as? OutgoingSecretRequest
)
cryptoStore.updateOutgoingGossipingRequestState(request.requestId, OutgoingGossipingRequestState.SENDING)
val workRequest = createWork<SendGossipRequestWorker>(WorkerParamsFactory.toData(params), true)
postWork(workRequest)
val workRequest = gossipingWorkManager.createWork<SendGossipRequestWorker>(WorkerParamsFactory.toData(params), true)
gossipingWorkManager.postWork(workRequest)
}
/**
@ -157,33 +147,16 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
val params = CancelGossipRequestWorker.Params.fromRequest(sessionId, request)
cryptoStore.updateOutgoingGossipingRequestState(request.requestId, OutgoingGossipingRequestState.CANCELLING)
val workRequest = createWork<CancelGossipRequestWorker>(WorkerParamsFactory.toData(params), true)
postWork(workRequest)
val workRequest = gossipingWorkManager.createWork<CancelGossipRequestWorker>(WorkerParamsFactory.toData(params), true)
gossipingWorkManager.postWork(workRequest)
if (resend) {
val reSendParams = SendGossipRequestWorker.Params(
sessionId = sessionId,
keyShareRequest = request.copy(requestId = LocalEcho.createLocalEchoId())
)
val reSendWorkRequest = createWork<SendGossipRequestWorker>(WorkerParamsFactory.toData(reSendParams), true)
postWork(reSendWorkRequest)
val reSendWorkRequest = gossipingWorkManager.createWork<SendGossipRequestWorker>(WorkerParamsFactory.toData(reSendParams), true)
gossipingWorkManager.postWork(reSendWorkRequest)
}
}
private inline fun <reified W : ListenableWorker> createWork(data: Data, startChain: Boolean): OneTimeWorkRequest {
return workManagerProvider.matrixOneTimeWorkRequestBuilder<W>()
.setConstraints(WorkManagerProvider.workConstraints)
.startChain(startChain)
.setInputData(data)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10_000L, TimeUnit.MILLISECONDS)
.build()
}
private fun postWork(workRequest: OneTimeWorkRequest, policy: ExistingWorkPolicy = ExistingWorkPolicy.APPEND): Cancelable {
workManagerProvider.workManager
.beginUniqueWork(this::class.java.name, policy, workRequest)
.enqueue()
return CancelableWork(workManagerProvider.workManager, workRequest.id)
}
}

View file

@ -0,0 +1,141 @@
/*
* 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
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.failure.shouldBeRetried
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.internal.crypto.actions.EnsureOlmSessionsForDevicesAction
import im.vector.matrix.android.internal.crypto.actions.MessageEncrypter
import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.event.SecretSendEventContent
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
import javax.inject.Inject
internal class SendGossipWorker(context: Context,
params: WorkerParameters)
: CoroutineWorker(context, params) {
@JsonClass(generateAdapter = true)
internal data class Params(
val sessionId: String,
val secretValue: String,
val request: IncomingSecretShareRequest
)
@Inject lateinit var sendToDeviceTask: SendToDeviceTask
@Inject lateinit var cryptoStore: IMXCryptoStore
@Inject lateinit var eventBus: EventBus
@Inject lateinit var credentials: Credentials
@Inject lateinit var messageEncrypter: MessageEncrypter
@Inject lateinit var ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction
override suspend fun doWork(): Result {
val errorOutputData = Data.Builder().putBoolean("failed", true).build()
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success(errorOutputData)
val sessionComponent = getSessionComponent(params.sessionId)
?: return Result.success(errorOutputData).also {
// TODO, can this happen? should I update local echo?
Timber.e("Unknown Session, cannot send message, sessionId: ${params.sessionId}")
}
sessionComponent.inject(this)
val localId = LocalEcho.createLocalEchoId()
val eventType: String = EventType.SEND_SECRET
val toDeviceContent = SecretSendEventContent(
requestId = params.request.requestId ?: "",
secretValue = params.secretValue
)
val requestingUserId = params.request.userId ?: ""
val requestingDeviceId = params.request.deviceId ?: ""
val deviceInfo = cryptoStore.getUserDevice(requestingUserId, requestingDeviceId)
?: return Result.success(errorOutputData).also {
cryptoStore.updateGossipingRequestState(params.request, GossipingRequestState.FAILED_TO_ACCEPTED)
Timber.e("Unknown deviceInfo, cannot send message, sessionId: ${params.request.deviceId}")
}
val sendToDeviceMap = MXUsersDevicesMap<Any>()
val devicesByUser = mapOf(requestingUserId to listOf(deviceInfo))
val usersDeviceMap = ensureOlmSessionsForDevicesAction.handle(devicesByUser)
val olmSessionResult = usersDeviceMap.getObject(requestingUserId, requestingDeviceId)
if (olmSessionResult?.sessionId == null) {
// no session with this device, probably because there
// were no one-time keys.
return Result.success(errorOutputData).also {
cryptoStore.updateGossipingRequestState(params.request, GossipingRequestState.FAILED_TO_ACCEPTED)
Timber.e("no session with this device, probably because there were no one-time keys.")
}
}
val payloadJson = mapOf(
"type" to EventType.SEND_SECRET,
"content" to toDeviceContent.toContent()
)
try {
val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
sendToDeviceMap.setObject(requestingUserId, requestingDeviceId, encodedPayload)
} catch (failure: Throwable) {
Timber.e("## Fail to encrypt gossip + ${failure.localizedMessage}")
}
cryptoStore.saveGossipingEvent(Event(
type = eventType,
content = toDeviceContent.toContent(),
senderId = credentials.userId
).also {
it.ageLocalTs = System.currentTimeMillis()
})
try {
sendToDeviceTask.execute(
SendToDeviceTask.Params(
eventType = EventType.ENCRYPTED,
contentMap = sendToDeviceMap,
transactionId = localId
)
)
cryptoStore.updateGossipingRequestState(params.request, GossipingRequestState.ACCEPTED)
return Result.success()
} catch (exception: Throwable) {
return if (exception.shouldBeRetried()) {
Result.retry()
} else {
cryptoStore.updateGossipingRequestState(params.request, GossipingRequestState.FAILED_TO_ACCEPTED)
Result.success(errorOutputData)
}
}
}
}

View file

@ -1,74 +0,0 @@
/*
* 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
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.actions.MessageEncrypter
import im.vector.matrix.android.internal.crypto.algorithms.olm.MXOlmDecryptionFactory
import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.event.SecretSendEventContent
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
internal class ShareSecretCryptoProvider @Inject constructor(
val messageEncrypter: MessageEncrypter,
val sendToDeviceTask: SendToDeviceTask,
val deviceListManager: DeviceListManager,
private val olmDecryptionFactory: MXOlmDecryptionFactory,
val cryptoCoroutineScope: CoroutineScope,
val cryptoStore: IMXCryptoStore,
val coroutineDispatchers: MatrixCoroutineDispatchers
) {
fun shareSecretWithDevice(request: IncomingSecretShareRequest, secretValue: String) {
val userId = request.userId ?: return
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
runCatching { deviceListManager.downloadKeys(listOf(userId), false) }
.mapCatching {
val deviceId = request.deviceId
val deviceInfo = cryptoStore.getUserDevice(userId, deviceId ?: "") ?: throw RuntimeException()
Timber.i("## shareSecretWithDevice() : sharing secret ${request.secretName} with device $userId:$deviceId")
val payloadJson = mutableMapOf<String, Any>("type" to EventType.SEND_SECRET)
payloadJson["content"] = SecretSendEventContent(
requestId = request.requestId ?: "",
secretValue = secretValue
)
val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
val sendToDeviceMap = MXUsersDevicesMap<Any>()
sendToDeviceMap.setObject(userId, deviceId, encodedPayload)
Timber.i("## shareSecretWithDevice() : sending to $userId:$deviceId")
val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap)
sendToDeviceTask.execute(sendToDeviceParams)
}
}
}
fun decryptEvent(event: Event): MXEventDecryptionResult {
return runBlocking(coroutineDispatchers.crypto) {
olmDecryptionFactory.create().decryptEvent(event, ShareSecretCryptoProvider::class.java.name)
}
}
}

View file

@ -149,7 +149,7 @@ internal class MXMegolmDecryption(private val userId: String,
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
val senderDevice = encryptedEventContent?.deviceId ?: return
val recipients = if (event.senderId != userId) {
val recipients = if (event.senderId == userId) {
mapOf(
userId to listOf("*")
)

View file

@ -25,7 +25,6 @@ import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.crypto.DeviceListManager
import im.vector.matrix.android.internal.crypto.MXOlmDevice
import im.vector.matrix.android.internal.crypto.MyDeviceInfoHolder
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.model.CryptoCrossSigningKey
import im.vector.matrix.android.internal.crypto.model.KeyUsage
import im.vector.matrix.android.internal.crypto.model.rest.UploadSignatureQueryBuilder
@ -62,7 +61,6 @@ internal class DefaultCrossSigningService @Inject constructor(
private val taskExecutor: TaskExecutor,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val cryptoCoroutineScope: CoroutineScope,
private val outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
private val eventBus: EventBus) : CrossSigningService, DeviceListManager.UserDevicesUpdateListener {
private var olmUtility: OlmUtility? = null
@ -599,6 +597,7 @@ internal class DefaultCrossSigningService @Inject constructor(
override fun canCrossSign(): Boolean {
return checkSelfTrust().isVerified() && cryptoStore.getCrossSigningPrivateKeys()?.selfSigned != null
&& cryptoStore.getCrossSigningPrivateKeys()?.user != null
}
override fun trustUser(otherUserId: String, callback: MatrixCallback<Unit>) {
@ -770,7 +769,12 @@ internal class DefaultCrossSigningService @Inject constructor(
Timber.v("## CrossSigning - update trust for $otherUserId , verified=${it.isVerified()}")
setUserKeysAsTrusted(otherUserId, it.isVerified())
}
}
}
// now check device trust
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
userIds.forEach { otherUserId ->
// TODO if my keys have changes, i should recheck all devices of all users?
val devices = cryptoStore.getUserDeviceList(otherUserId)
devices?.forEach { device ->
@ -791,24 +795,22 @@ internal class DefaultCrossSigningService @Inject constructor(
}
private fun setUserKeysAsTrusted(otherUserId: String, trusted: Boolean) {
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
val currentTrust = cryptoStore.getCrossSigningInfo(otherUserId)?.isTrusted()
cryptoStore.setUserKeysAsTrusted(otherUserId, trusted)
// If it's me, recheck trust of all users and devices?
val users = ArrayList<String>()
if (otherUserId == userId && currentTrust != trusted) {
val currentTrust = cryptoStore.getCrossSigningInfo(otherUserId)?.isTrusted()
cryptoStore.setUserKeysAsTrusted(otherUserId, trusted)
// If it's me, recheck trust of all users and devices?
val users = ArrayList<String>()
if (otherUserId == userId && currentTrust != trusted) {
// reRequestAllPendingRoomKeyRequest()
cryptoStore.updateUsersTrust {
users.add(it)
checkUserTrust(it).isVerified()
}
cryptoStore.updateUsersTrust {
users.add(it)
checkUserTrust(it).isVerified()
}
users.forEach {
cryptoStore.getUserDeviceList(it)?.forEach { device ->
val updatedTrust = checkDeviceTrust(it, device.deviceId, device.trustLevel?.isLocallyVerified() ?: false)
Timber.v("## CrossSigning - update trust for device ${device.deviceId} of user $otherUserId , verified=$updatedTrust")
cryptoStore.setDeviceTrust(it, device.deviceId, updatedTrust.isCrossSignedVerified(), updatedTrust.isLocallyVerified())
}
users.forEach {
cryptoStore.getUserDeviceList(it)?.forEach { device ->
val updatedTrust = checkDeviceTrust(it, device.deviceId, device.trustLevel?.isLocallyVerified() ?: false)
Timber.v("## CrossSigning - update trust for device ${device.deviceId} of user $otherUserId , verified=$updatedTrust")
cryptoStore.setDeviceTrust(it, device.deviceId, updatedTrust.isCrossSignedVerified(), updatedTrust.isLocallyVerified())
}
}
}

View file

@ -272,7 +272,7 @@ internal class DefaultSharedSecretStorageService @Inject constructor(
val ivParameterSpec = IvParameterSpec(iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
// secret are not that big, just do Final
val cipherBytes = cipher.doFinal(clearDataBase64.fromBase64())
val cipherBytes = cipher.doFinal(clearDataBase64.toByteArray())
require(cipherBytes.isNotEmpty())
val macKeySpec = SecretKeySpec(macKey, "HmacSHA256")
@ -303,6 +303,15 @@ internal class DefaultSharedSecretStorageService @Inject constructor(
val cipherRawBytes = cipherContent.ciphertext?.fromBase64() ?: throw SharedSecretStorageError.BadCipherText
// Check Signature
val macKeySpec = SecretKeySpec(macKey, "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256").apply { init(macKeySpec) }
val digest = mac.doFinal(cipherRawBytes)
if (!cipherContent.mac?.fromBase64()?.contentEquals(digest).orFalse()) {
throw SharedSecretStorageError.BadMac
}
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
val secretKeySpec = SecretKeySpec(aesKey, "AES")
@ -313,17 +322,7 @@ internal class DefaultSharedSecretStorageService @Inject constructor(
require(decryptedSecret.isNotEmpty())
// Check Signature
val macKeySpec = SecretKeySpec(macKey, "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256").apply { init(macKeySpec) }
val digest = mac.doFinal(cipherRawBytes)
if (!cipherContent.mac?.fromBase64()?.contentEquals(digest).orFalse()) {
throw SharedSecretStorageError.BadMac
} else {
// we are good
return decryptedSecret.toBase64NoPadding()
}
return String(decryptedSecret, Charsets.UTF_8)
}
override fun getAlgorithmsForSecret(name: String): List<KeyInfoResult> {

View file

@ -49,7 +49,9 @@ internal interface IMXCryptoStore {
/**
* @return the olm account
*/
fun getAccount(): OlmAccount?
fun getOlmAccount(): OlmAccount
fun getOrCreateOlmAccount(): OlmAccount
/**
* Retrieve the known inbound group sessions.
@ -159,7 +161,7 @@ internal interface IMXCryptoStore {
*
* @param account the account to save
*/
fun storeAccount(account: OlmAccount)
fun saveOlmAccount()
/**
* Store a device for a user.

View file

@ -122,27 +122,7 @@ internal class RealmCryptoStore @Inject constructor(
.setRealmConfiguration(realmConfiguration)
.build()
/* ==========================================================================================
* Other data
* ========================================================================================== */
override fun hasData(): Boolean {
return doWithRealm(realmConfiguration) {
!it.isEmpty
// Check if there is a MetaData object
&& it.where<CryptoMetadataEntity>().count() > 0
}
}
override fun deleteStore() {
doRealmTransaction(realmConfiguration) {
it.deleteAll()
}
}
override fun open() {
realmLocker = Realm.getInstance(realmConfiguration)
init {
// Ensure CryptoMetadataEntity is inserted in DB
doRealmTransaction(realmConfiguration) { realm ->
var currentMetadata = realm.where<CryptoMetadataEntity>().findFirst()
@ -173,6 +153,27 @@ internal class RealmCryptoStore @Inject constructor(
}
}
}
/* ==========================================================================================
* Other data
* ========================================================================================== */
override fun hasData(): Boolean {
return doWithRealm(realmConfiguration) {
!it.isEmpty
// Check if there is a MetaData object
&& it.where<CryptoMetadataEntity>().count() > 0
}
}
override fun deleteStore() {
doRealmTransaction(realmConfiguration) {
it.deleteAll()
}
}
override fun open() {
realmLocker = Realm.getInstance(realmConfiguration)
}
override fun close() {
olmSessionsToRelease.forEach {
@ -203,20 +204,31 @@ internal class RealmCryptoStore @Inject constructor(
}?.deviceId ?: ""
}
override fun storeAccount(account: OlmAccount) {
olmAccount = account
override fun saveOlmAccount() {
doRealmTransaction(realmConfiguration) {
it.where<CryptoMetadataEntity>().findFirst()?.putOlmAccount(account)
it.where<CryptoMetadataEntity>().findFirst()?.putOlmAccount(olmAccount)
}
}
override fun getAccount(): OlmAccount? {
if (olmAccount == null) {
olmAccount = doRealmQueryAndCopy(realmConfiguration) { it.where<CryptoMetadataEntity>().findFirst() }?.getOlmAccount()
}
override fun getOlmAccount(): OlmAccount {
return olmAccount!!
}
return olmAccount
override fun getOrCreateOlmAccount(): OlmAccount {
doRealmTransaction(realmConfiguration) {
val metaData = it.where<CryptoMetadataEntity>().findFirst()
val existing = metaData!!.getOlmAccount()
if (existing == null) {
Timber.d("## Crypto Creating olm account")
val created = OlmAccount()
metaData.putOlmAccount(created)
olmAccount = created
} else {
Timber.d("## Crypto Access existing account")
olmAccount = existing
}
}
return olmAccount!!
}
override fun storeUserDevice(userId: String?, deviceInfo: CryptoDeviceInfo?) {

View file

@ -25,6 +25,7 @@ import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.convertToUTF8
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
import javax.inject.Inject
internal interface UploadKeysTask : Task<UploadKeysTask.Params, KeysUploadResponse> {
@ -50,6 +51,8 @@ internal class DefaultUploadKeysTask @Inject constructor(
oneTimeKeys = params.oneTimeKeys
)
Timber.i("## Uploading device keys -> $body")
return executeRequest(eventBus) {
apiCall = if (encodedDeviceId.isBlank()) {
cryptoApi.uploadKeys(body)

View file

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerif
import im.vector.matrix.android.api.session.crypto.verification.SasMode
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
@ -35,6 +36,7 @@ internal class DefaultIncomingSASDefaultVerificationTransaction(
private val cryptoStore: IMXCryptoStore,
crossSigningService: CrossSigningService,
outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
incomingGossipingRequestManager: IncomingGossipingRequestManager,
deviceFingerprint: String,
transactionId: String,
otherUserID: String,
@ -46,6 +48,7 @@ internal class DefaultIncomingSASDefaultVerificationTransaction(
cryptoStore,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
deviceFingerprint,
transactionId,
otherUserID,

View file

@ -20,6 +20,7 @@ import im.vector.matrix.android.api.session.crypto.verification.CancelCode
import im.vector.matrix.android.api.session.crypto.verification.OutgoingSasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
@ -32,6 +33,7 @@ internal class DefaultOutgoingSASDefaultVerificationTransaction(
cryptoStore: IMXCryptoStore,
crossSigningService: CrossSigningService,
outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
incomingGossipingRequestManager: IncomingGossipingRequestManager,
deviceFingerprint: String,
transactionId: String,
otherUserId: String,
@ -43,6 +45,7 @@ internal class DefaultOutgoingSASDefaultVerificationTransaction(
cryptoStore,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
deviceFingerprint,
transactionId,
otherUserId,

View file

@ -50,6 +50,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageVerificati
import im.vector.matrix.android.api.session.room.model.message.MessageVerificationStartContent
import im.vector.matrix.android.api.session.room.model.message.ValidVerificationDone
import im.vector.matrix.android.internal.crypto.DeviceListManager
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.MyDeviceInfoHolder
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
@ -90,6 +91,7 @@ internal class DefaultVerificationService @Inject constructor(
@DeviceId private val deviceId: String?,
private val cryptoStore: IMXCryptoStore,
private val outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
private val incomingGossipingRequestManager: IncomingGossipingRequestManager,
private val myDeviceInfoHolder: Lazy<MyDeviceInfoHolder>,
private val deviceListManager: DeviceListManager,
private val setDeviceVerificationAction: SetDeviceVerificationAction,
@ -454,7 +456,7 @@ internal class DefaultVerificationService @Inject constructor(
private suspend fun handleStart(otherUserId: String?,
startReq: ValidVerificationInfoStart,
txConfigure: (DefaultVerificationTransaction) -> Unit): CancelCode? {
Timber.d("## SAS onStartRequestReceived ${startReq.transactionId}")
Timber.d("## SAS onStartRequestReceived $startReq")
if (otherUserId?.let { checkKeysAreDownloaded(it, startReq.fromDevice) } != null) {
val tid = startReq.transactionId
var existing = getExistingTransaction(otherUserId, tid)
@ -466,15 +468,17 @@ internal class DefaultVerificationService @Inject constructor(
// smallest is used, and the other m.key.verification.start event is ignored.
// In the case of a single user verifying two of their devices, the device ID is
// compared instead .
if (existing != null && !existing.isIncoming) {
if (existing is DefaultOutgoingSASDefaultVerificationTransaction) {
val readyRequest = getExistingVerificationRequest(otherUserId, tid)
if (readyRequest?.isReady == true) {
if (isOtherPrioritary(otherUserId, existing.otherDeviceId ?: "")) {
Timber.d("## SAS concurrent start isOtherPrioritary, clear")
// The other is prioritary!
// I should replace my outgoing with an incoming
removeTransaction(otherUserId, tid)
existing = null
} else {
Timber.d("## SAS concurrent start i am prioritary, ignore")
// i am prioritary, ignore this start event!
return null
}
@ -530,6 +534,7 @@ internal class DefaultVerificationService @Inject constructor(
cryptoStore,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
myDeviceInfoHolder.get().myDevice.fingerprint()!!,
startReq.transactionId,
otherUserId,
@ -544,7 +549,7 @@ internal class DefaultVerificationService @Inject constructor(
existing.onStartReceived(startReq)
return null
} else {
Timber.w("## SAS onStartRequestReceived - unexpected message ${startReq.transactionId}")
Timber.w("## SAS onStartRequestReceived - unexpected message ${startReq.transactionId} / $existing")
return CancelCode.UnexpectedMessage
}
}
@ -754,6 +759,7 @@ internal class DefaultVerificationService @Inject constructor(
private suspend fun onReadyReceived(event: Event) {
val readyReq = event.getClearContent().toModel<KeyVerificationReady>()?.asValidObject()
Timber.v("## SAS onReadyReceived $readyReq")
if (readyReq == null || event.senderId == null) {
// ignore
@ -834,17 +840,18 @@ internal class DefaultVerificationService @Inject constructor(
if (readyReq.methods.contains(VERIFICATION_METHOD_RECIPROCATE)) {
// Create the pending transaction
val tx = DefaultQrCodeVerificationTransaction(
setDeviceVerificationAction,
readyReq.transactionId,
senderId,
readyReq.fromDevice,
crossSigningService,
outgoingGossipingRequestManager,
cryptoStore,
qrCodeData,
userId,
deviceId ?: "",
false)
setDeviceVerificationAction = setDeviceVerificationAction,
transactionId = readyReq.transactionId,
otherUserId = senderId,
otherDeviceId = readyReq.fromDevice,
crossSigningService = crossSigningService,
outgoingGossipingRequestManager = outgoingGossipingRequestManager,
incomingGossipingRequestManager = incomingGossipingRequestManager,
cryptoStore = cryptoStore,
qrCodeData = qrCodeData,
userId = userId,
deviceId = deviceId ?: "",
isIncoming = false)
tx.transport = transportCreator.invoke(tx)
@ -1001,13 +1008,11 @@ internal class DefaultVerificationService @Inject constructor(
}
private fun addTransaction(tx: DefaultVerificationTransaction) {
tx.otherUserId.let { otherUserId ->
synchronized(txMap) {
val txInnerMap = txMap.getOrPut(otherUserId) { HashMap() }
txInnerMap[tx.transactionId] = tx
dispatchTxAdded(tx)
tx.addListener(this)
}
synchronized(txMap) {
val txInnerMap = txMap.getOrPut(tx.otherUserId) { HashMap() }
txInnerMap[tx.transactionId] = tx
dispatchTxAdded(tx)
tx.addListener(this)
}
}
@ -1022,6 +1027,7 @@ internal class DefaultVerificationService @Inject constructor(
cryptoStore,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
myDeviceInfoHolder.get().myDevice.fingerprint()!!,
txID,
otherUserId,
@ -1096,6 +1102,18 @@ internal class DefaultVerificationService @Inject constructor(
return verificationRequest
}
override fun cancelVerificationRequest(request: PendingVerificationRequest) {
if (request.roomId != null) {
val transport = verificationTransportRoomMessageFactory.createTransport(request.roomId, null)
transport.cancelTransaction(request.transactionId ?: "", request.otherUserId, null, CancelCode.User)
} else {
val transport = verificationTransportToDeviceFactory.createTransport(null)
request.targetDevices?.forEach { deviceId ->
transport.cancelTransaction(request.transactionId ?: "", request.otherUserId, deviceId, CancelCode.User)
}
}
}
override fun requestKeyVerification(methods: List<VerificationMethod>, otherUserId: String, otherDevices: List<String>?): PendingVerificationRequest {
// TODO refactor this with the DM one
Timber.i("## Requesting verification to user: $otherUserId with device list $otherDevices")
@ -1198,6 +1216,7 @@ internal class DefaultVerificationService @Inject constructor(
cryptoStore,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
myDeviceInfoHolder.get().myDevice.fingerprint()!!,
transactionId,
otherUserId,
@ -1329,17 +1348,18 @@ internal class DefaultVerificationService @Inject constructor(
if (VERIFICATION_METHOD_RECIPROCATE in result) {
// Create the pending transaction
val tx = DefaultQrCodeVerificationTransaction(
setDeviceVerificationAction,
transactionId,
otherUserId,
otherDeviceId,
crossSigningService,
outgoingGossipingRequestManager,
cryptoStore,
qrCodeData,
userId,
deviceId ?: "",
false)
setDeviceVerificationAction = setDeviceVerificationAction,
transactionId = transactionId,
otherUserId = otherUserId,
otherDeviceId = otherDeviceId,
crossSigningService = crossSigningService,
outgoingGossipingRequestManager = outgoingGossipingRequestManager,
incomingGossipingRequestManager = incomingGossipingRequestManager,
cryptoStore = cryptoStore,
qrCodeData = qrCodeData,
userId = userId,
deviceId = deviceId ?: "",
isIncoming = false)
tx.transport = transportCreator.invoke(tx)

View file

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY
import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.verification.VerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
import im.vector.matrix.android.internal.crypto.crosssigning.DeviceTrustLevel
@ -33,6 +34,7 @@ internal abstract class DefaultVerificationTransaction(
private val setDeviceVerificationAction: SetDeviceVerificationAction,
private val crossSigningService: CrossSigningService,
private val outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
private val incomingGossipingRequestManager: IncomingGossipingRequestManager,
private val userId: String,
override val transactionId: String,
override val otherUserId: String,
@ -86,6 +88,8 @@ internal abstract class DefaultVerificationTransaction(
}
if (otherUserId == userId) {
incomingGossipingRequestManager.onVerificationCompleteForDevice(otherDeviceId!!)
// If me it's reasonable to sign and upload the device signature
// Notice that i might not have the private keys, so may not be able to do it
crossSigningService.trustDevice(otherDeviceId!!, object : MatrixCallback<Unit> {
@ -96,7 +100,7 @@ internal abstract class DefaultVerificationTransaction(
}
transport.done(transactionId) {
if (otherUserId == userId) {
if (otherUserId == userId && !crossSigningService.canCrossSign()) {
outgoingGossipingRequestManager.sendSecretShareRequest(SELF_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*")))
outgoingGossipingRequestManager.sendSecretShareRequest(USER_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*")))
}

View file

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.crypto.verification.SasMode
import im.vector.matrix.android.api.session.crypto.verification.SasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
import im.vector.matrix.android.internal.crypto.model.MXKey
@ -44,6 +45,7 @@ internal abstract class SASDefaultVerificationTransaction(
private val cryptoStore: IMXCryptoStore,
crossSigningService: CrossSigningService,
outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
incomingGossipingRequestManager: IncomingGossipingRequestManager,
private val deviceFingerprint: String,
transactionId: String,
otherUserId: String,
@ -53,6 +55,7 @@ internal abstract class SASDefaultVerificationTransaction(
setDeviceVerificationAction,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
userId,
transactionId,
otherUserId,

View file

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.crypto.verification.CancelCode
import im.vector.matrix.android.api.session.crypto.verification.QrCodeVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.OutgoingGossipingRequestManager
import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAction
import im.vector.matrix.android.internal.crypto.crosssigning.fromBase64
@ -38,6 +39,7 @@ internal class DefaultQrCodeVerificationTransaction(
override var otherDeviceId: String?,
private val crossSigningService: CrossSigningService,
outgoingGossipingRequestManager: OutgoingGossipingRequestManager,
incomingGossipingRequestManager: IncomingGossipingRequestManager,
private val cryptoStore: IMXCryptoStore,
// Not null only if other user is able to scan QR code
private val qrCodeData: QrCodeData?,
@ -48,6 +50,7 @@ internal class DefaultQrCodeVerificationTransaction(
setDeviceVerificationAction,
crossSigningService,
outgoingGossipingRequestManager,
incomingGossipingRequestManager,
userId,
transactionId,
otherUserId,

View file

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.internal.crypto.CancelGossipRequestWorker
import im.vector.matrix.android.internal.crypto.CryptoModule
import im.vector.matrix.android.internal.crypto.SendGossipRequestWorker
import im.vector.matrix.android.internal.crypto.SendGossipWorker
import im.vector.matrix.android.internal.crypto.verification.SendVerificationMessageWorker
import im.vector.matrix.android.internal.di.MatrixComponent
import im.vector.matrix.android.internal.di.SessionAssistedInjectModule
@ -109,8 +110,11 @@ internal interface SessionComponent {
fun inject(worker: SendVerificationMessageWorker)
fun inject(worker: SendGossipRequestWorker)
fun inject(worker: CancelGossipRequestWorker)
fun inject(worker: SendGossipWorker)
@Component.Factory
interface Factory {
fun create(

View file

@ -53,9 +53,9 @@ internal class FileUploader @Inject constructor(@Authenticated
suspend fun uploadByteArray(byteArray: ByteArray,
filename: String?,
mimeType: String,
mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
val uploadBody = byteArray.toRequestBody(mimeType.toMediaTypeOrNull())
val uploadBody = byteArray.toRequestBody(mimeType?.toMediaTypeOrNull())
return upload(uploadBody, filename, progressListener)
}

View file

@ -16,12 +16,12 @@
package im.vector.matrix.android.internal.session.content
import android.content.Context
import android.graphics.Bitmap
import android.media.ThumbnailUtils
import android.provider.MediaStore
import android.media.MediaMetadataRetriever
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.File
internal object ThumbnailExtractor {
@ -33,34 +33,40 @@ internal object ThumbnailExtractor {
val mimeType: String
)
fun extractThumbnail(attachment: ContentAttachmentData): ThumbnailData? {
val file = File(attachment.path)
if (!file.exists() || !file.isFile) {
return null
}
fun extractThumbnail(context: Context, attachment: ContentAttachmentData): ThumbnailData? {
return if (attachment.type == ContentAttachmentData.Type.VIDEO) {
extractVideoThumbnail(attachment)
extractVideoThumbnail(context, attachment)
} else {
null
}
}
private fun extractVideoThumbnail(attachment: ContentAttachmentData): ThumbnailData? {
val thumbnail = ThumbnailUtils.createVideoThumbnail(attachment.path, MediaStore.Video.Thumbnails.MINI_KIND) ?: return null
val outputStream = ByteArrayOutputStream()
thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val thumbnailWidth = thumbnail.width
val thumbnailHeight = thumbnail.height
val thumbnailSize = outputStream.size()
val thumbnailData = ThumbnailData(
width = thumbnailWidth,
height = thumbnailHeight,
size = thumbnailSize.toLong(),
bytes = outputStream.toByteArray(),
mimeType = "image/jpeg"
)
thumbnail.recycle()
outputStream.reset()
private fun extractVideoThumbnail(context: Context, attachment: ContentAttachmentData): ThumbnailData? {
var thumbnailData: ThumbnailData? = null
val mediaMetadataRetriever = MediaMetadataRetriever()
try {
mediaMetadataRetriever.setDataSource(context, attachment.queryUri)
val thumbnail = mediaMetadataRetriever.frameAtTime
val outputStream = ByteArrayOutputStream()
thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val thumbnailWidth = thumbnail.width
val thumbnailHeight = thumbnail.height
val thumbnailSize = outputStream.size()
thumbnailData = ThumbnailData(
width = thumbnailWidth,
height = thumbnailHeight,
size = thumbnailSize.toLong(),
bytes = outputStream.toByteArray(),
mimeType = "image/jpeg"
)
thumbnail.recycle()
outputStream.reset()
} catch (e: Exception) {
Timber.e(e, "Cannot extract video thumbnail")
} finally {
mediaMetadataRetriever.release()
}
return thumbnailData
}
}

View file

@ -17,12 +17,9 @@
package im.vector.matrix.android.internal.session.content
import android.content.Context
import android.graphics.BitmapFactory
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toContent
@ -41,8 +38,6 @@ import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import javax.inject.Inject
private data class NewImageAttributes(
@ -94,8 +89,90 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var newImageAttributes: NewImageAttributes? = null
val attachmentFile = try {
File(attachment.path)
try {
val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
?: return Result.success(
WorkerParamsFactory.toData(
params.copy(
lastFailureMessage = "Cannot openInputStream for file: " + attachment.queryUri.toString()
)
)
)
inputStream.use {
var uploadedThumbnailUrl: String? = null
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData ->
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
}
}
try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
} else {
fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
}
uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) {
Timber.e(t, "Thumbnail update failed")
}
}
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) {
if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(it, current, total)
}
}
}
}
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt file")
notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(inputStream, attachment.getSafeMimeType())
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
} else {
fileUploader
.uploadByteArray(inputStream.readBytes(), attachment.name, attachment.getSafeMimeType(), progressListener)
}
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
uploadedThumbnailUrl,
uploadedThumbnailEncryptedFileInfo,
newImageAttributes)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
}
}
} catch (e: Exception) {
Timber.e(e)
notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) }
@ -106,109 +183,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
)
)
)
}
.let { originalFile ->
if (attachment.type == ContentAttachmentData.Type.IMAGE) {
if (params.compressBeforeSending) {
Compressor.compress(context, originalFile) {
default(
width = MAX_IMAGE_SIZE,
height = MAX_IMAGE_SIZE
)
}.also { compressedFile ->
// Update the params
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(compressedFile.absolutePath, options)
val fileSize = compressedFile.length().toInt()
newImageAttributes = NewImageAttributes(
options.outWidth,
options.outHeight,
fileSize
)
}
} else {
// TODO Fix here the image rotation issue
originalFile
}
} else {
// Other type
originalFile
}
}
var uploadedThumbnailUrl: String? = null
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
ThumbnailExtractor.extractThumbnail(params.attachment)?.let { thumbnailData ->
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
}
}
try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
} else {
fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
}
uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) {
Timber.e(t)
return handleFailure(params, t)
}
}
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) {
if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(it, current, total)
}
}
}
}
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt file")
notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.getSafeMimeType())
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
} else {
fileUploader
.uploadFile(attachmentFile, attachment.name, attachment.getSafeMimeType(), progressListener)
}
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
uploadedThumbnailUrl,
uploadedThumbnailEncryptedFileInfo,
newImageAttributes)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
}
}

View file

@ -16,6 +16,7 @@
package im.vector.matrix.android.internal.session.room.send
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import androidx.exifinterface.media.ExifInterface
@ -74,6 +75,7 @@ import javax.inject.Inject
* The transactionId is used as loc
*/
internal class LocalEchoEventFactory @Inject constructor(
private val context: Context,
@UserId private val userId: String,
private val stringProvider: StringProvider,
private val textPillsUtils: TextPillsUtils,
@ -266,14 +268,14 @@ internal class LocalEchoEventFactory @Inject constructor(
height = height?.toInt() ?: 0,
size = attachment.size.toInt()
),
url = attachment.path
url = attachment.queryUri.toString()
)
return createEvent(roomId, content)
}
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event {
val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(attachment.path)
mediaDataRetriever.setDataSource(context, attachment.queryUri)
// Use frame to calculate height and width as we are sure to get the right ones
val firstFrame: Bitmap? = mediaDataRetriever.frameAtTime
@ -281,7 +283,7 @@ internal class LocalEchoEventFactory @Inject constructor(
val width = firstFrame?.width ?: 0
mediaDataRetriever.release()
val thumbnailInfo = ThumbnailExtractor.extractThumbnail(attachment)?.let {
val thumbnailInfo = ThumbnailExtractor.extractThumbnail(context, attachment)?.let {
ThumbnailInfo(
width = it.width,
height = it.height,
@ -299,10 +301,10 @@ internal class LocalEchoEventFactory @Inject constructor(
size = attachment.size,
duration = attachment.duration?.toInt() ?: 0,
// Glide will be able to use the local path and extract a thumbnail.
thumbnailUrl = attachment.path,
thumbnailUrl = attachment.queryUri.toString(),
thumbnailInfo = thumbnailInfo
),
url = attachment.path
url = attachment.queryUri.toString()
)
return createEvent(roomId, content)
}
@ -315,7 +317,7 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() } ?: "audio/mpeg",
size = attachment.size
),
url = attachment.path
url = attachment.queryUri.toString()
)
return createEvent(roomId, content)
}
@ -329,7 +331,7 @@ internal class LocalEchoEventFactory @Inject constructor(
?: "application/octet-stream",
size = attachment.size
),
url = attachment.path
url = attachment.queryUri.toString()
)
return createEvent(roomId, content)
}

View file

@ -128,7 +128,7 @@ internal class TimelineEventDecryptor @Inject constructor(
}
}
} catch (t: Throwable) {
Timber.e(t, "Failed to decrypt event $eventId")
Timber.e("Failed to decrypt event $eventId, ${t.localizedMessage}")
} finally {
synchronized(existingRequests) {
existingRequests.remove(request)

1
multipicker/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

56
multipicker/build.gradle Normal file
View file

@ -0,0 +1,56 @@
/*
* 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.
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 29
defaultConfig {
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'consumer-rules.pro'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
// Log
implementation 'com.jakewharton.timber:timber:4.7.1'
}

View file

21
multipicker/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="im.vector.riotx.multipicker">
<application>
<provider
android:name=".provider.MultiPickerFileProvider"
android:authorities="${applicationId}.multipicker.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/multipicker_provider_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,93 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerAudioType
/**
* Audio file picker implementation
*/
class AudioPicker(override val requestCode: Int) : Picker<MultiPickerAudioType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected audio files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerAudioType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val audioList = mutableListOf<MultiPickerAudioType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLong()
}
audioList.add(
MultiPickerAudioType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
duration
)
)
}
}
}
return audioList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "audio/*"
}
}
}

View file

@ -0,0 +1,130 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Implementation of taking a photo with Camera
*/
class CameraPicker(val requestCode: Int) {
/**
* Start camera by using an Activity
* @param activity Activity to handle onActivityResult().
* @return Uri of taken photo or null if the operation is cancelled.
*/
fun startWithExpectingFile(activity: Activity): Uri? {
val photoUri = createPhotoUri(activity)
val intent = createIntent().apply {
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}
activity.startActivityForResult(intent, requestCode)
return photoUri
}
/**
* Start camera by using a Fragment
* @param fragment Fragment to handle onActivityResult().
* @return Uri of taken photo or null if the operation is cancelled.
*/
fun startWithExpectingFile(fragment: Fragment): Uri? {
val photoUri = createPhotoUri(fragment.requireContext())
val intent = createIntent().apply {
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}
fragment.startActivityForResult(intent, requestCode)
return photoUri
}
/**
* Call this function from onActivityResult(int, int, Intent).
* @return Taken photo or null if request code is wrong
* or result code is not Activity.RESULT_OK
* or user cancelled the operation.
*/
fun getTakenPhoto(context: Context, requestCode: Int, resultCode: Int, photoUri: Uri): MultiPickerImageType? {
if (requestCode == this.requestCode && resultCode == Activity.RESULT_OK) {
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
photoUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, photoUri)
val orientation = ImageUtils.getOrientation(context, photoUri)
return MultiPickerImageType(
name,
size,
context.contentResolver.getType(photoUri),
photoUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
}
}
}
return null
}
private fun createIntent(): Intent {
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
}
private fun createPhotoUri(context: Context): Uri {
val file = createImageFile(context)
val authority = context.packageName + ".multipicker.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}
private fun createImageFile(context: Context): File {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val storageDir: File = context.filesDir
return File.createTempFile(
"${timeStamp}_", /* prefix */
".jpg", /* suffix */
storageDir /* directory */
)
}
}

View file

@ -0,0 +1,135 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import im.vector.riotx.multipicker.entity.MultiPickerContactType
/**
* Contact Picker implementation
*/
class ContactPicker(override val requestCode: Int) : Picker<MultiPickerContactType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected contact or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerContactType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val contactList = mutableListOf<MultiPickerContactType>()
data?.data?.let { selectedUri ->
val projection = arrayOf(
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.Contacts.PHOTO_URI,
ContactsContract.Contacts._ID
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndex(ContactsContract.Contacts._ID)
val nameColumn = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val photoUriColumn = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
val contactId = cursor.getInt(idColumn)
var name = cursor.getString(nameColumn)
var photoUri = cursor.getString(photoUriColumn)
var phoneNumberList = mutableListOf<String>()
var emailList = mutableListOf<String>()
getRawContactId(context.contentResolver, contactId)?.let { rawContactId ->
val selection = ContactsContract.Data.RAW_CONTACT_ID + " = ?"
val selectionArgs = arrayOf(rawContactId.toString())
context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
arrayOf(
ContactsContract.Data.MIMETYPE,
ContactsContract.Data.DATA1
),
selection,
selectionArgs,
null
)?.use { cursor ->
while (cursor.moveToNext()) {
val mimeType = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE))
val contactData = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.DATA1))
if (mimeType == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) {
name = contactData
}
if (mimeType == ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) {
phoneNumberList.add(contactData)
}
if (mimeType == ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) {
emailList.add(contactData)
}
}
}
}
contactList.add(
MultiPickerContactType(
name,
photoUri,
phoneNumberList,
emailList
)
)
}
}
}
return contactList
}
private fun getRawContactId(contentResolver: ContentResolver, contactId: Int): Int? {
val projection = arrayOf(ContactsContract.RawContacts._ID)
val selection = ContactsContract.RawContacts.CONTACT_ID + " = ?"
val selectionArgs = arrayOf(contactId.toString() + "")
return contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
return if (cursor.moveToFirst()) cursor.getInt(cursor.getColumnIndex(ContactsContract.RawContacts._ID)) else null
}
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_PICK).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = ContactsContract.Contacts.CONTENT_TYPE
}
}
}

View file

@ -0,0 +1,73 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.provider.OpenableColumns
import im.vector.riotx.multipicker.entity.MultiPickerFileType
/**
* Implementation of selecting any type of files
*/
class FilePicker(override val requestCode: Int) : Picker<MultiPickerFileType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerFileType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val fileList = mutableListOf<MultiPickerFileType>()
getSelectedUriList(data).forEach { selectedUri ->
context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
fileList.add(
MultiPickerFileType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri
)
)
}
}
}
return fileList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "*/*"
}
}
}

View file

@ -0,0 +1,91 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.utils.ImageUtils
/**
* Image Picker implementation
*/
class ImagePicker(override val requestCode: Int) : Picker<MultiPickerImageType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected image files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerImageType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val imageList = mutableListOf<MultiPickerImageType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, selectedUri)
val orientation = ImageUtils.getOrientation(context, selectedUri)
imageList.add(
MultiPickerImageType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
)
}
}
}
return imageList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "image/*"
}
}
}

View file

@ -0,0 +1,49 @@
/*
* 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.multipicker
class MultiPicker<T> {
companion object Type {
val IMAGE by lazy { MultiPicker<ImagePicker>() }
val FILE by lazy { MultiPicker<FilePicker>() }
val VIDEO by lazy { MultiPicker<VideoPicker>() }
val AUDIO by lazy { MultiPicker<AudioPicker>() }
val CONTACT by lazy { MultiPicker<ContactPicker>() }
val CAMERA by lazy { MultiPicker<CameraPicker>() }
const val REQUEST_CODE_PICK_IMAGE = 5000
const val REQUEST_CODE_PICK_VIDEO = 5001
const val REQUEST_CODE_PICK_FILE = 5002
const val REQUEST_CODE_PICK_AUDIO = 5003
const val REQUEST_CODE_PICK_CONTACT = 5004
const val REQUEST_CODE_TAKE_PHOTO = 5005
@Suppress("UNCHECKED_CAST")
fun <T> get(type: MultiPicker<T>): T {
return when (type) {
IMAGE -> ImagePicker(REQUEST_CODE_PICK_IMAGE) as T
VIDEO -> VideoPicker(REQUEST_CODE_PICK_VIDEO) as T
FILE -> FilePicker(REQUEST_CODE_PICK_FILE) as T
AUDIO -> AudioPicker(REQUEST_CODE_PICK_AUDIO) as T
CONTACT -> ContactPicker(REQUEST_CODE_PICK_CONTACT) as T
CAMERA -> CameraPicker(REQUEST_CODE_TAKE_PHOTO) as T
else -> throw IllegalArgumentException("Unsupported type $type")
}
}
}
}

View file

@ -0,0 +1,116 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.fragment.app.Fragment
/**
* Abstract class to provide all types of Pickers
*/
abstract class Picker<T>(open val requestCode: Int) {
protected var single = false
/**
* Call this function from onActivityResult(int, int, Intent).
* @return selected files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
abstract fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<T>
/**
* Use this function to retrieve files which are shared from another application or internally
* by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
*/
fun getIncomingFiles(context: Context, data: Intent?): List<T> {
if (data == null) return emptyList()
val uriList = mutableListOf<Uri>()
if (data.action == Intent.ACTION_SEND) {
(data.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { uriList.add(it) }
} else if (data.action == Intent.ACTION_SEND_MULTIPLE) {
val extraUriList: List<Uri>? = data.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
extraUriList?.let { uriList.addAll(it) }
}
val resInfoList: List<ResolveInfo> = context.packageManager.queryIntentActivities(data, PackageManager.MATCH_DEFAULT_ONLY)
uriList.forEach {
for (resolveInfo in resInfoList) {
val packageName: String = resolveInfo.activityInfo.packageName
context.grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
return getSelectedFiles(context, requestCode, Activity.RESULT_OK, data)
}
/**
* Call this function to disable multiple selection of files.
*/
fun single(): Picker<T> {
single = true
return this
}
abstract fun createIntent(): Intent
/**
* Start Storage Access Framework UI by using an Activity.
* @param activity Activity to handle onActivityResult().
*/
fun startWith(activity: Activity) {
activity.startActivityForResult(createIntent().apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }, requestCode)
}
/**
* Start Storage Access Framework UI by using a Fragment.
* @param fragment Fragment to handle onActivityResult().
*/
fun startWith(fragment: Fragment) {
fragment.startActivityForResult(createIntent().apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }, requestCode)
}
protected fun getSelectedUriList(data: Intent?): List<Uri> {
val selectedUriList = mutableListOf<Uri>()
val dataUri = data?.data
val clipData = data?.clipData
if (clipData != null) {
for (i in 0 until clipData.itemCount) {
selectedUriList.add(clipData.getItemAt(i).uri)
}
} else if (dataUri != null) {
selectedUriList.add(dataUri)
} else {
data?.extras?.get(Intent.EXTRA_STREAM)?.let {
(it as? List<*>)?.filterIsInstance<Uri>()?.let { uriList ->
selectedUriList.addAll(uriList)
}
if (it is Uri) {
selectedUriList.add(it)
}
}
}
return selectedUriList
}
}

View file

@ -0,0 +1,102 @@
/*
* 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.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerVideoType
/**
* Video Picker implementation
*/
class VideoPicker(override val requestCode: Int) : Picker<MultiPickerVideoType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected video files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerVideoType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val videoList = mutableListOf<MultiPickerVideoType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
var width = 0
var height = 0
var orientation = 0
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLong()
width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH).toInt()
height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT).toInt()
orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION).toInt()
}
videoList.add(
MultiPickerVideoType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
width,
height,
orientation,
duration
)
)
}
}
}
return videoList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "video/*"
}
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.multipicker.entity
import android.net.Uri
data class MultiPickerAudioType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val duration: Long
) : MultiPickerBaseType

View file

@ -0,0 +1,26 @@
/*
* 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.multipicker.entity
import android.net.Uri
interface MultiPickerBaseType {
val displayName: String?
val size: Long
val mimeType: String?
val contentUri: Uri
}

View file

@ -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.riotx.multipicker.entity
data class MultiPickerContactType(
val displayName: String,
val photoUri: String?,
val phoneNumberList: List<String>,
val emailList: List<String>
)

View file

@ -0,0 +1,26 @@
/*
* 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.multipicker.entity
import android.net.Uri
data class MultiPickerFileType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri
) : MultiPickerBaseType

View file

@ -0,0 +1,29 @@
/*
* 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.multipicker.entity
import android.net.Uri
data class MultiPickerImageType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int
) : MultiPickerBaseType

View file

@ -0,0 +1,30 @@
/*
* 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.multipicker.entity
import android.net.Uri
data class MultiPickerVideoType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int,
val duration: Long
) : MultiPickerBaseType

View file

@ -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.riotx.multipicker.provider
import androidx.core.content.FileProvider
class MultiPickerFileProvider : FileProvider()

View file

@ -0,0 +1,58 @@
/*
* 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.multipicker.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface
import timber.log.Timber
object ImageUtils {
fun getBitmap(context: Context, uri: Uri): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri))
} else {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
}
} catch (e: Exception) {
Timber.e(e, "Cannot decode Bitmap: %s", uri.toString())
null
}
}
fun getOrientation(context: Context, uri: Uri): Int {
var orientation = 0
context.contentResolver.openInputStream(uri)?.use { inputStream ->
try {
ExifInterface(inputStream).let {
orientation = it.rotationDegrees
}
} catch (e: Exception) {
Timber.e(e, "Cannot read orientation: %s", uri.toString())
}
}
return orientation
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path
name="external_files"
path="." />
</paths>

View file

@ -1 +1,2 @@
include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch'
include ':multipicker'

View file

@ -254,6 +254,7 @@ dependencies {
implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx")
implementation project(":diff-match-patch")
implementation project(":multipicker")
implementation 'com.android.support:multidex:1.0.3'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
@ -323,7 +324,7 @@ dependencies {
implementation 'com.nulab-inc:zxcvbn:1.2.7'
//Alerter
implementation 'com.tapadoo.android:alerter:4.0.3'
implementation 'com.tapadoo.android:alerter:5.1.2'
implementation 'com.otaliastudios:autocomplete:1.1.0'
@ -347,9 +348,6 @@ dependencies {
// Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
// File picker
implementation 'com.kbeanie:multipicker:1.6@aar'
// DI
implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion"

View file

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Adding CAMERA permission prevents Chromebooks to see the application on the PlayStore -->
<!-- Tell that the Camera is not mandatory to install the application -->

View file

@ -48,6 +48,7 @@ import im.vector.riotx.features.lifecycle.VectorActivityLifecycleCallbacks
import im.vector.riotx.features.notifications.NotificationDrawerManager
import im.vector.riotx.features.notifications.NotificationUtils
import im.vector.riotx.features.notifications.PushRuleTriggerListener
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
import im.vector.riotx.features.session.SessionListener
import im.vector.riotx.features.settings.VectorPreferences
@ -77,6 +78,7 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration.
@Inject lateinit var notificationUtils: NotificationUtils
@Inject lateinit var appStateHandler: AppStateHandler
@Inject lateinit var rxConfig: RxConfig
@Inject lateinit var popupAlertManager: PopupAlertManager
lateinit var vectorComponent: VectorComponent
private var fontThreadHandler: Handler? = null
@ -102,7 +104,7 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration.
BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
EpoxyController.defaultDiffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
registerActivityLifecycleCallbacks(VectorActivityLifecycleCallbacks())
registerActivityLifecycleCallbacks(VectorActivityLifecycleCallbacks(popupAlertManager))
val fontRequest = FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",

View file

@ -26,6 +26,8 @@ import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomDirectoryUsersFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomKnownUsersFragment
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsFragment
import im.vector.riotx.features.crypto.verification.cancel.VerificationCancelFragment
import im.vector.riotx.features.crypto.verification.cancel.VerificationNotMeFragment
import im.vector.riotx.features.crypto.verification.choose.VerificationChooseMethodFragment
import im.vector.riotx.features.crypto.verification.conclusion.VerificationConclusionFragment
import im.vector.riotx.features.crypto.verification.emoji.VerificationEmojiCodeFragment
@ -336,6 +338,16 @@ interface FragmentModule {
@FragmentKey(VerificationConclusionFragment::class)
fun bindVerificationConclusionFragment(fragment: VerificationConclusionFragment): Fragment
@Binds
@IntoMap
@FragmentKey(VerificationCancelFragment::class)
fun bindVerificationCancelFragment(fragment: VerificationCancelFragment): Fragment
@Binds
@IntoMap
@FragmentKey(VerificationNotMeFragment::class)
fun bindVerificationNotMeFragment(fragment: VerificationNotMeFragment): Fragment
@Binds
@IntoMap
@FragmentKey(QrCodeScannerFragment::class)

View file

@ -45,6 +45,7 @@ import im.vector.riotx.features.notifications.NotificationBroadcastReceiver
import im.vector.riotx.features.notifications.NotificationDrawerManager
import im.vector.riotx.features.notifications.NotificationUtils
import im.vector.riotx.features.notifications.PushRuleTriggerListener
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.rageshake.BugReporter
import im.vector.riotx.features.rageshake.VectorFileLogger
import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
@ -128,6 +129,8 @@ interface VectorComponent {
fun emojiDataSource(): EmojiDataSource
fun alertManager() : PopupAlertManager
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): VectorComponent

View file

@ -16,10 +16,13 @@
package im.vector.riotx.core.extensions
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import im.vector.riotx.R
import im.vector.riotx.features.themes.ThemeUtils
/**
* Apply a Vertical LinearLayout Manager to the recyclerView and set the adapter from the epoxy controller
@ -40,7 +43,13 @@ fun RecyclerView.configureWith(epoxyController: EpoxyController,
itemAnimator?.let { this.itemAnimator = it }
}
if (showDivider) {
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
addItemDecoration(
DividerItemDecoration(context, DividerItemDecoration.VERTICAL).apply {
ContextCompat.getDrawable(context, ThemeUtils.getResourceId(context, R.drawable.divider_horizontal_light))?.let {
setDrawable(it)
}
}
)
}
setHasFixedSize(hasFixedSize)
adapter = epoxyController.adapter

View file

@ -18,20 +18,13 @@ package im.vector.riotx.features.attachments
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.kbeanie.multipicker.api.Picker.PICK_AUDIO
import com.kbeanie.multipicker.api.Picker.PICK_CONTACT
import com.kbeanie.multipicker.api.Picker.PICK_FILE
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_CAMERA
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_DEVICE
import com.kbeanie.multipicker.core.ImagePickerImpl
import com.kbeanie.multipicker.core.PickerManager
import com.kbeanie.multipicker.utils.IntentUtils
import im.vector.matrix.android.BuildConfig
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.core.platform.Restorable
import im.vector.riotx.features.attachments.AttachmentsHelper.Callback
import im.vector.riotx.multipicker.MultiPicker
import timber.log.Timber
private const val CAPTURE_PATH_KEY = "CAPTURE_PATH_KEY"
@ -39,20 +32,8 @@ private const val PENDING_TYPE_KEY = "PENDING_TYPE_KEY"
/**
* This class helps to handle attachments by providing simple methods.
* The process is asynchronous and you must implement [Callback] methods to get the data or a failure.
*/
class AttachmentsHelper private constructor(private val context: Context,
private val pickerManagerFactory: PickerManagerFactory) : Restorable {
companion object {
fun create(fragment: Fragment, callback: Callback): AttachmentsHelper {
return AttachmentsHelper(fragment.requireContext(), FragmentPickerManagerFactory(fragment, callback))
}
fun create(activity: Activity, callback: Callback): AttachmentsHelper {
return AttachmentsHelper(activity, ActivityPickerManagerFactory(activity, callback))
}
}
class AttachmentsHelper(val context: Context, val callback: Callback) : Restorable {
interface Callback {
fun onContactAttachmentReady(contactAttachment: ContactAttachment) {
@ -66,39 +47,15 @@ class AttachmentsHelper private constructor(private val context: Context,
}
// Capture path allows to handle camera image picking. It must be restored if the activity gets killed.
private var capturePath: String? = null
private var captureUri: Uri? = null
// The pending type is set if we have to handle permission request. It must be restored if the activity gets killed.
var pendingType: AttachmentTypeSelectorView.Type? = null
private val imagePicker by lazy {
pickerManagerFactory.createImagePicker()
}
private val videoPicker by lazy {
pickerManagerFactory.createVideoPicker()
}
private val cameraImagePicker by lazy {
pickerManagerFactory.createCameraImagePicker()
}
private val filePicker by lazy {
pickerManagerFactory.createFilePicker()
}
private val audioPicker by lazy {
pickerManagerFactory.createAudioPicker()
}
private val contactPicker by lazy {
pickerManagerFactory.createContactPicker()
}
// Restorable
override fun onSaveInstanceState(outState: Bundle) {
capturePath?.also {
outState.putString(CAPTURE_PATH_KEY, it)
captureUri?.also {
outState.putParcelable(CAPTURE_PATH_KEY, it)
}
pendingType?.also {
outState.putSerializable(PENDING_TYPE_KEY, it)
@ -106,10 +63,7 @@ class AttachmentsHelper private constructor(private val context: Context,
}
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
capturePath = savedInstanceState?.getString(CAPTURE_PATH_KEY)
if (capturePath != null) {
cameraImagePicker.reinitialize(capturePath)
}
captureUri = savedInstanceState?.getParcelable(CAPTURE_PATH_KEY) as? Uri
pendingType = savedInstanceState?.getSerializable(PENDING_TYPE_KEY) as? AttachmentTypeSelectorView.Type
}
@ -118,36 +72,36 @@ class AttachmentsHelper private constructor(private val context: Context,
/**
* Starts the process for handling file picking
*/
fun selectFile() {
filePicker.pickFile()
fun selectFile(fragment: Fragment) {
MultiPicker.get(MultiPicker.FILE).startWith(fragment)
}
/**
* Starts the process for handling image picking
*/
fun selectGallery() {
imagePicker.pickImage()
fun selectGallery(fragment: Fragment) {
MultiPicker.get(MultiPicker.IMAGE).startWith(fragment)
}
/**
* Starts the process for handling audio picking
*/
fun selectAudio() {
audioPicker.pickAudio()
fun selectAudio(fragment: Fragment) {
MultiPicker.get(MultiPicker.AUDIO).startWith(fragment)
}
/**
* Starts the process for handling capture image picking
*/
fun openCamera() {
capturePath = cameraImagePicker.pickImage()
fun openCamera(fragment: Fragment) {
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(fragment)
}
/**
* Starts the process for handling contact picking
*/
fun selectContact() {
contactPicker.pickContact()
fun selectContact(fragment: Fragment) {
MultiPicker.get(MultiPicker.CONTACT).startWith(fragment)
}
/**
@ -157,14 +111,58 @@ class AttachmentsHelper private constructor(private val context: Context,
*/
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (resultCode == Activity.RESULT_OK) {
val pickerManager = getPickerManagerForRequestCode(requestCode)
if (pickerManager != null) {
if (pickerManager is ImagePickerImpl) {
pickerManager.reinitialize(capturePath)
when (requestCode) {
MultiPicker.REQUEST_CODE_PICK_FILE -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.FILE)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
pickerManager.submit(data)
return true
MultiPicker.REQUEST_CODE_PICK_AUDIO -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.AUDIO)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
MultiPicker.REQUEST_CODE_PICK_CONTACT -> {
MultiPicker.get(MultiPicker.CONTACT)
.getSelectedFiles(context, requestCode, resultCode, data)
.firstOrNull()
?.toContactAttachment()
?.let {
callback.onContactAttachmentReady(it)
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
captureUri?.let { captureUri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(context, requestCode, resultCode, captureUri)
?.let {
callback.onContentAttachmentsReady(
listOf(it).map { it.toContentAttachmentData() }
)
}
}
}
MultiPicker.REQUEST_CODE_PICK_VIDEO -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
else -> return false
}
return true
}
return false
}
@ -174,39 +172,35 @@ class AttachmentsHelper private constructor(private val context: Context,
*
* @return true if it can handle the intent data, false otherwise
*/
fun handleShareIntent(intent: Intent): Boolean {
fun handleShareIntent(context: Context, intent: Intent): Boolean {
val type = intent.resolveType(context) ?: return false
if (type.startsWith("image")) {
imagePicker.submit(safeShareIntent(intent))
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("video")) {
videoPicker.submit(safeShareIntent(intent))
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("audio")) {
videoPicker.submit(safeShareIntent(intent))
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.AUDIO).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("application") || type.startsWith("file") || type.startsWith("*")) {
filePicker.submit(safeShareIntent(intent))
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.FILE).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else {
return false
}
return true
}
private fun safeShareIntent(intent: Intent): Intent {
// Work around for getPickerIntentForSharing doing NPE in android 10
return try {
IntentUtils.getPickerIntentForSharing(intent)
} catch (failure: Throwable) {
intent
}
}
private fun getPickerManagerForRequestCode(requestCode: Int): PickerManager? {
return when (requestCode) {
PICK_IMAGE_DEVICE -> imagePicker
PICK_IMAGE_CAMERA -> cameraImagePicker
PICK_FILE -> filePicker
PICK_CONTACT -> contactPicker
PICK_AUDIO -> audioPicker
else -> null
}
}
}

View file

@ -16,51 +16,48 @@
package im.vector.riotx.features.attachments
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.multipicker.entity.MultiPickerAudioType
import im.vector.riotx.multipicker.entity.MultiPickerBaseType
import im.vector.riotx.multipicker.entity.MultiPickerContactType
import im.vector.riotx.multipicker.entity.MultiPickerFileType
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.entity.MultiPickerVideoType
import timber.log.Timber
fun ChosenContact.toContactAttachment(): ContactAttachment {
fun MultiPickerContactType.toContactAttachment(): ContactAttachment {
return ContactAttachment(
displayName = displayName,
photoUri = photoUri,
emails = emails.toList(),
phones = phones.toList()
emails = emailList.toList(),
phones = phoneNumberList.toList()
)
}
fun ChosenFile.toContentAttachmentData(): ContentAttachmentData {
fun MultiPickerFileType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData(
path = originalPath,
mimeType = mimeType,
type = mapType(),
size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName,
queryUri = queryUri
queryUri = contentUri
)
}
fun ChosenAudio.toContentAttachmentData(): ContentAttachmentData {
fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData(
path = originalPath,
mimeType = mimeType,
type = mapType(),
size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName,
duration = duration,
queryUri = queryUri
queryUri = contentUri
)
}
private fun ChosenFile.mapType(): ContentAttachmentData.Type {
private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type {
return when {
mimeType?.startsWith("image/") == true -> ContentAttachmentData.Type.IMAGE
mimeType?.startsWith("video/") == true -> ContentAttachmentData.Type.VIDEO
@ -69,10 +66,9 @@ private fun ChosenFile.mapType(): ContentAttachmentData.Type {
}
}
fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData(
path = originalPath,
mimeType = mimeType,
type = mapType(),
name = displayName,
@ -80,23 +76,20 @@ fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
height = height.toLong(),
width = width.toLong(),
exifOrientation = orientation,
date = createdAt?.time ?: System.currentTimeMillis(),
queryUri = queryUri
queryUri = contentUri
)
}
fun ChosenVideo.toContentAttachmentData(): ContentAttachmentData {
fun MultiPickerVideoType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData(
path = originalPath,
mimeType = mimeType,
type = ContentAttachmentData.Type.VIDEO,
size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
height = height.toLong(),
width = width.toLong(),
duration = duration,
name = displayName,
queryUri = queryUri
queryUri = contentUri
)
}

View file

@ -1,96 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments
import com.kbeanie.multipicker.api.callbacks.AudioPickerCallback
import com.kbeanie.multipicker.api.callbacks.ContactPickerCallback
import com.kbeanie.multipicker.api.callbacks.FilePickerCallback
import com.kbeanie.multipicker.api.callbacks.ImagePickerCallback
import com.kbeanie.multipicker.api.callbacks.VideoPickerCallback
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
/**
* This class delegates the PickerManager callbacks to an [AttachmentsHelper.Callback]
*/
class AttachmentsPickerCallback(private val callback: AttachmentsHelper.Callback)
: ImagePickerCallback,
FilePickerCallback,
VideoPickerCallback,
AudioPickerCallback,
ContactPickerCallback {
override fun onContactChosen(contact: ChosenContact?) {
if (contact == null) {
callback.onAttachmentsProcessFailed()
} else {
val contactAttachment = contact.toContactAttachment()
callback.onContactAttachmentReady(contactAttachment)
}
}
override fun onAudiosChosen(audios: MutableList<ChosenAudio>?) {
if (audios.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = audios.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onFilesChosen(files: MutableList<ChosenFile>?) {
if (files.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = files.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onImagesChosen(images: MutableList<ChosenImage>?) {
if (images.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = images.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onVideosChosen(videos: MutableList<ChosenVideo>?) {
if (videos.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = videos.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onError(error: String?) {
callback.onAttachmentsProcessFailed()
}
}

View file

@ -1,134 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments
import android.app.Activity
import androidx.fragment.app.Fragment
import com.kbeanie.multipicker.api.AudioPicker
import com.kbeanie.multipicker.api.CameraImagePicker
import com.kbeanie.multipicker.api.ContactPicker
import com.kbeanie.multipicker.api.FilePicker
import com.kbeanie.multipicker.api.ImagePicker
import com.kbeanie.multipicker.api.VideoPicker
/**
* Factory for creating different pickers. It allows to use with fragment or activity builders.
*/
interface PickerManagerFactory {
fun createImagePicker(): ImagePicker
fun createCameraImagePicker(): CameraImagePicker
fun createVideoPicker(): VideoPicker
fun createFilePicker(): FilePicker
fun createAudioPicker(): AudioPicker
fun createContactPicker(): ContactPicker
}
class ActivityPickerManagerFactory(private val activity: Activity, callback: AttachmentsHelper.Callback) : PickerManagerFactory {
private val attachmentsPickerCallback = AttachmentsPickerCallback(callback)
override fun createImagePicker(): ImagePicker {
return ImagePicker(activity).also {
it.setImagePickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createCameraImagePicker(): CameraImagePicker {
return CameraImagePicker(activity).also {
it.setImagePickerCallback(attachmentsPickerCallback)
}
}
override fun createVideoPicker(): VideoPicker {
return VideoPicker(activity).also {
it.setVideoPickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createFilePicker(): FilePicker {
return FilePicker(activity).also {
it.allowMultiple()
it.setFilePickerCallback(attachmentsPickerCallback)
}
}
override fun createAudioPicker(): AudioPicker {
return AudioPicker(activity).also {
it.allowMultiple()
it.setAudioPickerCallback(attachmentsPickerCallback)
}
}
override fun createContactPicker(): ContactPicker {
return ContactPicker(activity).also {
it.setContactPickerCallback(attachmentsPickerCallback)
}
}
}
class FragmentPickerManagerFactory(private val fragment: Fragment, callback: AttachmentsHelper.Callback) : PickerManagerFactory {
private val attachmentsPickerCallback = AttachmentsPickerCallback(callback)
override fun createImagePicker(): ImagePicker {
return ImagePicker(fragment).also {
it.setImagePickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createCameraImagePicker(): CameraImagePicker {
return CameraImagePicker(fragment).also {
it.setImagePickerCallback(attachmentsPickerCallback)
}
}
override fun createVideoPicker(): VideoPicker {
return VideoPicker(fragment).also {
it.setVideoPickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createFilePicker(): FilePicker {
return FilePicker(fragment).also {
it.allowMultiple()
it.setFilePickerCallback(attachmentsPickerCallback)
}
}
override fun createAudioPicker(): AudioPicker {
return AudioPicker(fragment).also {
it.allowMultiple()
it.setAudioPickerCallback(attachmentsPickerCallback)
}
}
override fun createContactPicker(): ContactPicker {
return ContactPicker(fragment).also {
it.setContactPickerCallback(attachmentsPickerCallback)
}
}
}

View file

@ -25,7 +25,7 @@ class AttachmentBigPreviewController @Inject constructor() : TypedEpoxyControlle
override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEach {
attachmentBigPreviewItem {
id(it.path)
id(it.queryUri.toString())
attachment(it)
}
}
@ -43,7 +43,7 @@ class AttachmentMiniaturePreviewController @Inject constructor() : TypedEpoxyCon
override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEachIndexed { index, contentAttachmentData ->
attachmentMiniaturePreviewItem {
id(contentAttachmentData.path)
id(contentAttachmentData.queryUri.toString())
attachment(contentAttachmentData)
checked(data.currentAttachmentIndex == index)
clickListener { _ ->

View file

@ -33,11 +33,10 @@ abstract class AttachmentPreviewItem<H : AttachmentPreviewItem.Holder> : VectorE
abstract val attachment: ContentAttachmentData
override fun bind(holder: H) {
val path = attachment.path
if (attachment.type == ContentAttachmentData.Type.VIDEO || attachment.type == ContentAttachmentData.Type.IMAGE) {
Glide.with(holder.view.context)
.asBitmap()
.load(path)
.load(attachment.queryUri)
.apply(RequestOptions().frame(0))
.into(holder.imageView)
} else {

View file

@ -17,10 +17,11 @@
package im.vector.riotx.features.attachments.preview
import android.net.Uri
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class AttachmentsPreviewAction : VectorViewModelAction {
object RemoveCurrentAttachment : AttachmentsPreviewAction()
data class SetCurrentAttachment(val index: Int): AttachmentsPreviewAction()
data class UpdatePathOfCurrentAttachment(val newPath: String): AttachmentsPreviewAction()
data class UpdatePathOfCurrentAttachment(val newUri: Uri): AttachmentsPreviewAction()
}

View file

@ -172,9 +172,9 @@ class AttachmentsPreviewFragment @Inject constructor(
}
private fun handleCropResult(result: Intent) {
val resultPath = UCrop.getOutput(result)?.path
if (resultPath != null) {
viewModel.handle(AttachmentsPreviewAction.UpdatePathOfCurrentAttachment(resultPath))
val resultUri = UCrop.getOutput(result)
if (resultUri != null) {
viewModel.handle(AttachmentsPreviewAction.UpdatePathOfCurrentAttachment(resultUri))
} else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
@ -202,8 +202,7 @@ class AttachmentsPreviewFragment @Inject constructor(
private fun doHandleEditAction() = withState(viewModel) {
val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState
val destinationFile = File(requireContext().cacheDir, "${currentAttachment.name}_edited_image_${System.currentTimeMillis()}")
// Note: using currentAttachment.queryUri.toUri() make the app crash when sharing from Google Photos
val uri = File(currentAttachment.path).toUri()
val uri = currentAttachment.queryUri
UCrop.of(uri, destinationFile.toUri())
.withOptions(
UCrop.Options()

View file

@ -62,7 +62,7 @@ class AttachmentsPreviewViewModel @AssistedInject constructor(@Assisted initialS
private fun handleUpdatePathOfCurrentAttachment(action: AttachmentsPreviewAction.UpdatePathOfCurrentAttachment) = withState {
val attachments = it.attachments.mapIndexed { index, contentAttachmentData ->
if (index == it.currentAttachmentIndex) {
contentAttachmentData.copy(path = action.newPath)
contentAttachmentData.copy(queryUri = action.newUri)
} else {
contentAttachmentData
}

View file

@ -36,6 +36,7 @@ import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.riotx.R
import im.vector.riotx.features.popup.DefaultVectorAlert
import im.vector.riotx.features.popup.PopupAlertManager
import timber.log.Timber
import java.text.DateFormat
@ -54,7 +55,7 @@ import javax.inject.Singleton
*/
@Singleton
class KeyRequestHandler @Inject constructor(private val context: Context)
class KeyRequestHandler @Inject constructor(private val context: Context, private val popupAlertManager: PopupAlertManager)
: GossipingRequestListener,
VerificationService.Listener {
@ -118,9 +119,9 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
}
if (deviceInfo.isUnknown) {
session?.cryptoService()?.setDeviceVerification(DeviceTrustLevel(false, false), userId, deviceId)
session?.cryptoService()?.setDeviceVerification(DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false), userId, deviceId)
deviceInfo.trustLevel = DeviceTrustLevel(false, false)
deviceInfo.trustLevel = DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false)
// can we get more info on this device?
session?.cryptoService()?.getDevicesList(object : MatrixCallback<DevicesListResponse> {
@ -188,7 +189,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
}
}
val alert = PopupAlertManager.VectorAlert(
val alert = DefaultVectorAlert(
alertManagerId(userId, deviceId),
context.getString(R.string.key_share_request),
dialogText,
@ -210,7 +211,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
denyAllRequests(mappingKey)
})
PopupAlertManager.postVectorAlert(alert)
popupAlertManager.postVectorAlert(alert)
}
private fun denyAllRequests(mappingKey: String) {
@ -250,7 +251,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
&& it.requestId == request.requestId
}
if (alertsToRequests[alertMgrUniqueKey]?.isEmpty() == true) {
PopupAlertManager.cancelAlert(alertMgrUniqueKey)
popupAlertManager.cancelAlert(alertMgrUniqueKey)
alertsToRequests.remove(keyForMap(userId, deviceId))
}
}
@ -261,7 +262,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
if (state == VerificationTxState.Verified) {
// ok it's verified, see if we have key request for that
shareAllSessions("${tx.otherDeviceId}${tx.otherUserId}")
PopupAlertManager.cancelAlert("ikr_${tx.otherDeviceId}${tx.otherUserId}")
popupAlertManager.cancelAlert("ikr_${tx.otherDeviceId}${tx.otherUserId}")
}
}
// should do it with QR tx also
@ -271,7 +272,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context)
override fun markedAsManuallyVerified(userId: String, deviceId: String) {
// accept related requests
shareAllSessions(keyForMap(userId, deviceId))
PopupAlertManager.cancelAlert(alertManagerId(userId, deviceId))
popupAlertManager.cancelAlert(alertManagerId(userId, deviceId))
}
private fun keyForMap(userId: String, deviceId: String) = "$deviceId$userId"

View file

@ -17,15 +17,17 @@ package im.vector.riotx.features.crypto.verification
import android.content.Context
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.crypto.verification.PendingVerificationRequest
import im.vector.matrix.android.api.session.crypto.verification.VerificationService
import im.vector.matrix.android.api.session.crypto.verification.VerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.api.session.crypto.verification.PendingVerificationRequest
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.home.room.detail.RoomDetailActivity
import im.vector.riotx.features.home.room.detail.RoomDetailArgs
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.popup.VerificationVectorAlert
import im.vector.riotx.features.themes.ThemeUtils
import javax.inject.Inject
import javax.inject.Singleton
@ -34,7 +36,9 @@ import javax.inject.Singleton
* Listens to the VerificationManager and add a new notification when an incoming request is detected.
*/
@Singleton
class IncomingVerificationRequestHandler @Inject constructor(private val context: Context) : VerificationService.Listener {
class IncomingVerificationRequestHandler @Inject constructor(
private val context: Context,
private val popupAlertManager: PopupAlertManager) : VerificationService.Listener {
private var session: Session? = null
@ -58,7 +62,7 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
val name = session?.getUser(tx.otherUserId)?.displayName
?: tx.otherUserId
val alert = PopupAlertManager.VectorAlert(
val alert = VerificationVectorAlert(
uid,
context.getString(R.string.sas_incoming_request_notif_title),
context.getString(R.string.sas_incoming_request_notif_content, name),
@ -68,12 +72,14 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
// TODO a bit too hugly :/
activity.supportFragmentManager.findFragmentByTag(VerificationBottomSheet.WAITING_SELF_VERIF_TAG)?.let {
false.also {
PopupAlertManager.cancelAlert(uid)
popupAlertManager.cancelAlert(uid)
}
} ?: true
} else true
})
.apply {
matrixItem = session?.getUser(tx.otherUserId)?.toMatrixItem()
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)?.let {
it.navigator.performDeviceVerification(it, tx.otherUserId, tx.transactionId)
@ -99,11 +105,11 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
// 10mn expiration
expirationTimestamp = System.currentTimeMillis() + (10 * 60 * 1000L)
}
PopupAlertManager.postVectorAlert(alert)
popupAlertManager.postVectorAlert(alert)
}
is VerificationTxState.TerminalTxState -> {
// cancel related notification
PopupAlertManager.cancelAlert(uid)
popupAlertManager.cancelAlert(uid)
}
else -> Unit
}
@ -115,7 +121,7 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
val name = session?.getUser(pr.otherUserId)?.displayName
?: pr.otherUserId
val alert = PopupAlertManager.VectorAlert(
val alert = VerificationVectorAlert(
uniqueIdForVerificationRequest(pr),
context.getString(R.string.sas_incoming_request_notif_title),
"$name(${pr.otherUserId})",
@ -128,6 +134,8 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
} else true
})
.apply {
matrixItem = session?.getUser(pr.otherUserId)?.toMatrixItem()
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)?.let {
val roomId = pr.roomId
@ -148,14 +156,14 @@ class IncomingVerificationRequestHandler @Inject constructor(private val context
// 5mn expiration
expirationTimestamp = System.currentTimeMillis() + (5 * 60 * 1000L)
}
PopupAlertManager.postVectorAlert(alert)
popupAlertManager.postVectorAlert(alert)
}
}
override fun verificationRequestUpdated(pr: PendingVerificationRequest) {
// If an incoming request is readied (by another device?) we should discard the alert
if (pr.isIncoming && (pr.isReady || pr.handledByOtherSession)) {
PopupAlertManager.cancelAlert(uniqueIdForVerificationRequest(pr))
popupAlertManager.cancelAlert(uniqueIdForVerificationRequest(pr))
}
}

View file

@ -16,9 +16,11 @@
package im.vector.riotx.features.crypto.verification
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.KeyEvent
import android.view.View
import android.widget.ImageView
import android.widget.TextView
@ -34,19 +36,24 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.verification.CancelCode
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.commitTransaction
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.crypto.quads.SharedSecureStorageActivity
import im.vector.riotx.features.crypto.verification.cancel.VerificationCancelFragment
import im.vector.riotx.features.crypto.verification.cancel.VerificationNotMeFragment
import im.vector.riotx.features.crypto.verification.choose.VerificationChooseMethodFragment
import im.vector.riotx.features.crypto.verification.conclusion.VerificationConclusionFragment
import im.vector.riotx.features.crypto.verification.emoji.VerificationEmojiCodeFragment
import im.vector.riotx.features.crypto.verification.qrconfirmation.VerificationQrScannedByOtherFragment
import im.vector.riotx.features.crypto.verification.request.VerificationRequestFragment
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.settings.VectorSettingsActivity
import kotlinx.android.parcel.Parcelize
import timber.log.Timber
import javax.inject.Inject
@ -58,6 +65,7 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
data class VerificationArgs(
val otherUserId: String,
val verificationId: String? = null,
val verificationLocalId: String? = null,
val roomId: String? = null,
// Special mode where UX should show loading wheel until other session sends a request/tx
val selfVerificationMode: Boolean = false
@ -80,13 +88,17 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
lateinit var otherUserNameText: TextView
@BindView(R.id.verificationRequestShield)
lateinit var otherUserShield: View
lateinit var otherUserShield: ImageView
@BindView(R.id.verificationRequestAvatar)
lateinit var otherUserAvatarImageView: ImageView
override fun getLayoutResId() = R.layout.bottom_sheet_verification
init {
isCancelable = false
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -110,10 +122,27 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
.show()
Unit
}
VerificationBottomSheetViewEvents.GoToSettings -> {
dismiss()
(activity as? VectorBaseActivity)?.navigator?.openSettings(requireContext(), VectorSettingsActivity.EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY)
}
}.exhaustive
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).apply {
setOnKeyListener { _, keyCode, keyEvent ->
if (keyCode == KeyEvent.KEYCODE_BACK && keyEvent.action == KeyEvent.ACTION_UP) {
viewModel.queryCancel()
true
} else {
false
}
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK && requestCode == SECRET_REQUEST_CODE) {
data?.getStringExtra(SharedSecureStorageActivity.EXTRA_DATA_RESULT)?.let {
@ -127,15 +156,16 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
state.otherUserMxItem?.let { matrixItem ->
if (state.isMe) {
avatarRenderer.render(matrixItem, otherUserAvatarImageView)
if (state.sasTransactionState == VerificationTxState.Verified
|| state.qrTransactionState == VerificationTxState.Verified
|| state.verifiedFromPrivateKeys) {
otherUserAvatarImageView.setImageResource(R.drawable.ic_shield_trusted)
otherUserShield.setImageResource(R.drawable.ic_shield_trusted)
} else {
otherUserAvatarImageView.setImageResource(R.drawable.ic_shield_warning)
otherUserShield.setImageResource(R.drawable.ic_shield_warning)
}
otherUserNameText.text = getString(R.string.complete_security)
otherUserShield.isVisible = false
otherUserShield.isVisible = true
} else {
avatarRenderer.render(matrixItem, otherUserAvatarImageView)
@ -149,6 +179,18 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
}
if (state.userThinkItsNotHim) {
otherUserNameText.text = getString(R.string.dialog_title_warning)
showFragment(VerificationNotMeFragment::class, Bundle())
return@withState
}
if (state.userWantsToCancel) {
otherUserNameText.text = getString(R.string.are_you_sure)
showFragment(VerificationCancelFragment::class, Bundle())
return@withState
}
if (state.selfVerificationMode && state.verifiedFromPrivateKeys) {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(MvRx.KEY_ARG, VerificationConclusionFragment.Args(true, null, state.isMe))
@ -222,7 +264,14 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
// Transaction has not yet started
if (state.pendingRequest.invoke()?.cancelConclusion != null) {
// The request has been declined, we should dismiss
dismiss()
otherUserNameText.text = getString(R.string.verification_cancelled)
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(MvRx.KEY_ARG, VerificationConclusionFragment.Args(
false,
state.pendingRequest.invoke()?.cancelConclusion?.value ?: CancelCode.User.value,
state.isMe))
})
return@withState
}
// If it's an outgoing
@ -267,6 +316,10 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
}
override fun dismiss() {
super.dismiss()
}
companion object {
const val SECRET_REQUEST_CODE = 101

View file

@ -24,5 +24,6 @@ import im.vector.riotx.core.platform.VectorViewEvents
sealed class VerificationBottomSheetViewEvents : VectorViewEvents {
object Dismiss : VerificationBottomSheetViewEvents()
object AccessSecretStore : VerificationBottomSheetViewEvents()
object GoToSettings : VerificationBottomSheetViewEvents()
data class ModalError(val errorMessage: CharSequence) : VerificationBottomSheetViewEvents()
}

View file

@ -31,7 +31,9 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
import im.vector.matrix.android.api.session.crypto.verification.CancelCode
import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.PendingVerificationRequest
import im.vector.matrix.android.api.session.crypto.verification.QrCodeVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.SasVerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationMethod
@ -44,7 +46,6 @@ import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.matrix.android.internal.crypto.crosssigning.fromBase64
import im.vector.matrix.android.internal.crypto.crosssigning.isVerified
import im.vector.matrix.android.api.session.crypto.verification.PendingVerificationRequest
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import timber.log.Timber
@ -60,7 +61,10 @@ data class VerificationBottomSheetViewState(
// true when we display the loading and we wait for the other (incoming request)
val selfVerificationMode: Boolean = false,
val verifiedFromPrivateKeys: Boolean = false,
val isMe: Boolean = false
val isMe: Boolean = false,
val currentDeviceCanCrossSign: Boolean = false,
val userWantsToCancel: Boolean = false,
val userThinkItsNotHim: Boolean = false
) : MvRxState
class VerificationBottomSheetViewModel @AssistedInject constructor(
@ -111,7 +115,8 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
pendingRequest = if (pr != null) Success(pr) else Uninitialized,
selfVerificationMode = selfVerificationMode,
roomId = args.roomId,
isMe = args.otherUserId == session.myUserId
isMe = args.otherUserId == session.myUserId,
currentDeviceCanCrossSign = session.cryptoService().crossSigningService().canCrossSign()
)
}
@ -137,6 +142,57 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
args: VerificationBottomSheet.VerificationArgs): VerificationBottomSheetViewModel
}
fun queryCancel() = withState {
if (it.userThinkItsNotHim) {
setState {
copy(userThinkItsNotHim = false)
}
} else {
setState {
copy(userWantsToCancel = true)
}
}
}
fun confirmCancel() = withState { state ->
cancelAllPendingVerifications(state)
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
}
private fun cancelAllPendingVerifications(state: VerificationBottomSheetViewState) {
session.cryptoService()
.verificationService().getExistingVerificationRequest(state.otherUserMxItem?.id ?: "", state.transactionId)?.let {
session.cryptoService().verificationService().cancelVerificationRequest(it)
}
session.cryptoService()
.verificationService()
.getExistingTransaction(state.otherUserMxItem?.id ?: "", state.transactionId ?: "")
?.cancel(CancelCode.User)
}
fun continueFromCancel() {
setState {
copy(userWantsToCancel = false)
}
}
fun continueFromWasNotMe() {
setState {
copy(userThinkItsNotHim = false)
}
}
fun itWasNotMe() {
setState {
copy(userThinkItsNotHim = true)
}
}
fun goToSettings() = withState { state ->
cancelAllPendingVerifications(state)
_viewEvents.post(VerificationBottomSheetViewEvents.GoToSettings)
}
companion object : MvRxViewModelFactory<VerificationBottomSheetViewModel, VerificationBottomSheetViewState> {
override fun create(viewModelContext: ViewModelContext, state: VerificationBottomSheetViewState): VerificationBottomSheetViewModel? {

View file

@ -0,0 +1,96 @@
/*
* 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.crypto.verification.cancel
import com.airbnb.epoxy.EpoxyController
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.dividerItem
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.crypto.verification.VerificationBottomSheetViewState
import im.vector.riotx.features.crypto.verification.epoxy.bottomSheetVerificationActionItem
import im.vector.riotx.features.crypto.verification.epoxy.bottomSheetVerificationNoticeItem
import javax.inject.Inject
class VerificationCancelController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider
) : EpoxyController() {
var listener: Listener? = null
private var viewState: VerificationBottomSheetViewState? = null
fun update(viewState: VerificationBottomSheetViewState) {
this.viewState = viewState
requestModelBuild()
}
override fun buildModels() {
val state = viewState ?: return
if (state.isMe) {
if (state.currentDeviceCanCrossSign) {
bottomSheetVerificationNoticeItem {
id("notice")
notice(stringProvider.getString(R.string.verify_cancel_self_verification_from_trusted))
}
} else {
bottomSheetVerificationNoticeItem {
id("notice")
notice(stringProvider.getString(R.string.verify_cancel_self_verification_from_untrusted))
}
}
} else {
bottomSheetVerificationNoticeItem {
id("notice")
notice(stringProvider.getString(R.string.verify_cancel_self_verification_from_untrusted))
}
}
dividerItem {
id("sep0")
}
bottomSheetVerificationActionItem {
id("cancel")
title(stringProvider.getString(R.string.cancel))
titleColor(colorProvider.getColor(R.color.riotx_destructive_accent))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColor(R.color.riotx_destructive_accent))
listener { listener?.onTapCancel() }
}
dividerItem {
id("sep1")
}
bottomSheetVerificationActionItem {
id("continue")
title(stringProvider.getString(R.string._continue))
titleColor(colorProvider.getColor(R.color.riotx_positive_accent))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColor(R.color.riotx_positive_accent))
listener { listener?.onTapContinue() }
}
}
interface Listener {
fun onTapCancel()
fun onTapContinue()
}
}

View file

@ -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.riotx.features.crypto.verification.cancel
import android.os.Bundle
import android.view.View
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
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.VectorBaseFragment
import im.vector.riotx.features.crypto.verification.VerificationBottomSheetViewModel
import kotlinx.android.synthetic.main.bottom_sheet_verification_child_fragment.*
import javax.inject.Inject
class VerificationCancelFragment @Inject constructor(
val controller: VerificationCancelController
) : VectorBaseFragment(), VerificationCancelController.Listener {
private val viewModel by parentFragmentViewModel(VerificationBottomSheetViewModel::class)
override fun getLayoutResId() = R.layout.bottom_sheet_verification_child_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
}
override fun onDestroyView() {
bottomSheetVerificationRecyclerView.cleanup()
controller.listener = null
super.onDestroyView()
}
private fun setupRecyclerView() {
bottomSheetVerificationRecyclerView.configureWith(controller, hasFixedSize = false, disableItemAnimation = true)
controller.listener = this
}
override fun invalidate() = withState(viewModel) { state ->
controller.update(state)
}
override fun onTapCancel() {
viewModel.confirmCancel()
}
override fun onTapContinue() {
viewModel.continueFromCancel()
}
}

View file

@ -0,0 +1,82 @@
/*
* 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.crypto.verification.cancel
import com.airbnb.epoxy.EpoxyController
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.dividerItem
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.crypto.verification.VerificationBottomSheetViewState
import im.vector.riotx.features.crypto.verification.epoxy.bottomSheetVerificationActionItem
import im.vector.riotx.features.crypto.verification.epoxy.bottomSheetVerificationNoticeItem
import im.vector.riotx.features.html.EventHtmlRenderer
import javax.inject.Inject
class VerificationNotMeController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
private val eventHtmlRenderer: EventHtmlRenderer
) : EpoxyController() {
var listener: Listener? = null
private var viewState: VerificationBottomSheetViewState? = null
fun update(viewState: VerificationBottomSheetViewState) {
this.viewState = viewState
requestModelBuild()
}
override fun buildModels() {
bottomSheetVerificationNoticeItem {
id("notice")
notice(eventHtmlRenderer.render(stringProvider.getString(R.string.verify_not_me_self_verification)))
}
dividerItem {
id("sep0")
}
bottomSheetVerificationActionItem {
id("skip")
title(stringProvider.getString(R.string.skip))
titleColor(colorProvider.getColorFromAttribute(R.attr.riotx_text_primary))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColorFromAttribute(R.attr.riotx_text_primary))
listener { listener?.onTapSkip() }
}
dividerItem {
id("sep1")
}
bottomSheetVerificationActionItem {
id("settings")
title(stringProvider.getString(R.string.settings))
titleColor(colorProvider.getColor(R.color.riotx_positive_accent))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColor(R.color.riotx_positive_accent))
listener { listener?.onTapSettings() }
}
}
interface Listener {
fun onTapSkip()
fun onTapSettings()
}
}

View file

@ -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.riotx.features.crypto.verification.cancel
import android.os.Bundle
import android.view.View
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
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.VectorBaseFragment
import im.vector.riotx.features.crypto.verification.VerificationBottomSheetViewModel
import kotlinx.android.synthetic.main.bottom_sheet_verification_child_fragment.*
import javax.inject.Inject
class VerificationNotMeFragment @Inject constructor(
val controller: VerificationNotMeController
) : VectorBaseFragment(), VerificationNotMeController.Listener {
private val viewModel by parentFragmentViewModel(VerificationBottomSheetViewModel::class)
override fun getLayoutResId() = R.layout.bottom_sheet_verification_child_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
}
override fun onDestroyView() {
bottomSheetVerificationRecyclerView.cleanup()
controller.listener = null
super.onDestroyView()
}
private fun setupRecyclerView() {
bottomSheetVerificationRecyclerView.configureWith(controller, hasFixedSize = false, disableItemAnimation = true)
controller.listener = this
}
override fun invalidate() = withState(viewModel) { state ->
controller.update(state)
}
override fun onTapSkip() {
viewModel.continueFromWasNotMe()
}
override fun onTapSettings() {
viewModel.goToSettings()
}
}

View file

@ -95,10 +95,27 @@ class VerificationChooseMethodController @Inject constructor(
listener { listener?.doVerifyBySas() }
}
}
if (state.isMe && state.canCrossSign) {
dividerItem {
id("sep_notMe")
}
bottomSheetVerificationActionItem {
id("wasnote")
title(stringProvider.getString(R.string.verify_new_session_was_not_me))
titleColor(colorProvider.getColor(R.color.riotx_destructive_accent))
subTitle(stringProvider.getString(R.string.verify_new_session_compromized))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColorFromAttribute(R.attr.riotx_text_primary))
listener { listener?.onClickOnWasNotMe() }
}
}
}
interface Listener {
fun openCamera()
fun doVerifyBySas()
fun onClickOnWasNotMe()
}
}

View file

@ -89,6 +89,10 @@ class VerificationChooseMethodFragment @Inject constructor(
}
}
override fun onClickOnWasNotMe() {
sharedViewModel.itWasNotMe()
}
private fun doOpenQRCodeScanner() {
QrCodeScannerActivity.startForResult(this)
}

View file

@ -39,7 +39,9 @@ data class VerificationChooseMethodViewState(
val otherCanShowQrCode: Boolean = false,
val otherCanScanQrCode: Boolean = false,
val qrCodeText: String? = null,
val SASModeAvailable: Boolean = false
val SASModeAvailable: Boolean = false,
val isMe: Boolean = false,
val canCrossSign: Boolean = false
) : MvRxState
class VerificationChooseMethodViewModel @AssistedInject constructor(
@ -61,6 +63,10 @@ class VerificationChooseMethodViewModel @AssistedInject constructor(
}
}
override fun verificationRequestCreated(pr: PendingVerificationRequest) {
verificationRequestUpdated(pr)
}
override fun verificationRequestUpdated(pr: PendingVerificationRequest) = withState { state ->
val pvr = session.cryptoService().verificationService().getExistingVerificationRequest(state.otherUserId, state.transactionId)
@ -103,6 +109,8 @@ class VerificationChooseMethodViewModel @AssistedInject constructor(
val qrCodeVerificationTransaction = verificationService.getExistingTransaction(args.otherUserId, args.verificationId ?: "")
return VerificationChooseMethodViewState(otherUserId = args.otherUserId,
isMe = session.myUserId == pvr?.otherUserId,
canCrossSign = session.cryptoService().crossSigningService().canCrossSign(),
transactionId = args.verificationId ?: "",
otherCanShowQrCode = pvr?.otherCanShowQrCode().orFalse(),
otherCanScanQrCode = pvr?.otherCanScanQrCode().orFalse(),

View file

@ -58,6 +58,8 @@ class VerificationConclusionController @Inject constructor(
id("image")
imageRes(R.drawable.ic_shield_trusted)
}
bottomDone()
}
ConclusionState.WARNING -> {
bottomSheetVerificationNoticeItem {
@ -74,10 +76,32 @@ class VerificationConclusionController @Inject constructor(
id("warning_notice")
notice(eventHtmlRenderer.render(stringProvider.getString(R.string.verification_conclusion_compromised)))
}
}
else -> Unit
}
bottomDone()
}
ConclusionState.CANCELLED -> {
bottomSheetVerificationNoticeItem {
id("notice_cancelled")
notice(stringProvider.getString(R.string.verify_cancelled_notice))
}
dividerItem {
id("sep0")
}
bottomSheetVerificationActionItem {
id("got_it")
title(stringProvider.getString(R.string.sas_got_it))
titleColor(colorProvider.getColor(R.color.riotx_accent))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColor(R.color.riotx_accent))
listener { listener?.onButtonTapped() }
}
}
}
}
private fun bottomDone() {
dividerItem {
id("sep0")
}

View file

@ -66,12 +66,7 @@ class VerificationConclusionFragment @Inject constructor(
}
override fun invalidate() = withState(viewModel) { state ->
if (state.conclusionState == ConclusionState.CANCELLED) {
// Just dismiss in this case
sharedViewModel.handle(VerificationAction.GotItConclusion)
} else {
controller.update(state)
}
controller.update(state)
}
override fun onButtonTapped() {

View file

@ -84,11 +84,16 @@ class VerificationRequestController @Inject constructor(
listener { listener?.onClickDismiss() }
}
} else {
val styledText = matrixItem.let {
stringProvider.getString(R.string.verification_request_notice, it.id)
.toSpannable()
.colorizeMatchingText(it.id, colorProvider.getColorFromAttribute(R.attr.vctr_notice_text_color))
}
val styledText =
if (state.isMe) {
stringProvider.getString(R.string.verify_new_session_notice)
} else {
matrixItem.let {
stringProvider.getString(R.string.verification_request_notice, it.id)
.toSpannable()
.colorizeMatchingText(it.id, colorProvider.getColorFromAttribute(R.attr.vctr_notice_text_color))
}
}
bottomSheetVerificationNoticeItem {
id("notice")
@ -119,18 +124,42 @@ class VerificationRequestController @Inject constructor(
}
is Success -> {
if (!pr.invoke().isReady) {
bottomSheetVerificationWaitingItem {
id("waiting")
title(stringProvider.getString(R.string.verification_request_waiting_for, matrixItem.getBestName()))
if (state.isMe) {
bottomSheetVerificationWaitingItem {
id("waiting")
title(stringProvider.getString(R.string.verification_request_waiting))
}
} else {
bottomSheetVerificationWaitingItem {
id("waiting")
title(stringProvider.getString(R.string.verification_request_waiting_for, matrixItem.getBestName()))
}
}
}
}
}
}
if (state.isMe && state.currentDeviceCanCrossSign) {
dividerItem {
id("sep_notMe")
}
bottomSheetVerificationActionItem {
id("wasnote")
title(stringProvider.getString(R.string.verify_new_session_was_not_me))
titleColor(colorProvider.getColor(R.color.riotx_destructive_accent))
subTitle(stringProvider.getString(R.string.verify_new_session_compromized))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColorFromAttribute(R.attr.riotx_text_primary))
listener { listener?.onClickOnWasNotMe() }
}
}
}
interface Listener {
fun onClickOnVerificationStart()
fun onClickOnWasNotMe()
fun onClickRecoverFromPassphrase()
fun onClickDismiss()
}

View file

@ -69,4 +69,8 @@ class VerificationRequestFragment @Inject constructor(
override fun onClickDismiss() {
viewModel.handle(VerificationAction.SkipVerification)
}
override fun onClickOnWasNotMe() {
viewModel.itWasNotMe()
}
}

View file

@ -96,8 +96,8 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
// PRIVATE API *********************************************************************************
private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver()
.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
val resolvedUrl = activeSessionHolder.getSafeActiveSession()?.contentUrlResolver()
?.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
return glideRequest
.load(resolvedUrl)

View file

@ -41,6 +41,7 @@ import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.pushers.PushersManager
import im.vector.riotx.features.disclaimer.showDisclaimerDialog
import im.vector.riotx.features.notifications.NotificationDrawerManager
import im.vector.riotx.features.popup.DefaultVectorAlert
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
import im.vector.riotx.features.settings.VectorPreferences
@ -60,6 +61,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
@Inject lateinit var pushManager: PushersManager
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
@Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var popupAlertManager: PopupAlertManager
private val drawerListener = object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerStateChanged(newState: Int) {
@ -149,8 +151,8 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
if (crossSigningEnabledOnAccount && myCrossSigningKeys?.isTrusted() == false) {
// We need to ask
sharedActionViewModel.hasDisplayedCompleteSecurityPrompt = true
PopupAlertManager.postVectorAlert(
PopupAlertManager.VectorAlert(
popupAlertManager.postVectorAlert(
DefaultVectorAlert(
uid = "completeSecurity",
title = getString(R.string.new_signin),
description = getString(R.string.complete_security),

View file

@ -1,3 +1,4 @@
/*
* Copyright 2019 New Vector Ltd
*
@ -19,8 +20,10 @@ package im.vector.riotx.features.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.forEachIndexed
import androidx.lifecycle.Observer
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.bottomnavigation.BottomNavigationItemView
@ -32,11 +35,14 @@ import im.vector.riotx.R
import im.vector.riotx.core.extensions.commitTransactionNow
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.ui.views.KeysBackupBanner
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.home.room.list.RoomListParams
import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.popup.VerificationVectorAlert
import im.vector.riotx.features.workers.signout.SignOutViewModel
import kotlinx.android.synthetic.main.fragment_home_detail.*
import timber.log.Timber
@ -48,12 +54,15 @@ private const val INDEX_ROOMS = 2
class HomeDetailFragment @Inject constructor(
val homeDetailViewModelFactory: HomeDetailViewModel.Factory,
private val avatarRenderer: AvatarRenderer
private val avatarRenderer: AvatarRenderer,
private val alertManager: PopupAlertManager
) : VectorBaseFragment(), KeysBackupBanner.Delegate {
private val unreadCounterBadgeViews = arrayListOf<UnreadCounterBadgeView>()
private val viewModel: HomeDetailViewModel by fragmentViewModel()
private val unknownDeviceDetectorSharedViewModel : UnknownDeviceDetectorSharedViewModel by activityViewModel()
private lateinit var sharedActionViewModel: HomeSharedActionViewModel
override fun getLayoutResId() = R.layout.fragment_home_detail
@ -77,6 +86,38 @@ class HomeDetailFragment @Inject constructor(
viewModel.selectSubscribe(this, HomeDetailViewState::displayMode) { displayMode ->
switchDisplayMode(displayMode)
}
unknownDeviceDetectorSharedViewModel.subscribe {
it.unknownSessions.invoke()?.let { unknownDevices ->
Timber.v("## Detector - ${unknownDevices.size} Unknown sessions")
unknownDevices.forEachIndexed { index, deviceInfo ->
Timber.v("## Detector - #$index deviceId:${deviceInfo.second.deviceId} lastSeenTs:${deviceInfo.second.lastSeenTs}")
}
val uid = "Newest_Device"
alertManager.cancelAlert(uid)
if (it.canCrossSign && unknownDevices.isNotEmpty()) {
val newest = unknownDevices.first().second
val user = unknownDevices.first().first
alertManager.postVectorAlert(
VerificationVectorAlert(
uid = uid,
title = getString(R.string.new_session),
description = getString(R.string.new_session_review),
iconId = R.drawable.ic_shield_warning
).apply {
matrixItem = user
colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent)
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)
?.navigator
?.requestSessionVerification(requireContext(), newest.deviceId ?: "")
}
dismissedAction = Runnable {}
}
)
}
}
}
}
private fun onGroupChange(groupSummary: GroupSummary?) {

View file

@ -0,0 +1,86 @@
/*
* 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.home
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.NoOpCancellable
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.matrix.rx.rx
import im.vector.matrix.rx.singleBuilder
import im.vector.riotx.core.di.HasScreenInjector
import im.vector.riotx.core.platform.EmptyAction
import im.vector.riotx.core.platform.EmptyViewEvents
import im.vector.riotx.core.platform.VectorViewModel
import io.reactivex.android.schedulers.AndroidSchedulers
data class UnknownDevicesState(
val unknownSessions: Async<List<Pair<MatrixItem?, DeviceInfo>>> = Uninitialized,
val canCrossSign: Boolean = false
) : MvRxState
class UnknownDeviceDetectorSharedViewModel(session: Session, initialState: UnknownDevicesState)
: VectorViewModel<UnknownDevicesState, EmptyAction, EmptyViewEvents>(initialState) {
init {
session.rx().liveUserCryptoDevices(session.myUserId)
.observeOn(AndroidSchedulers.mainThread())
.switchMap { deviceList ->
// Timber.v("## Detector - ============================")
// Timber.v("## Detector - Crypto device update ${deviceList.map { "${it.deviceId} : ${it.isVerified}" }}")
singleBuilder<DevicesListResponse> {
session.cryptoService().getDevicesList(it)
NoOpCancellable
}.map { resp ->
// Timber.v("## Detector - Device Infos ${resp.devices?.map { "${it.deviceId} : lastSeen:${it.lastSeenTs}" }}")
resp.devices?.filter { info ->
deviceList.firstOrNull { info.deviceId == it.deviceId }?.let {
!it.isVerified
} ?: false
}?.sortedByDescending { it.lastSeenTs }
?.map {
session.getUser(it.user_id ?: "")?.toMatrixItem() to it
} ?: emptyList()
}
.toObservable()
}
.execute { async ->
copy(unknownSessions = async)
}
session.rx().liveCrossSigningInfo(session.myUserId)
.execute {
copy(canCrossSign = session.cryptoService().crossSigningService().canCrossSign())
}
}
override fun handle(action: EmptyAction) {}
companion object : MvRxViewModelFactory<UnknownDeviceDetectorSharedViewModel, UnknownDevicesState> {
override fun create(viewModelContext: ViewModelContext, state: UnknownDevicesState): UnknownDeviceDetectorSharedViewModel? {
val session = (viewModelContext.activity as HasScreenInjector).injector().activeSessionHolder().getActiveSession()
return UnknownDeviceDetectorSharedViewModel(session, state)
}
}
}

View file

@ -250,7 +250,7 @@ class RoomDetailFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java)
attachmentsHelper = AttachmentsHelper.create(this, this).register()
attachmentsHelper = AttachmentsHelper(requireContext(), this).register()
keyboardStateUtils = KeyboardStateUtils(requireActivity())
setupToolbar(roomToolbar)
setupRecyclerView()
@ -290,9 +290,9 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.observeViewEvents {
when (it) {
is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable)
is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds)
is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it)
is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable)
is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds)
is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it)
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG)
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
@ -665,7 +665,7 @@ class RoomDetailFragment @Inject constructor(
private fun sendUri(uri: Uri): Boolean {
roomDetailViewModel.preventAttachmentPreview = true
val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(shareIntent)
val isHandled = attachmentsHelper.handleShareIntent(requireContext(), shareIntent)
if (!isHandled) {
roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show()
@ -1350,11 +1350,11 @@ class RoomDetailFragment @Inject constructor(
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
when (type) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera()
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile()
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery()
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio()
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact()
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(this)
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(this)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(this)
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(this)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(this)
AttachmentTypeSelectorView.Type.STICKER -> vectorBaseActivity.notImplemented("Adding stickers")
}.exhaustive
}

View file

@ -610,7 +610,7 @@ class RoomDetailViewModel @AssistedInject constructor(
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet())
else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError(
tooBigFile.name ?: tooBigFile.path,
tooBigFile.name ?: tooBigFile.queryUri.toString(),
tooBigFile.size,
maxUploadFileSize
))

View file

@ -58,7 +58,7 @@ class ViewReactionsBottomSheet : VectorBaseBottomSheetDialogFragment(), ViewReac
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java)
recyclerView.configureWith(epoxyController, hasFixedSize = false)
recyclerView.configureWith(epoxyController, hasFixedSize = false, showDivider = true)
bottomSheetTitle.text = context?.getString(R.string.reactions)
epoxyController.listener = this
}

View file

@ -20,14 +20,13 @@ import android.app.Activity
import android.app.Application
import android.os.Bundle
import im.vector.riotx.features.popup.PopupAlertManager
import javax.inject.Inject
class VectorActivityLifecycleCallbacks @Inject constructor() : Application.ActivityLifecycleCallbacks {
class VectorActivityLifecycleCallbacks constructor(private val popupAlertManager: PopupAlertManager) : Application.ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity) {
}
override fun onActivityResumed(activity: Activity) {
PopupAlertManager.onNewActivityDisplayed(activity)
popupAlertManager.onNewActivityDisplayed(activity)
}
override fun onActivityStarted(activity: Activity) {

View file

@ -83,12 +83,12 @@ class DefaultNavigator @Inject constructor(
}
}
override fun requestSessionVerification(context: Context) {
override fun requestSessionVerification(context: Context, otherSessionId: String) {
val session = sessionHolder.getSafeActiveSession() ?: return
val pr = session.cryptoService().verificationService().requestKeyVerification(
supportedVerificationMethodsProvider.provide(),
session.myUserId,
session.cryptoService().getUserDevices(session.myUserId).map { it.deviceId }
listOf(otherSessionId)
)
if (context is VectorBaseActivity) {
VerificationBottomSheet.withArgs(

View file

@ -30,7 +30,7 @@ interface Navigator {
fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String)
fun requestSessionVerification(context: Context)
fun requestSessionVerification(context: Context, otherSessionId: String)
fun waitSessionVerification(context: Context)

View file

@ -20,20 +20,23 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import android.widget.ImageView
import com.tapadoo.alerter.Alerter
import com.tapadoo.alerter.OnHideAlertListener
import dagger.Lazy
import im.vector.riotx.R
import im.vector.riotx.features.home.AvatarRenderer
import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Singleton
/**
* Responsible of displaying important popup alerts on top of the screen.
* Alerts are stacked and will be displayed sequentially
*/
object PopupAlertManager {
@Singleton
class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy<AvatarRenderer>) {
private var weakCurrentActivity: WeakReference<Activity>? = null
private var currentAlerter: VectorAlert? = null
@ -160,9 +163,19 @@ object PopupAlertManager {
clearLightStatusBar()
alert.weakCurrentActivity = WeakReference(activity)
Alerter.create(activity)
.setTitle(alert.title)
val alerter = if (alert is VerificationVectorAlert) Alerter.create(activity, R.layout.alerter_verification_layout)
else Alerter.create(activity)
alerter.setTitle(alert.title)
.setText(alert.description)
.also { al ->
if (alert is VerificationVectorAlert) {
val tvCustomView = al.getLayoutContainer()
tvCustomView?.findViewById<ImageView>(R.id.ivUserAvatar)?.let { imageView ->
alert.matrixItem?.let { avatarRenderer.get().render(it, imageView) }
}
}
}
.apply {
if (!animate) {
setEnterAnimation(R.anim.anim_alerter_no_anim)
@ -226,37 +239,4 @@ object PopupAlertManager {
displayNextIfPossible()
}, 500)
}
/**
* Dataclass to describe an important alert with actions.
*/
data class VectorAlert(val uid: String,
val title: String,
val description: String,
@DrawableRes val iconId: Int?,
val shouldBeDisplayedIn: ((Activity) -> Boolean)? = null) {
data class Button(val title: String, val action: Runnable, val autoClose: Boolean)
// will be set by manager, and accessible by actions at runtime
var weakCurrentActivity: WeakReference<Activity>? = null
val actions = ArrayList<Button>()
var contentAction: Runnable? = null
var dismissedAction: Runnable? = null
/** If this timestamp is after current time, this alert will be skipped */
var expirationTimestamp: Long? = null
fun addButton(title: String, action: Runnable, autoClose: Boolean = true) {
actions.add(Button(title, action, autoClose))
}
@ColorRes
var colorRes: Int? = null
@ColorInt
var colorInt: Int? = null
}
}

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.popup
import android.app.Activity
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import im.vector.matrix.android.api.util.MatrixItem
import java.lang.ref.WeakReference
interface VectorAlert {
val uid: String
val title: String
val description: String
val iconId: Int?
val shouldBeDisplayedIn: ((Activity) -> Boolean)?
data class Button(val title: String, val action: Runnable, val autoClose: Boolean)
// will be set by manager, and accessible by actions at runtime
var weakCurrentActivity: WeakReference<Activity>?
val actions: MutableList<Button>
var contentAction: Runnable?
var dismissedAction: Runnable?
/** If this timestamp is after current time, this alert will be skipped */
var expirationTimestamp: Long?
fun addButton(title: String, action: Runnable, autoClose: Boolean = true) {
actions.add(Button(title, action, autoClose))
}
var colorRes: Int?
var colorInt: Int?
}
/**
* Dataclass to describe an important alert with actions.
*/
open class DefaultVectorAlert(override val uid: String,
override val title: String,
override val description: String,
@DrawableRes override val iconId: Int?,
override val shouldBeDisplayedIn: ((Activity) -> Boolean)? = null) : VectorAlert {
// will be set by manager, and accessible by actions at runtime
override var weakCurrentActivity: WeakReference<Activity>? = null
override val actions = ArrayList<VectorAlert.Button>()
override var contentAction: Runnable? = null
override var dismissedAction: Runnable? = null
/** If this timestamp is after current time, this alert will be skipped */
override var expirationTimestamp: Long? = null
override fun addButton(title: String, action: Runnable, autoClose: Boolean) {
actions.add(VectorAlert.Button(title, action, autoClose))
}
@ColorRes
override var colorRes: Int? = null
@ColorInt
override var colorInt: Int? = null
}
class VerificationVectorAlert(uid: String,
title: String,
override val description: String,
@DrawableRes override val iconId: Int?,
override val shouldBeDisplayedIn: ((Activity) -> Boolean)? = null
) : DefaultVectorAlert(
uid, title, description, iconId, shouldBeDisplayedIn
) {
var matrixItem: MatrixItem? = null
}

View file

@ -57,6 +57,8 @@ class VectorSettingsActivity : VectorBaseActivity(),
when (intent.getIntExtra(EXTRA_DIRECT_ACCESS, EXTRA_DIRECT_ACCESS_ROOT)) {
EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS ->
replaceFragment(R.id.vector_settings_page, VectorSettingsAdvancedSettingsFragment::class.java, null, FRAGMENT_TAG)
EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY ->
replaceFragment(R.id.vector_settings_page, VectorSettingsSecurityPrivacyFragment::class.java, null, FRAGMENT_TAG)
else ->
replaceFragment(R.id.vector_settings_page, VectorSettingsRootFragment::class.java, null, FRAGMENT_TAG)
}
@ -116,6 +118,7 @@ class VectorSettingsActivity : VectorBaseActivity(),
const val EXTRA_DIRECT_ACCESS_ROOT = 0
const val EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS = 1
const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY = 2
private const val FRAGMENT_TAG = "VectorSettingsPreferencesFragment"
}

View file

@ -78,6 +78,17 @@ class CrossSigningEpoxyController @Inject constructor(
interactionListener?.onResetCrossSigningKeys()
}
}
bottomSheetVerificationActionItem {
id("verify")
title(stringProvider.getString(R.string.complete_security))
titleColor(colorProvider.getColor(R.color.riotx_positive_accent))
iconRes(R.drawable.ic_arrow_right)
iconColor(colorProvider.getColor(R.color.riotx_positive_accent))
listener {
interactionListener?.verifySession()
}
}
}
} else if (data.xSigningIsEnableInAccount) {
genericItem {

Some files were not shown because too many files have changed in this diff Show more