diff --git a/CHANGES.md b/CHANGES.md index e264facd8e..db9b472ad9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,42 @@ -Changes in Riot.imX 0.91.4 (2020-XX-XX) +Changes in Riot.imX 0.91.5 (2020-07-11) +=================================================== + +Features ✨: + - 3pid invite: it is now possible to invite people by email. An Identity Server has to be configured (#548) + +Improvements 🙌: + - Cleaning chunks with lots of events as long as a threshold has been exceeded (35_000 events in DB) (#1634) + - Creating and listening to EventInsertEntity. (#1634) + - Handling (almost) properly the groups fetching (#1634) + - Improve fullscreen media display (#327) + - Setup server recovery banner (#1648) + - Set up SSSS from security settings (#1567) + - New lab setting to add 'unread notifications' tab to main screen + - Render third party invite event (#548) + - Display three pid invites in the room members list (#548) + +Bugfix 🐛: + - Integration Manager: Wrong URL to review terms if URL in config contains path (#1606) + - Regression Composer does not grow, crops out text (#1650) + - Bug / Unwanted draft (#698) + - All users seems to be able to see the enable encryption option in room settings (#1341) + - Leave room only leaves the current version (#1656) + - Regression | Share action menu do not work (#1647) + - verification issues on transition (#1555) + - Fix issue when restoring keys backup using recovery key + +SDK API changes ⚠️: + - CreateRoomParams has been updated + +Build 🧱: + - Upgrade some dependencies + - Revert to build-tools 3.5.3 + +Other changes: + - Use Intent.ACTION_CREATE_DOCUMENT to save megolm key or recovery key in a txt file + - Use `Context#withStyledAttributes` extension function (#1546) + +Changes in Riot.imX 0.91.4 (2020-07-06) =================================================== Features ✨: @@ -16,7 +54,7 @@ Bugfix 🐛: Build 🧱: - Fix lint false-positive about WorkManager (#1012) - - Upgrade build-tools from 3.5.3 to 3.6.6 + - Upgrade build-tools from 3.5.3 to 3.6.3 - Upgrade gradle from 5.4.1 to 5.6.4 Changes in Riot.imX 0.91.3 (2020-07-01) diff --git a/attachment-viewer/.gitignore b/attachment-viewer/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/attachment-viewer/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/attachment-viewer/build.gradle b/attachment-viewer/build.gradle new file mode 100644 index 0000000000..3a5c3298d4 --- /dev/null +++ b/attachment-viewer/build.gradle @@ -0,0 +1,78 @@ +/* + * 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' + +buildscript { + repositories { + maven { + url 'https://jitpack.io' + content { + // PhotoView + includeGroupByRegex 'com\\.github\\.chrisbanes' + } + } + jcenter() + } + +} + +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation 'com.github.chrisbanes:PhotoView:2.0.0' + + implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' + + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.0' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.1.0' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + +} \ No newline at end of file diff --git a/attachment-viewer/consumer-rules.pro b/attachment-viewer/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/attachment-viewer/proguard-rules.pro b/attachment-viewer/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/attachment-viewer/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/attachment-viewer/src/main/AndroidManifest.xml b/attachment-viewer/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ff8ec394d2 --- /dev/null +++ b/attachment-viewer/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt new file mode 100644 index 0000000000..f00a4eff30 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AnimatedImageViewHolder.kt @@ -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.attachmentviewer + +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar + +class AnimatedImageViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + val touchImageView: ImageView = itemView.findViewById(R.id.imageView) + val imageLoaderProgress: ProgressBar = itemView.findViewById(R.id.imageLoaderProgress) + + internal val target = DefaultImageLoaderTarget(this, this.touchImageView) +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt new file mode 100644 index 0000000000..b2b6c9fe16 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentEvents.kt @@ -0,0 +1,31 @@ +/* + * 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.attachmentviewer + +sealed class AttachmentEvents { + data class VideoEvent(val isPlaying: Boolean, val progress: Int, val duration: Int) : AttachmentEvents() +} + +interface AttachmentEventListener { + fun onEvent(event: AttachmentEvents) +} + +sealed class AttachmentCommands { + object PauseVideo : AttachmentCommands() + object StartVideo : AttachmentCommands() + data class SeekTo(val percentProgress: Int) : AttachmentCommands() +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt new file mode 100644 index 0000000000..92a4f1d9e4 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentSourceProvider.kt @@ -0,0 +1,45 @@ +/* + * 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.attachmentviewer + +import android.content.Context +import android.view.View + +sealed class AttachmentInfo(open val uid: String) { + data class Image(override val uid: String, val url: String, val data: Any?) : AttachmentInfo(uid) + data class AnimatedImage(override val uid: String, val url: String, val data: Any?) : AttachmentInfo(uid) + data class Video(override val uid: String, val url: String, val data: Any, val thumbnail: Image?) : AttachmentInfo(uid) +// data class Audio(override val uid: String, val url: String, val data: Any) : AttachmentInfo(uid) +// data class File(override val uid: String, val url: String, val data: Any) : AttachmentInfo(uid) +} + +interface AttachmentSourceProvider { + + fun getItemCount(): Int + + fun getAttachmentInfoAt(position: Int): AttachmentInfo + + fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.Image) + + fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.AnimatedImage) + + fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) + + fun overlayViewAtPosition(context: Context, position: Int): View? + + fun clear(id: String) +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt new file mode 100644 index 0000000000..8c2d4e9833 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentViewerActivity.kt @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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.attachmentviewer + +import android.graphics.Color +import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.ImageView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.transition.TransitionManager +import androidx.viewpager2.widget.ViewPager2 +import kotlinx.android.synthetic.main.activity_attachment_viewer.* +import java.lang.ref.WeakReference +import kotlin.math.abs + +abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener { + + lateinit var pager2: ViewPager2 + lateinit var imageTransitionView: ImageView + lateinit var transitionImageContainer: ViewGroup + + var topInset = 0 + var bottomInset = 0 + var systemUiVisibility = true + + private var overlayView: View? = null + set(value) { + if (value == overlayView) return + overlayView?.let { rootContainer.removeView(it) } + rootContainer.addView(value) + value?.updatePadding(top = topInset, bottom = bottomInset) + field = value + } + + private lateinit var swipeDismissHandler: SwipeToDismissHandler + private lateinit var directionDetector: SwipeDirectionDetector + private lateinit var scaleDetector: ScaleGestureDetector + private lateinit var gestureDetector: GestureDetectorCompat + + var currentPosition = 0 + + private var swipeDirection: SwipeDirection? = null + + private fun isScaled() = attachmentsAdapter.isScaled(currentPosition) + + private var wasScaled: Boolean = false + private var isSwipeToDismissAllowed: Boolean = true + private lateinit var attachmentsAdapter: AttachmentsAdapter + private var isOverlayWasClicked = false + +// private val shouldDismissToBottom: Boolean +// get() = e == null +// || !externalTransitionImageView.isRectVisible +// || !isAtStartPosition + + private var isImagePagerIdle = true + + fun setSourceProvider(sourceProvider: AttachmentSourceProvider) { + attachmentsAdapter.attachmentSourceProvider = sourceProvider + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // This is important for the dispatchTouchEvent, if not we must correct + // the touch coordinates + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE) + window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + + setContentView(R.layout.activity_attachment_viewer) + attachmentPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL + attachmentsAdapter = AttachmentsAdapter() + attachmentPager.adapter = attachmentsAdapter + imageTransitionView = transitionImageView + transitionImageContainer = findViewById(R.id.transitionImageContainer) + pager2 = attachmentPager + directionDetector = createSwipeDirectionDetector() + gestureDetector = createGestureDetector() + + attachmentPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + isImagePagerIdle = state == ViewPager2.SCROLL_STATE_IDLE + } + + override fun onPageSelected(position: Int) { + onSelectedPositionChanged(position) + } + }) + + swipeDismissHandler = createSwipeToDismissHandler() + rootContainer.setOnTouchListener(swipeDismissHandler) + rootContainer.viewTreeObserver.addOnGlobalLayoutListener { swipeDismissHandler.translationLimit = dismissContainer.height / 4 } + + scaleDetector = createScaleGestureDetector() + + ViewCompat.setOnApplyWindowInsetsListener(rootContainer) { _, insets -> + overlayView?.updatePadding(top = insets.systemWindowInsetTop, bottom = insets.systemWindowInsetBottom) + topInset = insets.systemWindowInsetTop + bottomInset = insets.systemWindowInsetBottom + insets + } + } + + fun onSelectedPositionChanged(position: Int) { + attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(currentPosition)?.let { + (it as? BaseViewHolder)?.onSelected(false) + } + attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(position)?.let { + (it as? BaseViewHolder)?.onSelected(true) + if (it is VideoViewHolder) { + it.eventListener = WeakReference(this) + } + } + currentPosition = position + overlayView = attachmentsAdapter.attachmentSourceProvider?.overlayViewAtPosition(this@AttachmentViewerActivity, position) + } + + override fun onPause() { + attachmentsAdapter.onPause(currentPosition) + super.onPause() + } + + override fun onResume() { + super.onResume() + attachmentsAdapter.onResume(currentPosition) + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + // The zoomable view is configured to disallow interception when image is zoomed + + // Check if the overlay is visible, and wants to handle the click + if (overlayView?.isVisible == true && overlayView?.dispatchTouchEvent(ev) == true) { + return true + } + + // Log.v("ATTACHEMENTS", "================\ndispatchTouchEvent $ev") + handleUpDownEvent(ev) + + // Log.v("ATTACHEMENTS", "scaleDetector is in progress ${scaleDetector.isInProgress}") + // Log.v("ATTACHEMENTS", "pointerCount ${ev.pointerCount}") + // Log.v("ATTACHEMENTS", "wasScaled $wasScaled") + if (swipeDirection == null && (scaleDetector.isInProgress || ev.pointerCount > 1 || wasScaled)) { + wasScaled = true +// Log.v("ATTACHEMENTS", "dispatch to pager") + return attachmentPager.dispatchTouchEvent(ev) + } + + // Log.v("ATTACHEMENTS", "is current item scaled ${isScaled()}") + return (if (isScaled()) super.dispatchTouchEvent(ev) else handleTouchIfNotScaled(ev)).also { +// Log.v("ATTACHEMENTS", "\n================") + } + } + + private fun handleUpDownEvent(event: MotionEvent) { + // Log.v("ATTACHEMENTS", "handleUpDownEvent $event") + if (event.action == MotionEvent.ACTION_UP) { + handleEventActionUp(event) + } + + if (event.action == MotionEvent.ACTION_DOWN) { + handleEventActionDown(event) + } + + scaleDetector.onTouchEvent(event) + gestureDetector.onTouchEvent(event) + } + + private fun handleEventActionDown(event: MotionEvent) { + swipeDirection = null + wasScaled = false + attachmentPager.dispatchTouchEvent(event) + + swipeDismissHandler.onTouch(rootContainer, event) + isOverlayWasClicked = dispatchOverlayTouch(event) + } + + private fun handleEventActionUp(event: MotionEvent) { +// wasDoubleTapped = false + swipeDismissHandler.onTouch(rootContainer, event) + attachmentPager.dispatchTouchEvent(event) + isOverlayWasClicked = dispatchOverlayTouch(event) + } + + private fun handleSingleTap(event: MotionEvent, isOverlayWasClicked: Boolean) { + // TODO if there is no overlay, we should at least toggle system bars? + if (overlayView != null && !isOverlayWasClicked) { + toggleOverlayViewVisibility() + super.dispatchTouchEvent(event) + } + } + + private fun toggleOverlayViewVisibility() { + if (systemUiVisibility) { + // we hide + TransitionManager.beginDelayedTransition(rootContainer) + hideSystemUI() + overlayView?.isVisible = false + } else { + // we show + TransitionManager.beginDelayedTransition(rootContainer) + showSystemUI() + overlayView?.isVisible = true + } + } + + private fun handleTouchIfNotScaled(event: MotionEvent): Boolean { +// Log.v("ATTACHEMENTS", "handleTouchIfNotScaled $event") + directionDetector.handleTouchEvent(event) + + return when (swipeDirection) { + SwipeDirection.Up, SwipeDirection.Down -> { + if (isSwipeToDismissAllowed && !wasScaled && isImagePagerIdle) { + swipeDismissHandler.onTouch(rootContainer, event) + } else true + } + SwipeDirection.Left, SwipeDirection.Right -> { + attachmentPager.dispatchTouchEvent(event) + } + else -> true + } + } + + private fun handleSwipeViewMove(translationY: Float, translationLimit: Int) { + val alpha = calculateTranslationAlpha(translationY, translationLimit) + backgroundView.alpha = alpha + dismissContainer.alpha = alpha + overlayView?.alpha = alpha + } + + private fun dispatchOverlayTouch(event: MotionEvent): Boolean = + overlayView + ?.let { it.isVisible && it.dispatchTouchEvent(event) } + ?: false + + private fun calculateTranslationAlpha(translationY: Float, translationLimit: Int): Float = + 1.0f - 1.0f / translationLimit.toFloat() / 4f * abs(translationY) + + private fun createSwipeToDismissHandler() + : SwipeToDismissHandler = SwipeToDismissHandler( + swipeView = dismissContainer, + shouldAnimateDismiss = { shouldAnimateDismiss() }, + onDismiss = { animateClose() }, + onSwipeViewMove = ::handleSwipeViewMove) + + private fun createSwipeDirectionDetector() = + SwipeDirectionDetector(this) { swipeDirection = it } + + private fun createScaleGestureDetector() = + ScaleGestureDetector(this, ScaleGestureDetector.SimpleOnScaleGestureListener()) + + private fun createGestureDetector() = + GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (isImagePagerIdle) { + handleSingleTap(e, isOverlayWasClicked) + } + return false + } + + override fun onDoubleTap(e: MotionEvent?): Boolean { + return super.onDoubleTap(e) + } + }) + + override fun onEvent(event: AttachmentEvents) { + if (overlayView is AttachmentEventListener) { + (overlayView as? AttachmentEventListener)?.onEvent(event) + } + } + + protected open fun shouldAnimateDismiss(): Boolean = true + + protected open fun animateClose() { + window.statusBarColor = Color.TRANSPARENT + finish() + } + + fun handle(commands: AttachmentCommands) { + (attachmentsAdapter.recyclerView?.findViewHolderForAdapterPosition(currentPosition) as? BaseViewHolder) + ?.handleCommand(commands) + } + + private fun hideSystemUI() { + systemUiVisibility = false + // Enables regular immersive mode. + // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. + // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE + // Set the content to appear under the system bars so that the + // content doesn't resize when the system bars hide and show. + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + // Hide the nav bar and status bar + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN) + } + + // Shows the system bars by removing all the flags +// except for the ones that make the content appear under the system bars. + private fun showSystemUI() { + systemUiVisibility = true + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt new file mode 100644 index 0000000000..27bdfdc91d --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/AttachmentsAdapter.kt @@ -0,0 +1,115 @@ +/* + * 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.attachmentviewer + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +class AttachmentsAdapter : RecyclerView.Adapter() { + + var attachmentSourceProvider: AttachmentSourceProvider? = null + set(value) { + field = value + notifyDataSetChanged() + } + + var recyclerView: RecyclerView? = null + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = null + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { + val inflater = LayoutInflater.from(parent.context) + val itemView = inflater.inflate(viewType, parent, false) + return when (viewType) { + R.layout.item_image_attachment -> ZoomableImageViewHolder(itemView) + R.layout.item_animated_image_attachment -> AnimatedImageViewHolder(itemView) + R.layout.item_video_attachment -> VideoViewHolder(itemView) + else -> UnsupportedViewHolder(itemView) + } + } + + override fun getItemViewType(position: Int): Int { + val info = attachmentSourceProvider!!.getAttachmentInfoAt(position) + return when (info) { + is AttachmentInfo.Image -> R.layout.item_image_attachment + is AttachmentInfo.Video -> R.layout.item_video_attachment + is AttachmentInfo.AnimatedImage -> R.layout.item_animated_image_attachment +// is AttachmentInfo.Audio -> TODO() +// is AttachmentInfo.File -> TODO() + } + } + + override fun getItemCount(): Int { + return attachmentSourceProvider?.getItemCount() ?: 0 + } + + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + attachmentSourceProvider?.getAttachmentInfoAt(position)?.let { + holder.bind(it) + when (it) { + is AttachmentInfo.Image -> { + attachmentSourceProvider?.loadImage((holder as ZoomableImageViewHolder).target, it) + } + is AttachmentInfo.AnimatedImage -> { + attachmentSourceProvider?.loadImage((holder as AnimatedImageViewHolder).target, it) + } + is AttachmentInfo.Video -> { + attachmentSourceProvider?.loadVideo((holder as VideoViewHolder).target, it) + } +// else -> { +// // } + } + } + } + + override fun onViewAttachedToWindow(holder: BaseViewHolder) { + holder.onAttached() + } + + override fun onViewRecycled(holder: BaseViewHolder) { + holder.onRecycled() + } + + override fun onViewDetachedFromWindow(holder: BaseViewHolder) { + holder.onDetached() + } + + fun isScaled(position: Int): Boolean { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) + if (holder is ZoomableImageViewHolder) { + return holder.touchImageView.attacher.scale > 1f + } + return false + } + + fun onPause(position: Int) { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) as? BaseViewHolder + holder?.entersBackground() + } + + fun onResume(position: Int) { + val holder = recyclerView?.findViewHolderForAdapterPosition(position) as? BaseViewHolder + holder?.entersForeground() + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt new file mode 100644 index 0000000000..49b47c11ff --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/BaseViewHolder.kt @@ -0,0 +1,45 @@ +/* + * 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.attachmentviewer + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +abstract class BaseViewHolder constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + + open fun onRecycled() { + boundResourceUid = null + } + + open fun onAttached() {} + open fun onDetached() {} + open fun entersBackground() {} + open fun entersForeground() {} + open fun onSelected(selected: Boolean) {} + + open fun handleCommand(commands: AttachmentCommands) {} + + var boundResourceUid: String? = null + + open fun bind(attachmentInfo: AttachmentInfo) { + boundResourceUid = attachmentInfo.uid + } +} + +class UnsupportedViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt new file mode 100644 index 0000000000..bb59c9e01e --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ImageLoaderTarget.kt @@ -0,0 +1,103 @@ +/* + * 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.attachmentviewer + +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams + +interface ImageLoaderTarget { + + fun contextView(): ImageView + + fun onResourceLoading(uid: String, placeholder: Drawable?) + + fun onLoadFailed(uid: String, errorDrawable: Drawable?) + + fun onResourceCleared(uid: String, placeholder: Drawable?) + + fun onResourceReady(uid: String, resource: Drawable) +} + +internal class DefaultImageLoaderTarget(val holder: AnimatedImageViewHolder, private val contextView: ImageView) + : ImageLoaderTarget { + override fun contextView(): ImageView { + return contextView + } + + override fun onResourceLoading(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = true + } + + override fun onLoadFailed(uid: String, errorDrawable: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + } + + override fun onResourceCleared(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.touchImageView.setImageDrawable(placeholder) + } + + override fun onResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + // Glide mess up the view size :/ + holder.touchImageView.updateLayoutParams { + width = LinearLayout.LayoutParams.MATCH_PARENT + height = LinearLayout.LayoutParams.MATCH_PARENT + } + holder.touchImageView.setImageDrawable(resource) + if (resource is Animatable) { + resource.start() + } + } + + internal class ZoomableImageTarget(val holder: ZoomableImageViewHolder, private val contextView: ImageView) : ImageLoaderTarget { + override fun contextView() = contextView + + override fun onResourceLoading(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = true + } + + override fun onLoadFailed(uid: String, errorDrawable: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + } + + override fun onResourceCleared(uid: String, placeholder: Drawable?) { + if (holder.boundResourceUid != uid) return + holder.touchImageView.setImageDrawable(placeholder) + } + + override fun onResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.imageLoaderProgress.isVisible = false + // Glide mess up the view size :/ + holder.touchImageView.updateLayoutParams { + width = LinearLayout.LayoutParams.MATCH_PARENT + height = LinearLayout.LayoutParams.MATCH_PARENT + } + holder.touchImageView.setImageDrawable(resource) + } + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt new file mode 100644 index 0000000000..ebe8784e15 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirection.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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.attachmentviewer + +sealed class SwipeDirection { + object NotDetected : SwipeDirection() + object Up : SwipeDirection() + object Down : SwipeDirection() + object Left : SwipeDirection() + object Right : SwipeDirection() + + companion object { + fun fromAngle(angle: Double): SwipeDirection { + return when (angle) { + in 0.0..45.0 -> Right + in 45.0..135.0 -> Up + in 135.0..225.0 -> Left + in 225.0..315.0 -> Down + in 315.0..360.0 -> Right + else -> NotDetected + } + } + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt new file mode 100644 index 0000000000..0cf9a19ab1 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeDirectionDetector.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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.attachmentviewer + +import android.content.Context +import android.view.MotionEvent +import kotlin.math.sqrt + +class SwipeDirectionDetector( + context: Context, + private val onDirectionDetected: (SwipeDirection) -> Unit +) { + + private val touchSlop: Int = android.view.ViewConfiguration.get(context).scaledTouchSlop + private var startX: Float = 0f + private var startY: Float = 0f + private var isDetected: Boolean = false + + fun handleTouchEvent(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = event.x + startY = event.y + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + if (!isDetected) { + onDirectionDetected(SwipeDirection.NotDetected) + } + startY = 0.0f + startX = startY + isDetected = false + } + MotionEvent.ACTION_MOVE -> if (!isDetected && getEventDistance(event) > touchSlop) { + isDetected = true + onDirectionDetected(getDirection(startX, startY, event.x, event.y)) + } + } + } + + /** + * Given two points in the plane p1=(x1, x2) and p2=(y1, y1), this method + * returns the direction that an arrow pointing from p1 to p2 would have. + * + * @param x1 the x position of the first point + * @param y1 the y position of the first point + * @param x2 the x position of the second point + * @param y2 the y position of the second point + * @return the direction + */ + private fun getDirection(x1: Float, y1: Float, x2: Float, y2: Float): SwipeDirection { + val angle = getAngle(x1, y1, x2, y2) + return SwipeDirection.fromAngle(angle) + } + + /** + * Finds the angle between two points in the plane (x1,y1) and (x2, y2) + * The angle is measured with 0/360 being the X-axis to the right, angles + * increase counter clockwise. + * + * @param x1 the x position of the first point + * @param y1 the y position of the first point + * @param x2 the x position of the second point + * @param y2 the y position of the second point + * @return the angle between two points + */ + private fun getAngle(x1: Float, y1: Float, x2: Float, y2: Float): Double { + val rad = Math.atan2((y1 - y2).toDouble(), (x2 - x1).toDouble()) + Math.PI + return (rad * 180 / Math.PI + 180) % 360 + } + + private fun getEventDistance(ev: MotionEvent): Float { + val dx = ev.getX(0) - startX + val dy = ev.getY(0) - startY + return sqrt((dx * dx + dy * dy).toDouble()).toFloat() + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt new file mode 100644 index 0000000000..ca93d4f73a --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/SwipeToDismissHandler.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright (C) 2018 stfalcon.com + * + * 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.attachmentviewer + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.graphics.Rect +import android.view.MotionEvent +import android.view.View +import android.view.ViewPropertyAnimator +import android.view.animation.AccelerateInterpolator + +class SwipeToDismissHandler( + private val swipeView: View, + private val onDismiss: () -> Unit, + private val onSwipeViewMove: (translationY: Float, translationLimit: Int) -> Unit, + private val shouldAnimateDismiss: () -> Boolean +) : View.OnTouchListener { + + companion object { + private const val ANIMATION_DURATION = 200L + } + + var translationLimit: Int = swipeView.height / 4 + private var isTracking = false + private var startY: Float = 0f + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (swipeView.hitRect.contains(event.x.toInt(), event.y.toInt())) { + isTracking = true + } + startY = event.y + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isTracking) { + isTracking = false + onTrackingEnd(v.height) + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (isTracking) { + val translationY = event.y - startY + swipeView.translationY = translationY + onSwipeViewMove(translationY, translationLimit) + } + return true + } + else -> { + return false + } + } + } + + internal fun initiateDismissToBottom() { + animateTranslation(swipeView.height.toFloat()) + } + + private fun onTrackingEnd(parentHeight: Int) { + val animateTo = when { + swipeView.translationY < -translationLimit -> -parentHeight.toFloat() + swipeView.translationY > translationLimit -> parentHeight.toFloat() + else -> 0f + } + + if (animateTo != 0f && !shouldAnimateDismiss()) { + onDismiss() + } else { + animateTranslation(animateTo) + } + } + + private fun animateTranslation(translationTo: Float) { + swipeView.animate() + .translationY(translationTo) + .setDuration(ANIMATION_DURATION) + .setInterpolator(AccelerateInterpolator()) + .setUpdateListener { onSwipeViewMove(swipeView.translationY, translationLimit) } + .setAnimatorListener(onAnimationEnd = { + if (translationTo != 0f) { + onDismiss() + } + + // remove the update listener, otherwise it will be saved on the next animation execution: + swipeView.animate().setUpdateListener(null) + }) + .start() + } +} + +internal fun ViewPropertyAnimator.setAnimatorListener( + onAnimationEnd: ((Animator?) -> Unit)? = null, + onAnimationStart: ((Animator?) -> Unit)? = null +) = this.setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + onAnimationEnd?.invoke(animation) + } + + override fun onAnimationStart(animation: Animator?) { + onAnimationStart?.invoke(animation) + } + }) + +internal val View?.hitRect: Rect + get() = Rect().also { this?.getHitRect(it) } diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt new file mode 100644 index 0000000000..548c6431e5 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoLoaderTarget.kt @@ -0,0 +1,76 @@ +/* + * 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.attachmentviewer + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.core.view.isVisible +import java.io.File + +interface VideoLoaderTarget { + fun contextView(): ImageView + + fun onThumbnailResourceLoading(uid: String, placeholder: Drawable?) + + fun onThumbnailLoadFailed(uid: String, errorDrawable: Drawable?) + + fun onThumbnailResourceCleared(uid: String, placeholder: Drawable?) + + fun onThumbnailResourceReady(uid: String, resource: Drawable) + + fun onVideoFileLoading(uid: String) + fun onVideoFileLoadFailed(uid: String) + fun onVideoFileReady(uid: String, file: File) +} + +internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val contextView: ImageView) : VideoLoaderTarget { + override fun contextView(): ImageView = contextView + + override fun onThumbnailResourceLoading(uid: String, placeholder: Drawable?) { + } + + override fun onThumbnailLoadFailed(uid: String, errorDrawable: Drawable?) { + } + + override fun onThumbnailResourceCleared(uid: String, placeholder: Drawable?) { + } + + override fun onThumbnailResourceReady(uid: String, resource: Drawable) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.setImageDrawable(resource) + } + + override fun onVideoFileLoading(uid: String) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.isVisible = true + holder.loaderProgressBar.isVisible = true + holder.videoView.isVisible = false + } + + override fun onVideoFileLoadFailed(uid: String) { + if (holder.boundResourceUid != uid) return + holder.videoFileLoadError() + } + + override fun onVideoFileReady(uid: String, file: File) { + if (holder.boundResourceUid != uid) return + holder.thumbnailImage.isVisible = false + holder.loaderProgressBar.isVisible = false + holder.videoView.isVisible = true + holder.videoReady(file) + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt new file mode 100644 index 0000000000..e1a5a9864f --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/VideoViewHolder.kt @@ -0,0 +1,157 @@ +/* + * 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.attachmentviewer + +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.VideoView +import androidx.core.view.isVisible +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import java.io.File +import java.lang.ref.WeakReference +import java.util.concurrent.TimeUnit + +// TODO, it would be probably better to use a unique media player +// for better customization and control +// But for now VideoView is enough, it released player when detached, we use a timer to update progress +class VideoViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + private var isSelected = false + private var mVideoPath: String? = null + private var progressDisposable: Disposable? = null + private var progress: Int = 0 + private var wasPaused = false + + var eventListener: WeakReference? = null + + val thumbnailImage: ImageView = itemView.findViewById(R.id.videoThumbnailImage) + val videoView: VideoView = itemView.findViewById(R.id.videoView) + val loaderProgressBar: ProgressBar = itemView.findViewById(R.id.videoLoaderProgress) + val videoControlIcon: ImageView = itemView.findViewById(R.id.videoControlIcon) + val errorTextView: TextView = itemView.findViewById(R.id.videoMediaViewerErrorView) + + internal val target = DefaultVideoLoaderTarget(this, thumbnailImage) + + override fun onRecycled() { + super.onRecycled() + progressDisposable?.dispose() + progressDisposable = null + mVideoPath = null + } + + fun videoReady(file: File) { + mVideoPath = file.path + if (isSelected) { + startPlaying() + } + } + + fun videoFileLoadError() { + } + + override fun entersBackground() { + if (videoView.isPlaying) { + progress = videoView.currentPosition + progressDisposable?.dispose() + progressDisposable = null + videoView.stopPlayback() + videoView.pause() + } + } + + override fun entersForeground() { + onSelected(isSelected) + } + + override fun onSelected(selected: Boolean) { + if (!selected) { + if (videoView.isPlaying) { + progress = videoView.currentPosition + videoView.stopPlayback() + } else { + progress = 0 + } + progressDisposable?.dispose() + progressDisposable = null + } else { + if (mVideoPath != null) { + startPlaying() + } + } + isSelected = true + } + + private fun startPlaying() { + thumbnailImage.isVisible = false + loaderProgressBar.isVisible = false + videoView.isVisible = true + + videoView.setOnPreparedListener { + progressDisposable?.dispose() + progressDisposable = Observable.interval(100, TimeUnit.MILLISECONDS) + .timeInterval() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + val duration = videoView.duration + val progress = videoView.currentPosition + val isPlaying = videoView.isPlaying +// Log.v("FOO", "isPlaying $isPlaying $progress/$duration") + eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) + } + } + + videoView.setVideoPath(mVideoPath) + if (!wasPaused) { + videoView.start() + if (progress > 0) { + videoView.seekTo(progress) + } + } + } + + override fun handleCommand(commands: AttachmentCommands) { + if (!isSelected) return + when (commands) { + AttachmentCommands.StartVideo -> { + wasPaused = false + videoView.start() + } + AttachmentCommands.PauseVideo -> { + wasPaused = true + videoView.pause() + } + is AttachmentCommands.SeekTo -> { + val duration = videoView.duration + if (duration > 0) { + val seekDuration = duration * (commands.percentProgress / 100f) + videoView.seekTo(seekDuration.toInt()) + } + } + } + } + + override fun bind(attachmentInfo: AttachmentInfo) { + super.bind(attachmentInfo) + progress = 0 + wasPaused = false + } +} diff --git a/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt new file mode 100644 index 0000000000..3eb06e4c27 --- /dev/null +++ b/attachment-viewer/src/main/java/im/vector/riotx/attachmentviewer/ZoomableImageViewHolder.kt @@ -0,0 +1,42 @@ +/* + * 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.attachmentviewer + +import android.view.View +import android.widget.ProgressBar +import com.github.chrisbanes.photoview.PhotoView + +class ZoomableImageViewHolder constructor(itemView: View) : + BaseViewHolder(itemView) { + + val touchImageView: PhotoView = itemView.findViewById(R.id.touchImageView) + val imageLoaderProgress: ProgressBar = itemView.findViewById(R.id.imageLoaderProgress) + + init { + touchImageView.setAllowParentInterceptOnEdge(false) + touchImageView.setOnScaleChangeListener { scaleFactor, _, _ -> + // Log.v("ATTACHEMENTS", "scaleFactor $scaleFactor") + // It's a bit annoying but when you pitch down the scaling + // is not exactly one :/ + touchImageView.setAllowParentInterceptOnEdge(scaleFactor <= 1.0008f) + } + touchImageView.setScale(1.0f, true) + touchImageView.setAllowParentInterceptOnEdge(true) + } + + internal val target = DefaultImageLoaderTarget.ZoomableImageTarget(this, touchImageView) +} diff --git a/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml b/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml new file mode 100644 index 0000000000..a8a68db1a5 --- /dev/null +++ b/attachment-viewer/src/main/res/layout/activity_attachment_viewer.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml b/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml new file mode 100644 index 0000000000..1096267124 --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_animated_image_attachment.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_image_attachment.xml b/attachment-viewer/src/main/res/layout/item_image_attachment.xml new file mode 100644 index 0000000000..91a009df2a --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_image_attachment.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/attachment-viewer/src/main/res/layout/item_video_attachment.xml b/attachment-viewer/src/main/res/layout/item_video_attachment.xml new file mode 100644 index 0000000000..29f01650fd --- /dev/null +++ b/attachment-viewer/src/main/res/layout/item_video_attachment.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + diff --git a/attachment-viewer/src/main/res/layout/view_image_attachment.xml b/attachment-viewer/src/main/res/layout/view_image_attachment.xml new file mode 100644 index 0000000000..3518a4472d --- /dev/null +++ b/attachment-viewer/src/main/res/layout/view_image_attachment.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5f1fa78620..47b3ab240d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.3.72' repositories { google() jcenter() @@ -10,12 +10,13 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:3.6.3' + // Warning: 3.6.3 leads to infinite gradle builds. Stick to 3.5.3 for the moment + classpath 'com.android.tools.build:gradle:3.5.3' classpath 'com.google.gms:google-services:4.3.2' classpath "com.airbnb.okreplay:gradle-plugin:1.5.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1' - classpath 'com.google.android.gms:oss-licenses-plugin:0.9.5' + classpath 'com.google.android.gms:oss-licenses-plugin:0.10.2' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -38,6 +39,8 @@ allprojects { includeGroupByRegex "com\\.github\\.yalantis" // JsonViewer includeGroupByRegex 'com\\.github\\.BillCarsonFr' + // PhotoView + includeGroupByRegex 'com\\.github\\.chrisbanes' } } maven { diff --git a/docs/voip_signaling.md b/docs/voip_signaling.md index c80cdd6b96..e055b4cd35 100644 --- a/docs/voip_signaling.md +++ b/docs/voip_signaling.md @@ -1,5 +1,6 @@ Useful links: - https://codelabs.developers.google.com/codelabs/webrtc-web/#0 +- http://webrtc.github.io/webrtc-org/native-code/android/ ╔════════════════════════════════════════════════╗ diff --git a/gradle.properties b/gradle.properties index d9d9e57cbc..99fd9d64fd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ # The setting is particularly useful for tweaking memory settings. android.enableJetifier=true android.useAndroidX=true -org.gradle.jvmargs=-Xmx8192m +org.gradle.jvmargs=-Xmx2048m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/matrix-sdk-android-rx/build.gradle b/matrix-sdk-android-rx/build.gradle index c67d10d810..70a05114c2 100644 --- a/matrix-sdk-android-rx/build.gradle +++ b/matrix-sdk-android-rx/build.gradle @@ -39,7 +39,7 @@ dependencies { implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' // Paging - implementation "androidx.paging:paging-runtime-ktx:2.1.0" + implementation "androidx.paging:paging-runtime-ktx:2.1.2" // Logging implementation 'com.jakewharton.timber:timber:4.7.1' diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index b91949778d..e945a52650 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -19,6 +19,7 @@ package im.vector.matrix.rx import android.net.Uri import im.vector.matrix.android.api.query.QueryStringValue import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary @@ -71,6 +72,13 @@ class RxRoom(private val room: Room) { } } + fun liveStateEvents(eventTypes: Set): Observable> { + return room.getStateEventsLive(eventTypes).asObservable() + .startWithCallable { + room.getStateEvents(eventTypes) + } + } + fun liveReadMarker(): Observable> { return room.getReadMarkerLive().asObservable() } @@ -104,6 +112,10 @@ class RxRoom(private val room: Room) { room.invite(userId, reason, it) } + fun invite3pid(threePid: ThreePid): Completable = completableBuilder { + room.invite3pid(threePid, it) + } + fun updateTopic(topic: String): Completable = completableBuilder { room.updateTopic(topic, it) } diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt index e8fef1361d..ca0bb46f4b 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt @@ -17,14 +17,20 @@ package im.vector.matrix.rx import androidx.paging.PagedList +import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.query.QueryStringValue import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME import im.vector.matrix.android.api.session.crypto.crosssigning.MXCrossSigningInfo +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME import im.vector.matrix.android.api.session.group.GroupSummaryQueryParams import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.pushers.Pusher import im.vector.matrix.android.api.session.room.RoomSummaryQueryParams +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.session.sync.SyncState @@ -36,9 +42,11 @@ import im.vector.matrix.android.api.util.toOptional import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo import im.vector.matrix.android.internal.crypto.store.PrivateKeysInfo +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent import io.reactivex.Observable import io.reactivex.Single +import io.reactivex.functions.Function3 class RxSession(private val session: Session) { @@ -165,6 +173,42 @@ class RxSession(private val session: Session) { session.widgetService().getRoomWidgets(roomId, widgetId, widgetTypes, excludedTypes) } } + + fun liveRoomChangeMembershipState(): Observable> { + return session.getChangeMembershipsLive().asObservable() + } + + fun liveSecretSynchronisationInfo(): Observable { + return Observable.combineLatest, Optional, Optional, SecretsSynchronisationInfo>( + liveAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME, KEYBACKUP_SECRET_SSSS_NAME)), + liveCrossSigningInfo(session.myUserId), + liveCrossSigningPrivateKeys(), + Function3 { _, crossSigningInfo, pInfo -> + // first check if 4S is already setup + val is4SSetup = session.sharedSecretStorageService.isRecoverySetup() + val isCrossSigningEnabled = crossSigningInfo.getOrNull() != null + val isCrossSigningTrusted = crossSigningInfo.getOrNull()?.isTrusted() == true + val allPrivateKeysKnown = pInfo.getOrNull()?.allKnown().orFalse() + + val keysBackupService = session.cryptoService().keysBackupService() + val currentBackupVersion = keysBackupService.currentBackupVersion + val megolmBackupAvailable = currentBackupVersion != null + val savedBackupKey = keysBackupService.getKeyBackupRecoveryKeyInfo() + + val megolmKeyKnown = savedBackupKey?.version == currentBackupVersion + SecretsSynchronisationInfo( + isBackupSetup = is4SSetup, + isCrossSigningEnabled = isCrossSigningEnabled, + isCrossSigningTrusted = isCrossSigningTrusted, + allPrivateKeysKnown = allPrivateKeysKnown, + megolmBackupAvailable = megolmBackupAvailable, + megolmSecretKnown = megolmKeyKnown, + isMegolmKeyIn4S = session.sharedSecretStorageService.isMegolmKeyInBackup() + ) + } + ) + .distinctUntilChanged() + } } fun Session.rx(): RxSession { diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/SecretsSynchronisationInfo.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/SecretsSynchronisationInfo.kt new file mode 100644 index 0000000000..616783706b --- /dev/null +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/SecretsSynchronisationInfo.kt @@ -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.matrix.rx + +data class SecretsSynchronisationInfo( + val isBackupSetup: Boolean, + val isCrossSigningEnabled: Boolean, + val isCrossSigningTrusted: Boolean, + val allPrivateKeysKnown: Boolean, + val megolmBackupAvailable: Boolean, + val megolmSecretKnown: Boolean, + val isMegolmKeyIn4S: Boolean +) diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 422a5dac1d..71b763545c 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -51,7 +51,6 @@ android { } buildTypes { - debug { // Set to true to log privacy or sensible data, such as token buildConfigField "boolean", "LOG_PRIVATE_DATA", project.property("vector.debugPrivateData") @@ -123,7 +122,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "androidx.appcompat:appcompat:1.1.0" - implementation "androidx.core:core-ktx:1.1.0" + implementation "androidx.core:core-ktx:1.3.0" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" @@ -205,5 +204,4 @@ dependencies { androidTestImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1' androidTestUtil 'androidx.test:orchestrator:1.2.0' - } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt index 5425f97fc4..08c24227be 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt @@ -65,7 +65,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams) val roomId = mTestHelper.doSync { - aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it) + aliceSession.createRoom(CreateRoomParams().apply { name = "MyRoom" }, it) } if (encryptedRoom) { @@ -175,7 +175,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { } mTestHelper.doSync { - samSession.joinRoom(room.roomId, null, it) + samSession.joinRoom(room.roomId, null, emptyList(), it) } return samSession @@ -286,9 +286,11 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { fun createDM(alice: Session, bob: Session): String { val roomId = mTestHelper.doSync { alice.createRoom( - CreateRoomParams(invitedUserIds = listOf(bob.myUserId)) - .setDirectMessage() - .enableEncryptionIfInvitedUsersSupportIt(), + CreateRoomParams().apply { + invitedUserIds.add(bob.myUserId) + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = true + }, it ) } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt index e78ef04050..a5c0913909 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt @@ -66,7 +66,10 @@ class KeyShareTests : InstrumentedTest { // Create an encrypted room and add a message val roomId = mTestHelper.doSync { aliceSession.createRoom( - CreateRoomParams(RoomDirectoryVisibility.PRIVATE).enableEncryptionWithAlgorithm(true), + CreateRoomParams().apply { + visibility = RoomDirectoryVisibility.PRIVATE + enableEncryption() + }, it ) } @@ -285,7 +288,7 @@ class KeyShareTests : InstrumentedTest { mTestHelper.waitWithLatch(60_000) { latch -> val keysBackupService = aliceSession2.cryptoService().keysBackupService() mTestHelper.retryPeriodicallyWithLatch(latch) { - Log.d("#TEST", "Recovery :${ keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") + Log.d("#TEST", "Recovery :${keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey == creationInfo.recoveryKey } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt index e7c24fadc8..d80a940675 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/MatrixConfiguration.kt @@ -33,7 +33,7 @@ data class MatrixConfiguration( ), /** * Optional proxy to connect to the matrix servers - * You can create one using for instance Proxy(proxyType, InetSocketAddress(hostname, port) + * You can create one using for instance Proxy(proxyType, InetSocketAddress.createUnresolved(hostname, port) */ val proxy: Proxy? = null ) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt new file mode 100644 index 0000000000..202c15b5b0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.extensions + +fun CharSequence.ensurePrefix(prefix: CharSequence): CharSequence { + return when { + startsWith(prefix) -> this + else -> "$prefix$this" + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 5b0f24aed7..8d97dfc01b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -47,6 +47,7 @@ import im.vector.matrix.android.api.session.terms.TermsService import im.vector.matrix.android.api.session.typing.TypingUsersTracker import im.vector.matrix.android.api.session.user.UserService import im.vector.matrix.android.api.session.widgets.WidgetService +import okhttp3.OkHttpClient /** * This interface defines interactions with a session. @@ -205,6 +206,13 @@ interface Session : */ fun removeListener(listener: Listener) + /** + * Will return a OkHttpClient which will manage pinned certificates and Proxy if configured. + * It will not add any access-token to the request. + * So it is exposed to let the app be able to download image with Glide or any other libraries which accept an OkHttp client. + */ + fun getOkHttpClient(): OkHttpClient + /** * A global session listener to get notified for some events. */ diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt index 8d856d0860..5709e66581 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt @@ -61,6 +61,8 @@ interface CrossSigningService { fun canCrossSign(): Boolean + fun allPrivateKeysKnown(): Boolean + fun trustUser(otherUserId: String, callback: MatrixCallback) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/UnsignedData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/UnsignedData.kt index b179cb7a31..16ff36ea07 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/UnsignedData.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/UnsignedData.kt @@ -39,5 +39,10 @@ data class UnsignedData( * Optional. The previous content for this event. If there is no previous content, this key will be missing. */ @Json(name = "prev_content") val prevContent: Map? = null, - @Json(name = "m.relations") val relations: AggregatedRelations? = null + @Json(name = "m.relations") val relations: AggregatedRelations? = null, + /** + * Optional. The eventId of the previous state event being replaced. + */ + @Json(name = "replaces_state") val replacesState: String? = null + ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/Group.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/Group.kt index 3967c15704..cdc8bc1621 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/Group.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/Group.kt @@ -16,9 +16,20 @@ package im.vector.matrix.android.api.session.group +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable + /** * This interface defines methods to interact within a group. */ interface Group { val groupId: String + + /** + * This methods allows you to refresh data about this group. It will be reflected on the GroupSummary. + * The SDK also takes care of refreshing group data every hour. + * @param callback : the matrix callback to be notified of success or failure + * @return a Cancelable to be able to cancel requests. + */ + fun fetchGroupData(callback: MatrixCallback): Cancelable } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomDirectoryService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomDirectoryService.kt index 0273c789dd..7014fbff37 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomDirectoryService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomDirectoryService.kt @@ -34,13 +34,6 @@ interface RoomDirectoryService { publicRoomsParams: PublicRoomsParams, callback: MatrixCallback): Cancelable - /** - * Join a room by id, or room alias - */ - fun joinRoom(roomIdOrAlias: String, - reason: String? = null, - callback: MatrixCallback): Cancelable - /** * Fetches the overall metadata about protocols supported by the homeserver. * Includes both the available protocols and all fields required for queries against each protocol. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt index bc6c17a130..4e7b973bba 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room import androidx.lifecycle.LiveData import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.util.Cancelable @@ -104,5 +105,13 @@ interface RoomService { searchOnServer: Boolean, callback: MatrixCallback>): Cancelable - fun getExistingDirectRoomWithUser(otherUserId: String) : Room? + /** + * Return a live data of all local changes membership that happened since the session has been opened. + * It allows you to track this in your client to known what is currently being processed by the SDK. + * It won't know anything about change being done in other client. + * Keys are roomId or roomAlias, depending of what you used as parameter for the join/leave action + */ + fun getChangeMembershipsLive(): LiveData> + + fun getExistingDirectRoomWithUser(otherUserId: String): Room? } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomSummaryQueryParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomSummaryQueryParams.kt index 6983bda225..51df30ad75 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomSummaryQueryParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomSummaryQueryParams.kt @@ -28,6 +28,7 @@ fun roomSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = { * [im.vector.matrix.android.api.session.room.Room] and [im.vector.matrix.android.api.session.room.RoomService] */ data class RoomSummaryQueryParams( + val roomId: QueryStringValue, val displayName: QueryStringValue, val canonicalAlias: QueryStringValue, val memberships: List @@ -35,11 +36,13 @@ data class RoomSummaryQueryParams( class Builder { + var roomId: QueryStringValue = QueryStringValue.IsNotEmpty var displayName: QueryStringValue = QueryStringValue.IsNotEmpty var canonicalAlias: QueryStringValue = QueryStringValue.NoCondition var memberships: List = Membership.all() fun build() = RoomSummaryQueryParams( + roomId = roomId, displayName = displayName, canonicalAlias = canonicalAlias, memberships = memberships diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/ChangeMembershipState.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/ChangeMembershipState.kt new file mode 100644 index 0000000000..1094f9cb21 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/ChangeMembershipState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.session.room.members + +sealed class ChangeMembershipState() { + object Unknown : ChangeMembershipState() + object Joining : ChangeMembershipState() + data class FailedJoining(val throwable: Throwable) : ChangeMembershipState() + object Joined : ChangeMembershipState() + object Leaving : ChangeMembershipState() + data class FailedLeaving(val throwable: Throwable) : ChangeMembershipState() + object Left : ChangeMembershipState() + + fun isInProgress() = this is Joining || this is Leaving + + fun isSuccessful() = this is Joined || this is Left + + fun isFailed() = this is FailedJoining || this is FailedLeaving +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt index f011d317cd..bb74b5afa5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room.members import androidx.lifecycle.LiveData import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.model.RoomMemberSummary import im.vector.matrix.android.api.util.Cancelable @@ -63,6 +64,12 @@ interface MembershipService { reason: String? = null, callback: MatrixCallback): Cancelable + /** + * Invite a user with email or phone number in the room + */ + fun invite3pid(threePid: ThreePid, + callback: MatrixCallback): Cancelable + /** * Ban a user from the room */ diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt new file mode 100644 index 0000000000..fa871d186e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt @@ -0,0 +1,66 @@ +/* + * 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.matrix.android.api.session.room.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Class representing the EventType.STATE_ROOM_THIRD_PARTY_INVITE state event content + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#m-room-third-party-invite + */ +@JsonClass(generateAdapter = true) +data class RoomThirdPartyInviteContent( + /** + * Required. A user-readable string which represents the user who has been invited. + * This should not contain the user's third party ID, as otherwise when the invite + * is accepted it would leak the association between the matrix ID and the third party ID. + */ + @Json(name = "display_name") val displayName: String, + + /** + * Required. A URL which can be fetched, with querystring public_key=public_key, to validate + * whether the key has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. + */ + @Json(name = "key_validity_url") val keyValidityUrl: String, + + /** + * Required. A base64-encoded ed25519 key with which token must be signed (though a signature from any entry in + * public_keys is also sufficient). This exists for backwards compatibility. + */ + @Json(name = "public_key") val publicKey: String, + + /** + * Keys with which the token may be signed. + */ + @Json(name = "public_keys") val publicKeys: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class PublicKeys( + /** + * An optional URL which can be fetched, with querystring public_key=public_key, to validate whether the key + * has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. If this URL + * is absent, the key must be considered valid indefinitely. + */ + @Json(name = "key_validity_url") val keyValidityUrl: String? = null, + + /** + * Required. A base-64 encoded ed25519 key with which token may be signed. + */ + @Json(name = "public_key") val publicKey: String +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt index 1abbe9ef3a..f89558801d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -16,253 +16,102 @@ package im.vector.matrix.android.api.session.room.model.create -import android.util.Patterns -import androidx.annotation.CheckResult -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import im.vector.matrix.android.api.MatrixPatterns.isUserId -import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig -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.toContent +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.model.PowerLevelsContent import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility -import im.vector.matrix.android.internal.auth.data.ThreePidMedium import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM -import timber.log.Timber -/** - * Parameter to create a room, with facilities functions to configure it - */ -@JsonClass(generateAdapter = true) -data class CreateRoomParams( - /** - * A public visibility indicates that the room will be shown in the published room list. - * A private visibility will hide the room from the published room list. - * Rooms default to private visibility if this key is not included. - * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] - */ - @Json(name = "visibility") - val visibility: RoomDirectoryVisibility? = null, - - /** - * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. - * The alias will belong on the same homeserver which created the room. - * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. - */ - @Json(name = "room_alias_name") - val roomAliasName: String? = null, - - /** - * If this is included, an m.room.name event will be sent into the room to indicate the name of the room. - * See Room Events for more information on m.room.name. - */ - @Json(name = "name") - val name: String? = null, - - /** - * If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room. - * See Room Events for more information on m.room.topic. - */ - @Json(name = "topic") - val topic: String? = null, - - /** - * A list of user IDs to invite to the room. - * This will tell the server to invite everyone in the list to the newly created room. - */ - @Json(name = "invite") - val invitedUserIds: List? = null, - - /** - * A list of objects representing third party IDs to invite into the room. - */ - @Json(name = "invite_3pid") - val invite3pids: List? = null, - - /** - * Extra keys to be added to the content of the m.room.create. - * The server will clobber the following keys: creator. - * Future versions of the specification may allow the server to clobber other keys. - */ - @Json(name = "creation_content") - val creationContent: Any? = null, - - /** - * A list of state events to set in the new room. - * This allows the user to override the default state events set in the new room. - * The expected format of the state events are an object with type, state_key and content keys set. - * Takes precedence over events set by presets, but gets overridden by name and topic keys. - */ - @Json(name = "initial_state") - val initialStates: List? = null, - - /** - * Convenience parameter for setting various default state events based on a preset. Must be either: - * private_chat => join_rules is set to invite. history_visibility is set to shared. - * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the - * room creator. - * public_chat: => join_rules is set to public. history_visibility is set to shared. - */ - @Json(name = "preset") - val preset: CreateRoomPreset? = null, - - /** - * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. - * See Direct Messaging for more information. - */ - @Json(name = "is_direct") - val isDirect: Boolean? = null, - - /** - * The power level content to override in the default power level event - */ - @Json(name = "power_level_content_override") - val powerLevelContentOverride: PowerLevelsContent? = null -) { - @Transient - internal var enableEncryptionIfInvitedUsersSupportIt: Boolean = false - private set +// TODO Give a way to include other initial states +class CreateRoomParams { + /** + * A public visibility indicates that the room will be shown in the published room list. + * A private visibility will hide the room from the published room list. + * Rooms default to private visibility if this key is not included. + * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] + */ + var visibility: RoomDirectoryVisibility? = null /** - * After calling this method, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, + * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. + * The alias will belong on the same homeserver which created the room. + * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. + */ + var roomAliasName: String? = null + + /** + * If this is not null, an m.room.name event will be sent into the room to indicate the name of the room. + * See Room Events for more information on m.room.name. + */ + var name: String? = null + + /** + * If this is not null, an m.room.topic event will be sent into the room to indicate the topic for the room. + * See Room Events for more information on m.room.topic. + */ + var topic: String? = null + + /** + * A list of user IDs to invite to the room. + * This will tell the server to invite everyone in the list to the newly created room. + */ + val invitedUserIds = mutableListOf() + + /** + * A list of objects representing third party IDs to invite into the room. + */ + val invite3pids = mutableListOf() + + /** + * If set to true, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, * the encryption will be enabled on the created room - * @param value true to activate this behavior. - * @return this, to allow chaining methods */ - fun enableEncryptionIfInvitedUsersSupportIt(value: Boolean = true): CreateRoomParams { - enableEncryptionIfInvitedUsersSupportIt = value - return this - } + var enableEncryptionIfInvitedUsersSupportIt: Boolean = false /** - * Add the crypto algorithm to the room creation parameters. - * - * @param enable true to enable encryption. - * @param algorithm the algorithm, default to [MXCRYPTO_ALGORITHM_MEGOLM], which is actually the only supported algorithm for the moment - * @return a modified copy of the CreateRoomParams object, or this if there is no modification + * Convenience parameter for setting various default state events based on a preset. Must be either: + * private_chat => join_rules is set to invite. history_visibility is set to shared. + * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the + * room creator. + * public_chat: => join_rules is set to public. history_visibility is set to shared. */ - @CheckResult - fun enableEncryptionWithAlgorithm(enable: Boolean = true, - algorithm: String = MXCRYPTO_ALGORITHM_MEGOLM): CreateRoomParams { - // Remove the existing value if any. - val newInitialStates = initialStates - ?.filter { it.type != EventType.STATE_ROOM_ENCRYPTION } - - return if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) { - if (enable) { - val contentMap = mapOf("algorithm" to algorithm) - - val algoEvent = Event( - type = EventType.STATE_ROOM_ENCRYPTION, - stateKey = "", - content = contentMap.toContent() - ) - - copy( - initialStates = newInitialStates.orEmpty() + algoEvent - ) - } else { - return copy( - initialStates = newInitialStates - ) - } - } else { - Timber.e("Unsupported algorithm: $algorithm") - this - } - } + var preset: CreateRoomPreset? = null /** - * Force the history visibility in the room creation parameters. - * - * @param historyVisibility the expected history visibility, set null to remove any existing value. - * @return a modified copy of the CreateRoomParams object + * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. + * See Direct Messaging for more information. */ - @CheckResult - fun setHistoryVisibility(historyVisibility: RoomHistoryVisibility?): CreateRoomParams { - // Remove the existing value if any. - val newInitialStates = initialStates - ?.filter { it.type != EventType.STATE_ROOM_HISTORY_VISIBILITY } + var isDirect: Boolean? = null - if (historyVisibility != null) { - val contentMap = mapOf("history_visibility" to historyVisibility) + /** + * Extra keys to be added to the content of the m.room.create. + * The server will clobber the following keys: creator. + * Future versions of the specification may allow the server to clobber other keys. + */ + var creationContent: Any? = null - val historyVisibilityEvent = Event( - type = EventType.STATE_ROOM_HISTORY_VISIBILITY, - stateKey = "", - content = contentMap.toContent()) - - return copy( - initialStates = newInitialStates.orEmpty() + historyVisibilityEvent - ) - } else { - return copy( - initialStates = newInitialStates - ) - } - } + /** + * The power level content to override in the default power level event + */ + var powerLevelContentOverride: PowerLevelsContent? = null /** * Mark as a direct message room. - * @return a modified copy of the CreateRoomParams object */ - @CheckResult - fun setDirectMessage(): CreateRoomParams { - return copy( - preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT, - isDirect = true - ) + fun setDirectMessage() { + preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + isDirect = true } /** - * Tells if the created room can be a direct chat one. - * - * @return true if it is a direct chat + * Supported value: MXCRYPTO_ALGORITHM_MEGOLM */ - fun isDirect(): Boolean { - return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT - && isDirect == true - } + var algorithm: String? = null + private set - /** - * @return the first invited user id - */ - fun getFirstInvitedUserId(): String? { - return invitedUserIds?.firstOrNull() ?: invite3pids?.firstOrNull()?.address - } + var historyVisibility: RoomHistoryVisibility? = null - /** - * Add some ids to the room creation - * ids might be a matrix id or an email address. - * - * @param ids the participant ids to add. - * @return a modified copy of the CreateRoomParams object - */ - @CheckResult - fun addParticipantIds(hsConfig: HomeServerConnectionConfig, - userId: String, - ids: List): CreateRoomParams { - return copy( - invite3pids = (invite3pids.orEmpty() + ids - .takeIf { hsConfig.identityServerUri != null } - ?.filter { id -> Patterns.EMAIL_ADDRESS.matcher(id).matches() } - ?.map { id -> - Invite3Pid( - idServer = hsConfig.identityServerUri!!.host!!, - medium = ThreePidMedium.EMAIL, - address = id - ) - } - .orEmpty()) - .distinct(), - invitedUserIds = (invitedUserIds.orEmpty() + ids - .filter { id -> isUserId(id) } - // do not invite oneself - .filter { id -> id != userId }) - .distinct() - ) - // TODO add phonenumbers when it will be available + fun enableEncryption() { + algorithm = MXCRYPTO_ALGORITHM_MEGOLM } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt deleted file mode 100644 index 8e3386080f..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt +++ /dev/null @@ -1,42 +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.matrix.android.api.session.room.model.create - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Invite3Pid( - /** - * Required. - * The hostname+port of the identity server which should be used for third party identifier lookups. - */ - @Json(name = "id_server") - val idServer: String, - - /** - * Required. - * The kind of address being passed in the address field, for example email. - */ - val medium: String, - - /** - * Required. - * The invitee's third party identifier. - */ - val address: String -) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/powerlevels/PowerLevelsHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/powerlevels/PowerLevelsHelper.kt index 6361a46bac..f434859f6e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/powerlevels/PowerLevelsHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/powerlevels/PowerLevelsHelper.kt @@ -17,7 +17,6 @@ package im.vector.matrix.android.api.session.room.powerlevels -import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.model.PowerLevelsContent /** @@ -124,59 +123,4 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { else -> Role.Moderator.value } } - - /** - * Check if user have the necessary power level to change room name - * @param userId the id of the user to check for. - * @return true if able to change room name - */ - fun isUserAbleToChangeRoomName(userId: String): Boolean { - val powerLevel = getUserPowerLevelValue(userId) - val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_NAME] ?: powerLevelsContent.stateDefault - return powerLevel >= minPowerLevel - } - - /** - * Check if user have the necessary power level to change room topic - * @param userId the id of the user to check for. - * @return true if able to change room topic - */ - fun isUserAbleToChangeRoomTopic(userId: String): Boolean { - val powerLevel = getUserPowerLevelValue(userId) - val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_TOPIC] ?: powerLevelsContent.stateDefault - return powerLevel >= minPowerLevel - } - - /** - * Check if user have the necessary power level to change room canonical alias - * @param userId the id of the user to check for. - * @return true if able to change room canonical alias - */ - fun isUserAbleToChangeRoomCanonicalAlias(userId: String): Boolean { - val powerLevel = getUserPowerLevelValue(userId) - val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_CANONICAL_ALIAS] ?: powerLevelsContent.stateDefault - return powerLevel >= minPowerLevel - } - - /** - * Check if user have the necessary power level to change room history readability - * @param userId the id of the user to check for. - * @return true if able to change room history readability - */ - fun isUserAbleToChangeRoomHistoryReadability(userId: String): Boolean { - val powerLevel = getUserPowerLevelValue(userId) - val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_HISTORY_VISIBILITY] ?: powerLevelsContent.stateDefault - return powerLevel >= minPowerLevel - } - - /** - * Check if user have the necessary power level to change room avatar - * @param userId the id of the user to check for. - * @return true if able to change room avatar - */ - fun isUserAbleToChangeRoomAvatar(userId: String): Boolean { - val powerLevel = getUserPowerLevelValue(userId) - val minPowerLevel = powerLevelsContent.events[EventType.STATE_ROOM_AVATAR] ?: powerLevelsContent.stateDefault - return powerLevel >= minPowerLevel - } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt index a69127532e..2353fc1c30 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt @@ -39,4 +39,6 @@ interface TimelineService { fun getTimeLineEvent(eventId: String): TimelineEvent? fun getTimeLineEventLive(eventId: String): LiveData> + + fun getAttachmentMessages() : List } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt index 6644972aca..22fbcf2d26 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.securestorage import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.listeners.ProgressListener +import im.vector.matrix.android.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME 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 @@ -124,6 +125,13 @@ interface SharedSecretStorageService { ) is IntegrityResult.Success } + fun isMegolmKeyInBackup(): Boolean { + return checkShouldBeAbleToAccessSecrets( + secretNames = listOf(KEYBACKUP_SECRET_SSSS_NAME), + keyId = null + ) is IntegrityResult.Success + } + fun checkShouldBeAbleToAccessSecrets(secretNames: List, keyId: String?): IntegrityResult fun requestSecret(name: String, myOtherDeviceId: String) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingGossipingRequestManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingGossipingRequestManager.kt index eb1c07cb92..5ad1013f49 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingGossipingRequestManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingGossipingRequestManager.kt @@ -71,8 +71,8 @@ internal class OutgoingGossipingRequestManager @Inject constructor( delay(1500) 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("## CRYPTO - GOSSIP sendSecretShareRequest() : we already request for that session: $it") + if (it.state == OutgoingGossipingRequestState.SENDING /**|| it.state == OutgoingGossipingRequestState.SENT*/) { + Timber.v("## CRYPTO - GOSSIP sendSecretShareRequest() : we are already sending for that session: $it") return@launch } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt index 7c5f64182c..5a7c07fb53 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.crypto.crosssigning import androidx.lifecycle.LiveData import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService import im.vector.matrix.android.api.session.crypto.crosssigning.MXCrossSigningInfo import im.vector.matrix.android.api.util.Optional @@ -507,6 +508,11 @@ internal class DefaultCrossSigningService @Inject constructor( && cryptoStore.getCrossSigningPrivateKeys()?.user != null } + override fun allPrivateKeysKnown(): Boolean { + return checkSelfTrust().isVerified() + && cryptoStore.getCrossSigningPrivateKeys()?.allKnown().orFalse() + } + override fun trustUser(otherUserId: String, callback: MatrixCallback) { cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { Timber.d("## CrossSigning - Mark user $userId as trusted ") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/PrivateKeysInfo.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/PrivateKeysInfo.kt index a10b6d2645..d1591e35d8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/PrivateKeysInfo.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/PrivateKeysInfo.kt @@ -20,4 +20,6 @@ data class PrivateKeysInfo( val master: String? = null, val selfSigned: String? = null, val user: String? = null -) +) { + fun allKnown() = master != null && selfSigned != null && user != null +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RoomVerificationUpdateTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RoomVerificationUpdateTask.kt deleted file mode 100644 index 400febc15f..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RoomVerificationUpdateTask.kt +++ /dev/null @@ -1,161 +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.matrix.android.internal.crypto.tasks - -import im.vector.matrix.android.api.session.crypto.CryptoService -import im.vector.matrix.android.api.session.crypto.MXCryptoError -import im.vector.matrix.android.api.session.crypto.verification.VerificationService -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.toModel -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.api.session.room.model.message.MessageRelationContent -import im.vector.matrix.android.api.session.room.model.message.MessageType -import im.vector.matrix.android.api.session.room.model.message.MessageVerificationReadyContent -import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent -import im.vector.matrix.android.api.session.room.model.message.MessageVerificationStartContent -import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult -import im.vector.matrix.android.internal.crypto.verification.DefaultVerificationService -import im.vector.matrix.android.internal.di.DeviceId -import im.vector.matrix.android.internal.di.UserId -import im.vector.matrix.android.internal.task.Task -import timber.log.Timber -import java.util.ArrayList -import javax.inject.Inject - -internal interface RoomVerificationUpdateTask : Task { - data class Params( - val events: List, - val verificationService: DefaultVerificationService, - val cryptoService: CryptoService - ) -} - -internal class DefaultRoomVerificationUpdateTask @Inject constructor( - @UserId private val userId: String, - @DeviceId private val deviceId: String?, - private val cryptoService: CryptoService) : RoomVerificationUpdateTask { - - companion object { - // XXX what about multi-account? - private val transactionsHandledByOtherDevice = ArrayList() - } - - override suspend fun execute(params: RoomVerificationUpdateTask.Params) { - // TODO ignore initial sync or back pagination? - - params.events.forEach { event -> - Timber.v("## SAS Verification live observer: received msgId: ${event.eventId} msgtype: ${event.type} from ${event.senderId}") - - // If the request is in the future by more than 5 minutes or more than 10 minutes in the past, - // the message should be ignored by the receiver. - - if (!VerificationService.isValidRequest(event.ageLocalTs - ?: event.originServerTs)) return@forEach Unit.also { - Timber.d("## SAS Verification live observer: msgId: ${event.eventId} is outdated") - } - - // decrypt if needed? - if (event.isEncrypted() && event.mxDecryptionResult == null) { - // TODO use a global event decryptor? attache to session and that listen to new sessionId? - // for now decrypt sync - try { - val result = cryptoService.decryptEvent(event, "") - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) - } catch (e: MXCryptoError) { - Timber.e("## SAS Failed to decrypt event: ${event.eventId}") - params.verificationService.onPotentiallyInterestingEventRoomFailToDecrypt(event) - } - } - Timber.v("## SAS Verification live observer: received msgId: ${event.eventId} type: ${event.getClearType()}") - - // Relates to is not encrypted - val relatesToEventId = event.content.toModel()?.relatesTo?.eventId - - if (event.senderId == userId) { - // If it's send from me, we need to keep track of Requests or Start - // done from another device of mine - - if (EventType.MESSAGE == event.getClearType()) { - val msgType = event.getClearContent().toModel()?.msgType - if (MessageType.MSGTYPE_VERIFICATION_REQUEST == msgType) { - event.getClearContent().toModel()?.let { - if (it.fromDevice != deviceId) { - // The verification is requested from another device - Timber.v("## SAS Verification live observer: Transaction requested from other device tid:${event.eventId} ") - event.eventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } - } - } - } - } else if (EventType.KEY_VERIFICATION_START == event.getClearType()) { - event.getClearContent().toModel()?.let { - if (it.fromDevice != deviceId) { - // The verification is started from another device - Timber.v("## SAS Verification live observer: Transaction started by other device tid:$relatesToEventId ") - relatesToEventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } - params.verificationService.onRoomRequestHandledByOtherDevice(event) - } - } - } else if (EventType.KEY_VERIFICATION_READY == event.getClearType()) { - event.getClearContent().toModel()?.let { - if (it.fromDevice != deviceId) { - // The verification is started from another device - Timber.v("## SAS Verification live observer: Transaction started by other device tid:$relatesToEventId ") - relatesToEventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } - params.verificationService.onRoomRequestHandledByOtherDevice(event) - } - } - } else if (EventType.KEY_VERIFICATION_CANCEL == event.getClearType() || EventType.KEY_VERIFICATION_DONE == event.getClearType()) { - relatesToEventId?.let { - transactionsHandledByOtherDevice.remove(it) - params.verificationService.onRoomRequestHandledByOtherDevice(event) - } - } - - Timber.v("## SAS Verification ignoring message sent by me: ${event.eventId} type: ${event.getClearType()}") - return@forEach - } - - if (relatesToEventId != null && transactionsHandledByOtherDevice.contains(relatesToEventId)) { - // Ignore this event, it is directed to another of my devices - Timber.v("## SAS Verification live observer: Ignore Transaction handled by other device tid:$relatesToEventId ") - return@forEach - } - when (event.getClearType()) { - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_KEY, - EventType.KEY_VERIFICATION_MAC, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_DONE -> { - params.verificationService.onRoomEvent(event) - } - EventType.MESSAGE -> { - if (MessageType.MSGTYPE_VERIFICATION_REQUEST == event.getClearContent().toModel()?.msgType) { - params.verificationService.onRoomRequestReceived(event) - } - } - } - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt index 1b50d3caa1..4d4eeb21fd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt @@ -1234,7 +1234,7 @@ internal class DefaultVerificationService @Inject constructor( ) // We can SCAN or SHOW QR codes only if cross-signing is enabled - val methodValues = if (crossSigningService.isCrossSigningVerified()) { + val methodValues = if (crossSigningService.isCrossSigningInitialized()) { // Add reciprocate method if application declares it can scan or show QR codes // Not sure if it ok to do that (?) val reciprocateMethod = methods diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt deleted file mode 100644 index 4eab1748b8..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt +++ /dev/null @@ -1,73 +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.matrix.android.internal.crypto.verification - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.crypto.CryptoService -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.internal.crypto.tasks.DefaultRoomVerificationUpdateTask -import im.vector.matrix.android.internal.crypto.tasks.RoomVerificationUpdateTask -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.task.TaskExecutor -import im.vector.matrix.android.internal.task.configureWith -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import javax.inject.Inject - -internal class VerificationMessageLiveObserver @Inject constructor( - @SessionDatabase realmConfiguration: RealmConfiguration, - private val roomVerificationUpdateTask: DefaultRoomVerificationUpdateTask, - private val cryptoService: CryptoService, - private val verificationService: DefaultVerificationService, - private val taskExecutor: TaskExecutor -) : RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity.whereTypes(it, listOf( - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_KEY, - EventType.KEY_VERIFICATION_MAC, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_READY, - EventType.MESSAGE, - EventType.ENCRYPTED) - ) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - // Should we ignore when it's an initial sync? - val events = changeSet.insertions - .asSequence() - .mapNotNull { results[it]?.asDomain() } - .filterNot { - // ignore local echos - LocalEcho.isLocalEchoId(it.eventId ?: "") - } - .toList() - - roomVerificationUpdateTask.configureWith( - RoomVerificationUpdateTask.Params(events, verificationService, cryptoService) - ).executeBy(taskExecutor) - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageProcessor.kt new file mode 100644 index 0000000000..c266d965cd --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageProcessor.kt @@ -0,0 +1,168 @@ +/* + * 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.matrix.android.internal.crypto.verification + +import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.crypto.MXCryptoError +import im.vector.matrix.android.api.session.crypto.verification.VerificationService +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.toModel +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.message.MessageRelationContent +import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.MessageVerificationReadyContent +import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent +import im.vector.matrix.android.api.session.room.model.message.MessageVerificationStartContent +import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult +import im.vector.matrix.android.internal.database.model.EventInsertType +import im.vector.matrix.android.internal.di.DeviceId +import im.vector.matrix.android.internal.di.UserId +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor +import io.realm.Realm +import timber.log.Timber +import java.util.ArrayList +import javax.inject.Inject + +internal class VerificationMessageProcessor @Inject constructor( + private val cryptoService: CryptoService, + private val verificationService: DefaultVerificationService, + @UserId private val userId: String, + @DeviceId private val deviceId: String? +) : EventInsertLiveProcessor { + + private val transactionsHandledByOtherDevice = ArrayList() + + private val allowedTypes = listOf( + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_KEY, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_READY, + EventType.MESSAGE, + EventType.ENCRYPTED + ) + + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + if (insertType != EventInsertType.INCREMENTAL_SYNC) { + return false + } + return allowedTypes.contains(eventType) && !LocalEcho.isLocalEchoId(eventId) + } + + override suspend fun process(realm: Realm, event: Event) { + Timber.v("## SAS Verification live observer: received msgId: ${event.eventId} msgtype: ${event.type} from ${event.senderId}") + + // If the request is in the future by more than 5 minutes or more than 10 minutes in the past, + // the message should be ignored by the receiver. + + if (!VerificationService.isValidRequest(event.ageLocalTs + ?: event.originServerTs)) return Unit.also { + Timber.d("## SAS Verification live observer: msgId: ${event.eventId} is outdated") + } + + // decrypt if needed? + if (event.isEncrypted() && event.mxDecryptionResult == null) { + // TODO use a global event decryptor? attache to session and that listen to new sessionId? + // for now decrypt sync + try { + val result = cryptoService.decryptEvent(event, "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } catch (e: MXCryptoError) { + Timber.e("## SAS Failed to decrypt event: ${event.eventId}") + verificationService.onPotentiallyInterestingEventRoomFailToDecrypt(event) + } + } + Timber.v("## SAS Verification live observer: received msgId: ${event.eventId} type: ${event.getClearType()}") + + // Relates to is not encrypted + val relatesToEventId = event.content.toModel()?.relatesTo?.eventId + + if (event.senderId == userId) { + // If it's send from me, we need to keep track of Requests or Start + // done from another device of mine + + if (EventType.MESSAGE == event.getClearType()) { + val msgType = event.getClearContent().toModel()?.msgType + if (MessageType.MSGTYPE_VERIFICATION_REQUEST == msgType) { + event.getClearContent().toModel()?.let { + if (it.fromDevice != deviceId) { + // The verification is requested from another device + Timber.v("## SAS Verification live observer: Transaction requested from other device tid:${event.eventId} ") + event.eventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } + } + } + } + } else if (EventType.KEY_VERIFICATION_START == event.getClearType()) { + event.getClearContent().toModel()?.let { + if (it.fromDevice != deviceId) { + // The verification is started from another device + Timber.v("## SAS Verification live observer: Transaction started by other device tid:$relatesToEventId ") + relatesToEventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } + verificationService.onRoomRequestHandledByOtherDevice(event) + } + } + } else if (EventType.KEY_VERIFICATION_READY == event.getClearType()) { + event.getClearContent().toModel()?.let { + if (it.fromDevice != deviceId) { + // The verification is started from another device + Timber.v("## SAS Verification live observer: Transaction started by other device tid:$relatesToEventId ") + relatesToEventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } + verificationService.onRoomRequestHandledByOtherDevice(event) + } + } + } else if (EventType.KEY_VERIFICATION_CANCEL == event.getClearType() || EventType.KEY_VERIFICATION_DONE == event.getClearType()) { + relatesToEventId?.let { + transactionsHandledByOtherDevice.remove(it) + verificationService.onRoomRequestHandledByOtherDevice(event) + } + } + + Timber.v("## SAS Verification ignoring message sent by me: ${event.eventId} type: ${event.getClearType()}") + return + } + + if (relatesToEventId != null && transactionsHandledByOtherDevice.contains(relatesToEventId)) { + // Ignore this event, it is directed to another of my devices + Timber.v("## SAS Verification live observer: Ignore Transaction handled by other device tid:$relatesToEventId ") + return + } + when (event.getClearType()) { + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_KEY, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_READY, + EventType.KEY_VERIFICATION_DONE -> { + verificationService.onRoomEvent(event) + } + EventType.MESSAGE -> { + if (MessageType.MSGTYPE_VERIFICATION_REQUEST == event.getClearContent().toModel()?.msgType) { + verificationService.onRoomRequestReceived(event) + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/DatabaseCleaner.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/DatabaseCleaner.kt new file mode 100644 index 0000000000..ca2126e621 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/DatabaseCleaner.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.database + +import im.vector.matrix.android.internal.database.helper.nextDisplayIndex +import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.ChunkEntityFields +import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields +import im.vector.matrix.android.internal.di.SessionDatabase +import im.vector.matrix.android.internal.session.SessionLifecycleObserver +import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection +import im.vector.matrix.android.internal.task.TaskExecutor +import io.realm.Realm +import io.realm.RealmConfiguration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val MAX_NUMBER_OF_EVENTS_IN_DB = 35_000L +private const val MIN_NUMBER_OF_EVENTS_BY_CHUNK = 300 + +/** + * This class makes sure to stay under a maximum number of events as it makes Realm to be unusable when listening to events + * when the database is getting too big. This will try incrementally to remove the biggest chunks until we get below the threshold. + * We make sure to still have a minimum number of events so it's not becoming unusable. + * So this won't work for users with a big number of very active rooms. + */ +internal class DatabaseCleaner @Inject constructor(@SessionDatabase private val realmConfiguration: RealmConfiguration, + private val taskExecutor: TaskExecutor) : SessionLifecycleObserver { + + override fun onStart() { + taskExecutor.executorScope.launch(Dispatchers.Default) { + awaitTransaction(realmConfiguration) { realm -> + val allRooms = realm.where(RoomEntity::class.java).findAll() + Timber.v("There are ${allRooms.size} rooms in this session") + cleanUp(realm, MAX_NUMBER_OF_EVENTS_IN_DB / 2L) + } + } + } + + private suspend fun cleanUp(realm: Realm, threshold: Long) { + val numberOfEvents = realm.where(EventEntity::class.java).findAll().size + val numberOfTimelineEvents = realm.where(TimelineEventEntity::class.java).findAll().size + Timber.v("Number of events in db: $numberOfEvents | Number of timeline events in db: $numberOfTimelineEvents") + if (threshold <= MIN_NUMBER_OF_EVENTS_BY_CHUNK || numberOfTimelineEvents < MAX_NUMBER_OF_EVENTS_IN_DB) { + Timber.v("Db is low enough") + } else { + val thresholdChunks = realm.where(ChunkEntity::class.java) + .greaterThan(ChunkEntityFields.NUMBER_OF_TIMELINE_EVENTS, threshold) + .findAll() + + Timber.v("There are ${thresholdChunks.size} chunks to clean with more than $threshold events") + for (chunk in thresholdChunks) { + val maxDisplayIndex = chunk.nextDisplayIndex(PaginationDirection.FORWARDS) + val thresholdDisplayIndex = maxDisplayIndex - threshold + val eventsToRemove = chunk.timelineEvents.where().lessThan(TimelineEventEntityFields.DISPLAY_INDEX, thresholdDisplayIndex).findAll() + Timber.v("There are ${eventsToRemove.size} events to clean in chunk: ${chunk.identifier()} from room ${chunk.room?.first()?.roomId}") + chunk.numberOfTimelineEvents = chunk.numberOfTimelineEvents - eventsToRemove.size + eventsToRemove.forEach { + val canDeleteRoot = it.root?.stateKey == null + if (canDeleteRoot) { + it.root?.deleteFromRealm() + } + it.readReceipts?.readReceipts?.deleteAllFromRealm() + it.readReceipts?.deleteFromRealm() + it.annotations?.apply { + editSummary?.deleteFromRealm() + pollResponseSummary?.deleteFromRealm() + referencesSummaryEntity?.deleteFromRealm() + reactionsSummary.deleteAllFromRealm() + } + it.annotations?.deleteFromRealm() + it.readReceipts?.deleteFromRealm() + it.deleteFromRealm() + } + // We reset the prevToken so we will need to fetch again. + chunk.prevToken = null + } + cleanUp(realm, (threshold / 1.5).toLong()) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/EventInsertLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/EventInsertLiveObserver.kt new file mode 100644 index 0000000000..98d8806288 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/EventInsertLiveObserver.kt @@ -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.matrix.android.internal.database + +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.crypto.MXCryptoError +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult +import im.vector.matrix.android.internal.database.mapper.asDomain +import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventInsertEntity +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.di.SessionDatabase +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor +import io.realm.RealmConfiguration +import io.realm.RealmResults +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase realmConfiguration: RealmConfiguration, + private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor>, + private val cryptoService: CryptoService) + : RealmLiveEntityObserver(realmConfiguration) { + + override val query = Monarchy.Query { + it.where(EventInsertEntity::class.java) + } + + override fun onChange(results: RealmResults) { + if (!results.isLoaded || results.isEmpty()) { + return + } + Timber.v("EventInsertEntity updated with ${results.size} results in db") + val filteredEvents = results.mapNotNull { + if (shouldProcess(it)) { + results.realm.copyFromRealm(it) + } else { + null + } + } + Timber.v("There are ${filteredEvents.size} events to process") + observerScope.launch { + awaitTransaction(realmConfiguration) { realm -> + filteredEvents.forEach { eventInsert -> + val eventId = eventInsert.eventId + val event = EventEntity.where(realm, eventId).findFirst() + if (event == null) { + Timber.v("Event $eventId not found") + return@forEach + } + val domainEvent = event.asDomain() + decryptIfNeeded(domainEvent) + processors.filter { + it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType) + }.forEach { + it.process(realm, domainEvent) + } + } + realm.delete(EventInsertEntity::class.java) + } + } + } + + private fun decryptIfNeeded(event: Event) { + if (event.isEncrypted() && event.mxDecryptionResult == null) { + try { + val result = cryptoService.decryptEvent(event, event.roomId ?: "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } catch (e: MXCryptoError) { + Timber.v("Failed to decrypt event") + // TODO -> we should keep track of this and retry, or some processing will never be handled + } + } + } + + private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean { + return processors.any { + it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt index c3ace55e1c..3f0dc4cddd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt @@ -19,8 +19,8 @@ package im.vector.matrix.android.internal.database import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.internal.session.SessionLifecycleObserver import im.vector.matrix.android.internal.util.createBackgroundHandler -import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm +import io.realm.RealmChangeListener import io.realm.RealmConfiguration import io.realm.RealmObject import io.realm.RealmResults @@ -30,10 +30,10 @@ import kotlinx.coroutines.cancelChildren import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -internal interface LiveEntityObserver: SessionLifecycleObserver +internal interface LiveEntityObserver : SessionLifecycleObserver internal abstract class RealmLiveEntityObserver(protected val realmConfiguration: RealmConfiguration) - : LiveEntityObserver, OrderedRealmCollectionChangeListener> { + : LiveEntityObserver, RealmChangeListener> { private companion object { val BACKGROUND_HANDLER = createBackgroundHandler("LIVE_ENTITY_BACKGROUND") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index d86151e694..a2965df27b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -115,6 +115,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, true } } + numberOfTimelineEvents++ timelineEvents.add(timelineEventEntity) } @@ -122,17 +123,18 @@ private fun computeIsUnique( realm: Realm, roomId: String, isLastForward: Boolean, - myRoomMemberContent: RoomMemberContent, + senderRoomMemberContent: RoomMemberContent, roomMemberContentsByUser: Map ): Boolean { val isHistoricalUnique = roomMemberContentsByUser.values.find { - it != myRoomMemberContent && it?.displayName == myRoomMemberContent.displayName + it != senderRoomMemberContent && it?.displayName == senderRoomMemberContent.displayName } == null return if (isLastForward) { val isLiveUnique = RoomMemberSummaryEntity .where(realm, roomId) - .equalTo(RoomMemberSummaryEntityFields.DISPLAY_NAME, myRoomMemberContent.displayName) - .findAll().none { + .equalTo(RoomMemberSummaryEntityFields.DISPLAY_NAME, senderRoomMemberContent.displayName) + .findAll() + .none { !roomMemberContentsByUser.containsKey(it.userId) } isHistoricalUnique && isLiveUnique diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventMapper.kt index 141403b6d4..11a5616bfb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventMapper.kt @@ -45,7 +45,6 @@ internal object EventMapper { eventEntity.redacts = event.redacts eventEntity.age = event.unsignedData?.age ?: event.originServerTs eventEntity.unsignedData = uds - eventEntity.decryptionResultJson = event.mxDecryptionResult?.let { MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).toJson(it) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/GroupMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/GroupMapper.kt deleted file mode 100644 index 89ed5844c2..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/GroupMapper.kt +++ /dev/null @@ -1,34 +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.matrix.android.internal.database.mapper - -import im.vector.matrix.android.api.session.group.Group -import im.vector.matrix.android.internal.database.model.GroupEntity -import im.vector.matrix.android.internal.session.group.DefaultGroup - -internal object GroupMapper { - - fun map(groupEntity: GroupEntity): Group { - return DefaultGroup( - groupEntity.groupId - ) - } -} - -internal fun GroupEntity.asDomain(): Group { - return GroupMapper.map(this) -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt index 19bf72970c..7014146539 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt @@ -27,6 +27,7 @@ internal open class ChunkEntity(@Index var prevToken: String? = null, @Index var nextToken: String? = null, var stateEvents: RealmList = RealmList(), var timelineEvents: RealmList = RealmList(), + var numberOfTimelineEvents: Long = 0, // Only one chunk will have isLastForward == true @Index var isLastForward: Boolean = false, @Index var isLastBackward: Boolean = false diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertEntity.kt new file mode 100644 index 0000000000..e23ea5c3d0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertEntity.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.database.model + +import io.realm.RealmObject + +/** + * This class is used to get notification on new events being inserted. It's to avoid realm getting slow when listening to insert + * in EventEntity table. + */ +internal open class EventInsertEntity(var eventId: String = "", + var eventType: String = "" +) : RealmObject() { + + private var insertTypeStr: String = EventInsertType.INCREMENTAL_SYNC.name + var insertType: EventInsertType + get() { + return EventInsertType.valueOf(insertTypeStr) + } + set(value) { + insertTypeStr = value.name + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertType.java b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertType.java new file mode 100644 index 0000000000..179b0f791c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventInsertType.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.database.model; + +public enum EventInsertType { + INITIAL_SYNC, + INCREMENTAL_SYNC, + PAGINATION, + LOCAL_ECHO +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/GroupEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/GroupEntity.kt index eb346a74ca..a0054ae8d6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/GroupEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/GroupEntity.kt @@ -22,8 +22,7 @@ import io.realm.annotations.PrimaryKey /** * This class is used to store group info (groupId and membership) from the sync response. - * Then [im.vector.matrix.android.internal.session.group.GroupSummaryUpdater] observes change and - * makes requests to fetch group information from the homeserver + * Then GetGroupDataTask is called regularly to fetch group information from the homeserver. */ internal open class GroupEntity(@PrimaryKey var groupId: String = "") : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomMemberSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomMemberSummaryEntity.kt index 45bf1b3a22..e2a9af649e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomMemberSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomMemberSummaryEntity.kt @@ -24,7 +24,7 @@ import io.realm.annotations.PrimaryKey internal open class RoomMemberSummaryEntity(@PrimaryKey var primaryKey: String = "", @Index var userId: String = "", @Index var roomId: String = "", - var displayName: String? = null, + @Index var displayName: String? = null, var avatarUrl: String? = null, var reason: String? = null, var isDirect: Boolean = false diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt index 9eceb56141..efe4c4955e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt @@ -25,6 +25,7 @@ import io.realm.annotations.RealmModule classes = [ ChunkEntity::class, EventEntity::class, + EventInsertEntity::class, TimelineEventEntity::class, FilterEntity::class, GroupEntity::class, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt index d998c41ccb..3618f3f7a8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt @@ -18,16 +18,28 @@ package im.vector.matrix.android.internal.database.query import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntityFields +import im.vector.matrix.android.internal.database.model.EventInsertEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import io.realm.Realm import io.realm.RealmList import io.realm.RealmQuery import io.realm.kotlin.where -internal fun EventEntity.copyToRealmOrIgnore(realm: Realm): EventEntity { - return realm.where() - .equalTo(EventEntityFields.EVENT_ID, eventId) - .equalTo(EventEntityFields.ROOM_ID, roomId) - .findFirst() ?: realm.copyToRealm(this) +internal fun EventEntity.copyToRealmOrIgnore(realm: Realm, insertType: EventInsertType): EventEntity { + val eventEntity = realm.where() + .equalTo(EventEntityFields.EVENT_ID, eventId) + .equalTo(EventEntityFields.ROOM_ID, roomId) + .findFirst() + return if (eventEntity == null) { + val insertEntity = EventInsertEntity(eventId = eventId, eventType = type).apply { + this.insertType = insertType + } + realm.insert(insertEntity) + // copy this event entity and return it + realm.copyToRealm(this) + } else { + eventEntity + } } internal fun EventEntity.Companion.where(realm: Realm, eventId: String): RealmQuery { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupEntityQueries.kt index 802bfbeae6..1e4f5639c4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupEntityQueries.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.database.query import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.internal.database.model.GroupEntity import im.vector.matrix.android.internal.database.model.GroupEntityFields +import im.vector.matrix.android.internal.query.process import io.realm.Realm import io.realm.RealmQuery import io.realm.kotlin.where @@ -28,10 +29,6 @@ internal fun GroupEntity.Companion.where(realm: Realm, groupId: String): RealmQu .equalTo(GroupEntityFields.GROUP_ID, groupId) } -internal fun GroupEntity.Companion.where(realm: Realm, membership: Membership? = null): RealmQuery { - val query = realm.where() - if (membership != null) { - query.equalTo(GroupEntityFields.MEMBERSHIP_STR, membership.name) - } - return query +internal fun GroupEntity.Companion.where(realm: Realm, memberships: List): RealmQuery { + return realm.where().process(GroupEntityFields.MEMBERSHIP_STR, memberships) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupSummaryEntityQueries.kt index 601da098ca..18d40d0e68 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupSummaryEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/GroupSummaryEntityQueries.kt @@ -20,6 +20,7 @@ import im.vector.matrix.android.internal.database.model.GroupSummaryEntity import im.vector.matrix.android.internal.database.model.GroupSummaryEntityFields import io.realm.Realm import io.realm.RealmQuery +import io.realm.kotlin.createObject import io.realm.kotlin.where internal fun GroupSummaryEntity.Companion.where(realm: Realm, groupId: String? = null): RealmQuery { @@ -34,3 +35,7 @@ internal fun GroupSummaryEntity.Companion.where(realm: Realm, groupIds: List() .`in`(GroupSummaryEntityFields.GROUP_ID, groupIds.toTypedArray()) } + +internal fun GroupSummaryEntity.Companion.getOrCreate(realm: Realm, groupId: String): GroupSummaryEntity { + return where(realm, groupId).findFirst() ?: realm.createObject(groupId) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/WorkManagerProvider.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/WorkManagerProvider.kt index 5a0202719b..a7d1b68c92 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/WorkManagerProvider.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/WorkManagerProvider.kt @@ -21,7 +21,9 @@ import androidx.work.Constraints import androidx.work.ListenableWorker import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import java.util.concurrent.TimeUnit import javax.inject.Inject internal class WorkManagerProvider @Inject constructor( @@ -39,6 +41,14 @@ internal class WorkManagerProvider @Inject constructor( OneTimeWorkRequestBuilder() .addTag(tag) + /** + * Create a PeriodicWorkRequestBuilder, with the Matrix SDK tag + */ + inline fun matrixPeriodicWorkRequestBuilder(repeatInterval: Long, + repeatIntervalTimeUnit: TimeUnit) = + PeriodicWorkRequestBuilder(repeatInterval, repeatIntervalTimeUnit) + .addTag(tag) + /** * Cancel all works instantiated by the Matrix SDK for the current session, and not those from the SDK client, or for other sessions */ diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 5c5da997da..16179dd64a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -50,7 +50,9 @@ import im.vector.matrix.android.api.session.user.UserService import im.vector.matrix.android.api.session.widgets.WidgetService import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.crypto.DefaultCryptoService +import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.di.SessionId +import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate import im.vector.matrix.android.internal.di.WorkManagerProvider import im.vector.matrix.android.internal.session.identity.DefaultIdentityService import im.vector.matrix.android.internal.session.room.timeline.TimelineEventDecryptor @@ -60,8 +62,10 @@ import im.vector.matrix.android.internal.session.sync.job.SyncWorker import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers import im.vector.matrix.android.internal.util.createUIHandler +import io.realm.RealmConfiguration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -76,6 +80,7 @@ internal class DefaultSession @Inject constructor( private val eventBus: EventBus, @SessionId override val sessionId: String, + @SessionDatabase private val realmConfiguration: RealmConfiguration, private val lifecycleObservers: Set<@JvmSuppressWildcards SessionLifecycleObserver>, private val sessionListeners: SessionListeners, private val roomService: Lazy, @@ -110,8 +115,10 @@ internal class DefaultSession @Inject constructor( private val defaultIdentityService: DefaultIdentityService, private val integrationManagerService: IntegrationManagerService, private val taskExecutor: TaskExecutor, - private val callSignalingService: Lazy) - : Session, + private val callSignalingService: Lazy, + @UnauthenticatedWithCertificate + private val unauthenticatedWithCertificateOkHttpClient: Lazy +) : Session, RoomService by roomService.get(), RoomDirectoryService by roomDirectoryService.get(), GroupService by groupService.get(), @@ -252,6 +259,10 @@ internal class DefaultSession @Inject constructor( override fun callSignalingService(): CallSignalingService = callSignalingService.get() + override fun getOkHttpClient(): OkHttpClient { + return unauthenticatedWithCertificateOkHttpClient.get() + } + override fun addListener(listener: Session.Listener) { sessionListeners.addListener(listener) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/EventInsertLiveProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/EventInsertLiveProcessor.kt new file mode 100644 index 0000000000..70d0a0d99f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/EventInsertLiveProcessor.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session + +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.internal.database.model.EventInsertType +import io.realm.Realm + +internal interface EventInsertLiveProcessor { + + fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean + + suspend fun process(realm: Realm, event: Event) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt index ff3bc0b073..83b90b16b9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt @@ -22,7 +22,7 @@ import javax.inject.Inject internal class SessionListeners @Inject constructor() { - private val listeners = ArrayList() + private val listeners = mutableSetOf() fun addListener(listener: Session.Listener) { synchronized(listeners) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index fb05bc68a2..0feb944b38 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -39,7 +39,9 @@ import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageSer import im.vector.matrix.android.api.session.typing.TypingUsersTracker import im.vector.matrix.android.internal.crypto.crosssigning.ShieldTrustUpdater import im.vector.matrix.android.internal.crypto.secrets.DefaultSharedSecretStorageService -import im.vector.matrix.android.internal.crypto.verification.VerificationMessageLiveObserver +import im.vector.matrix.android.internal.crypto.verification.VerificationMessageProcessor +import im.vector.matrix.android.internal.database.DatabaseCleaner +import im.vector.matrix.android.internal.database.EventInsertLiveObserver import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory import im.vector.matrix.android.internal.di.Authenticated import im.vector.matrix.android.internal.di.DeviceId @@ -64,16 +66,15 @@ import im.vector.matrix.android.internal.network.httpclient.addSocketFactory import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor import im.vector.matrix.android.internal.network.token.AccessTokenProvider import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider -import im.vector.matrix.android.internal.session.call.CallEventObserver +import im.vector.matrix.android.internal.session.call.CallEventProcessor import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor -import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService import im.vector.matrix.android.internal.session.identity.DefaultIdentityService import im.vector.matrix.android.internal.session.integrationmanager.IntegrationManager -import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater -import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver -import im.vector.matrix.android.internal.session.room.prune.EventsPruner -import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver +import im.vector.matrix.android.internal.session.room.EventRelationsAggregationProcessor +import im.vector.matrix.android.internal.session.room.create.RoomCreateEventProcessor +import im.vector.matrix.android.internal.session.room.prune.RedactionEventProcessor +import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventProcessor import im.vector.matrix.android.internal.session.securestorage.DefaultSecureStorageService import im.vector.matrix.android.internal.session.typing.DefaultTypingUsersTracker import im.vector.matrix.android.internal.session.user.accountdata.DefaultAccountDataService @@ -293,31 +294,31 @@ internal abstract class SessionModule { @Binds @IntoSet - abstract fun bindGroupSummaryUpdater(updater: GroupSummaryUpdater): SessionLifecycleObserver + abstract fun bindEventRedactionProcessor(processor: RedactionEventProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindEventsPruner(pruner: EventsPruner): SessionLifecycleObserver + abstract fun bindEventRelationsAggregationProcessor(processor: EventRelationsAggregationProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindEventRelationsAggregationUpdater(updater: EventRelationsAggregationUpdater): SessionLifecycleObserver + abstract fun bindRoomTombstoneEventProcessor(processor: RoomTombstoneEventProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindRoomTombstoneEventLiveObserver(observer: RoomTombstoneEventLiveObserver): SessionLifecycleObserver + abstract fun bindRoomCreateEventProcessor(processor: RoomCreateEventProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindRoomCreateEventLiveObserver(observer: RoomCreateEventLiveObserver): SessionLifecycleObserver + abstract fun bindVerificationMessageProcessor(processor: VerificationMessageProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindVerificationMessageLiveObserver(observer: VerificationMessageLiveObserver): SessionLifecycleObserver + abstract fun bindCallEventProcessor(processor: CallEventProcessor): EventInsertLiveProcessor @Binds @IntoSet - abstract fun bindCallEventObserver(observer: CallEventObserver): SessionLifecycleObserver + abstract fun bindEventInsertObserver(observer: EventInsertLiveObserver): SessionLifecycleObserver @Binds @IntoSet @@ -335,6 +336,10 @@ internal abstract class SessionModule { @IntoSet abstract fun bindIdentityService(observer: DefaultIdentityService): SessionLifecycleObserver + @Binds + @IntoSet + abstract fun bindDatabaseCleaner(observer: DatabaseCleaner): SessionLifecycleObserver + @Binds abstract fun bindInitialSyncProgressService(service: DefaultInitialSyncProgressService): InitialSyncProgressService diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt deleted file mode 100644 index 585ecb61ca..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt +++ /dev/null @@ -1,66 +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.session.call - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.di.UserId -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -internal class CallEventObserver @Inject constructor( - @SessionDatabase realmConfiguration: RealmConfiguration, - @UserId private val userId: String, - private val task: CallEventsObserverTask -) : RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity.whereTypes(it, listOf( - EventType.CALL_ANSWER, - EventType.CALL_CANDIDATES, - EventType.CALL_INVITE, - EventType.CALL_HANGUP, - EventType.ENCRYPTED) - ) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - Timber.v("EventRelationsAggregationUpdater called with ${changeSet.insertions.size} insertions") - - val insertedDomains = changeSet.insertions - .asSequence() - .mapNotNull { results[it]?.asDomain() } - .toList() - - val params = CallEventsObserverTask.Params( - insertedDomains, - userId - ) - observerScope.launch { - task.execute(params) - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventProcessor.kt new file mode 100644 index 0000000000..34ee7b2c54 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventProcessor.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.call + +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.database.model.EventInsertType +import im.vector.matrix.android.internal.di.UserId +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor +import io.realm.Realm +import timber.log.Timber +import javax.inject.Inject + +internal class CallEventProcessor @Inject constructor( + @UserId private val userId: String, + private val callService: DefaultCallSignalingService +) : EventInsertLiveProcessor { + + private val allowedTypes = listOf( + EventType.CALL_ANSWER, + EventType.CALL_CANDIDATES, + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.ENCRYPTED + ) + + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + if (insertType != EventInsertType.INCREMENTAL_SYNC) { + return false + } + return allowedTypes.contains(eventType) + } + + override suspend fun process(realm: Realm, event: Event) { + update(realm, event) + } + + private fun update(realm: Realm, event: Event) { + val now = System.currentTimeMillis() + // TODO might check if an invite is not closed (hangup/answsered) in the same event batch? + event.roomId ?: return Unit.also { + Timber.w("Event with no room id ${event.eventId}") + } + val age = now - (event.ageLocalTs ?: now) + if (age > 40_000) { + // To old to ring? + return + } + event.ageLocalTs + if (EventType.isCallEvent(event.getClearType())) { + callService.onCallEvent(event) + } + Timber.v("$realm : $userId") + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt deleted file mode 100644 index 2d96bd3b23..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt +++ /dev/null @@ -1,92 +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.session.call - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.crypto.CryptoService -import im.vector.matrix.android.api.session.crypto.MXCryptoError -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.algorithms.olm.OlmDecryptionResult -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.task.Task -import im.vector.matrix.android.internal.util.awaitTransaction -import io.realm.Realm -import timber.log.Timber -import javax.inject.Inject - -internal interface CallEventsObserverTask : Task { - - data class Params( - val events: List, - val userId: String - ) -} - -internal class DefaultCallEventsObserverTask @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - private val cryptoService: CryptoService, - private val callService: DefaultCallSignalingService) : CallEventsObserverTask { - - override suspend fun execute(params: CallEventsObserverTask.Params) { - val events = params.events - val userId = params.userId - monarchy.awaitTransaction { realm -> - Timber.v(">>> DefaultCallEventsObserverTask[${params.hashCode()}] called with ${events.size} events") - update(realm, events, userId) - Timber.v("<<< DefaultCallEventsObserverTask[${params.hashCode()}] finished") - } - } - - private fun update(realm: Realm, events: List, userId: String) { - val now = System.currentTimeMillis() - // TODO might check if an invite is not closed (hangup/answsered) in the same event batch? - events.forEach { event -> - event.roomId ?: return@forEach Unit.also { - Timber.w("Event with no room id ${event.eventId}") - } - val age = now - (event.ageLocalTs ?: now) - if (age > 40_000) { - // To old to ring? - return@forEach - } - event.ageLocalTs - decryptIfNeeded(event) - if (EventType.isCallEvent(event.getClearType())) { - callService.onCallEvent(event) - } - } - Timber.v("$realm : $userId") - } - - private fun decryptIfNeeded(event: Event) { - if (event.isEncrypted() && event.mxDecryptionResult == null) { - try { - val result = cryptoService.decryptEvent(event, event.roomId ?: "") - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) - } catch (e: MXCryptoError) { - Timber.v("Call service: Failed to decrypt event") - // TODO -> we should keep track of this and retry, or aggregation will be broken - } - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt index a25d198e83..bc4cef8772 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt @@ -41,7 +41,4 @@ internal abstract class CallModule { @Binds abstract fun bindGetTurnServerTask(task: DefaultGetTurnServerTask): GetTurnServerTask - - @Binds - abstract fun bindCallEventsObserverTask(task: DefaultCallEventsObserverTask): CallEventsObserverTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGetGroupDataTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGetGroupDataTask.kt index 7c5de5b137..ee43441453 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGetGroupDataTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGetGroupDataTask.kt @@ -18,7 +18,9 @@ package im.vector.matrix.android.internal.session.group import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.room.model.Membership +import im.vector.matrix.android.internal.database.model.GroupEntity import im.vector.matrix.android.internal.database.model.GroupSummaryEntity +import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.network.executeRequest @@ -28,11 +30,14 @@ import im.vector.matrix.android.internal.session.group.model.GroupUsers import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.awaitTransaction import org.greenrobot.eventbus.EventBus +import timber.log.Timber import javax.inject.Inject internal interface GetGroupDataTask : Task { - - data class Params(val groupId: String) + sealed class Params { + object FetchAllActive : Params() + data class FetchWithIds(val groupIds: List) : Params() + } } internal class DefaultGetGroupDataTask @Inject constructor( @@ -41,44 +46,64 @@ internal class DefaultGetGroupDataTask @Inject constructor( private val eventBus: EventBus ) : GetGroupDataTask { + private data class GroupData( + val groupId: String, + val groupSummary: GroupSummaryResponse, + val groupRooms: GroupRooms, + val groupUsers: GroupUsers + ) + override suspend fun execute(params: GetGroupDataTask.Params) { - val groupId = params.groupId - val groupSummary = executeRequest(eventBus) { - apiCall = groupAPI.getSummary(groupId) + val groupIds = when (params) { + is GetGroupDataTask.Params.FetchAllActive -> { + getActiveGroupIds() + } + is GetGroupDataTask.Params.FetchWithIds -> { + params.groupIds + } } - val groupRooms = executeRequest(eventBus) { - apiCall = groupAPI.getRooms(groupId) + Timber.v("Fetch data for group with ids: ${groupIds.joinToString(";")}") + val data = groupIds.map { groupId -> + val groupSummary = executeRequest(eventBus) { + apiCall = groupAPI.getSummary(groupId) + } + val groupRooms = executeRequest(eventBus) { + apiCall = groupAPI.getRooms(groupId) + } + val groupUsers = executeRequest(eventBus) { + apiCall = groupAPI.getUsers(groupId) + } + GroupData(groupId, groupSummary, groupRooms, groupUsers) } - val groupUsers = executeRequest(eventBus) { - apiCall = groupAPI.getUsers(groupId) - } - insertInDb(groupSummary, groupRooms, groupUsers, groupId) + insertInDb(data) } - private suspend fun insertInDb(groupSummary: GroupSummaryResponse, - groupRooms: GroupRooms, - groupUsers: GroupUsers, - groupId: String) { + private fun getActiveGroupIds(): List { + return monarchy.fetchAllMappedSync( + { realm -> + GroupEntity.where(realm, Membership.activeMemberships()) + }, + { it.groupId } + ) + } + + private suspend fun insertInDb(groupDataList: List) { monarchy .awaitTransaction { realm -> - val groupSummaryEntity = GroupSummaryEntity.where(realm, groupId).findFirst() - ?: realm.createObject(GroupSummaryEntity::class.java, groupId) + groupDataList.forEach { groupData -> - groupSummaryEntity.avatarUrl = groupSummary.profile?.avatarUrl ?: "" - val name = groupSummary.profile?.name - groupSummaryEntity.displayName = if (name.isNullOrEmpty()) groupId else name - groupSummaryEntity.shortDescription = groupSummary.profile?.shortDescription ?: "" + val groupSummaryEntity = GroupSummaryEntity.getOrCreate(realm, groupData.groupId) - groupSummaryEntity.roomIds.clear() - groupRooms.rooms.mapTo(groupSummaryEntity.roomIds) { it.roomId } + groupSummaryEntity.avatarUrl = groupData.groupSummary.profile?.avatarUrl ?: "" + val name = groupData.groupSummary.profile?.name + groupSummaryEntity.displayName = if (name.isNullOrEmpty()) groupData.groupId else name + groupSummaryEntity.shortDescription = groupData.groupSummary.profile?.shortDescription ?: "" - groupSummaryEntity.userIds.clear() - groupUsers.users.mapTo(groupSummaryEntity.userIds) { it.userId } + groupSummaryEntity.roomIds.clear() + groupData.groupRooms.rooms.mapTo(groupSummaryEntity.roomIds) { it.roomId } - groupSummaryEntity.membership = when (groupSummary.user?.membership) { - Membership.JOIN.value -> Membership.JOIN - Membership.INVITE.value -> Membership.INVITE - else -> Membership.LEAVE + groupSummaryEntity.userIds.clear() + groupData.groupUsers.users.mapTo(groupSummaryEntity.userIds) { it.userId } } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroup.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroup.kt index 6c7b5b2a8b..a9e77a73d0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroup.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroup.kt @@ -16,6 +16,20 @@ package im.vector.matrix.android.internal.session.group +import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.group.Group +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith -internal class DefaultGroup(override val groupId: String) : Group +internal class DefaultGroup(override val groupId: String, + private val taskExecutor: TaskExecutor, + private val getGroupDataTask: GetGroupDataTask) : Group { + + override fun fetchGroupData(callback: MatrixCallback): Cancelable { + val params = GetGroupDataTask.Params.FetchWithIds(listOf(groupId)) + return getGroupDataTask.configureWith(params) { + this.callback = callback + }.executeBy(taskExecutor) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt index af73b896f4..4dd162276f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt @@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.group.GroupSummaryQueryParams import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.internal.database.mapper.asDomain +import im.vector.matrix.android.internal.database.model.GroupEntity import im.vector.matrix.android.internal.database.model.GroupSummaryEntity import im.vector.matrix.android.internal.database.model.GroupSummaryEntityFields import im.vector.matrix.android.internal.database.query.where @@ -33,10 +34,15 @@ import io.realm.Realm import io.realm.RealmQuery import javax.inject.Inject -internal class DefaultGroupService @Inject constructor(@SessionDatabase private val monarchy: Monarchy) : GroupService { +internal class DefaultGroupService @Inject constructor(@SessionDatabase private val monarchy: Monarchy, + private val groupFactory: GroupFactory) : GroupService { override fun getGroup(groupId: String): Group? { - return null + return Realm.getInstance(monarchy.realmConfiguration).use { realm -> + GroupEntity.where(realm, groupId).findFirst()?.let { + groupFactory.create(groupId) + } + } } override fun getGroupSummary(groupId: String): GroupSummary? { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt index bb33212f9c..f025040c39 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt @@ -35,7 +35,6 @@ internal class GetGroupDataWorker(context: Context, params: WorkerParameters) : @JsonClass(generateAdapter = true) internal data class Params( override val sessionId: String, - val groupIds: List, override val lastFailureMessage: String? = null ) : SessionWorkerParams @@ -48,14 +47,11 @@ internal class GetGroupDataWorker(context: Context, params: WorkerParameters) : val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success() sessionComponent.inject(this) - val results = params.groupIds.map { groupId -> - runCatching { fetchGroupData(groupId) } - } - val isSuccessful = results.none { it.isFailure } - return if (isSuccessful) Result.success() else Result.retry() - } - - private suspend fun fetchGroupData(groupId: String) { - getGroupDataTask.execute(GetGroupDataTask.Params(groupId)) + return runCatching { + getGroupDataTask.execute(GetGroupDataTask.Params.FetchAllActive) + }.fold( + { Result.success() }, + { Result.retry() } + ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupFactory.kt new file mode 100644 index 0000000000..a5046d45d4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.group + +import im.vector.matrix.android.api.session.group.Group +import im.vector.matrix.android.internal.session.SessionScope +import im.vector.matrix.android.internal.task.TaskExecutor +import javax.inject.Inject + +internal interface GroupFactory { + fun create(groupId: String): Group +} + +@SessionScope +internal class DefaultGroupFactory @Inject constructor(private val getGroupDataTask: GetGroupDataTask, + private val taskExecutor: TaskExecutor) : + GroupFactory { + + override fun create(groupId: String): Group { + return DefaultGroup( + groupId = groupId, + taskExecutor = taskExecutor, + getGroupDataTask = getGroupDataTask + ) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupModule.kt index b48c6a96e8..6799ffd3e5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupModule.kt @@ -36,6 +36,9 @@ internal abstract class GroupModule { } } + @Binds + abstract fun bindGroupFactory(factory: DefaultGroupFactory): GroupFactory + @Binds abstract fun bindGetGroupDataTask(task: DefaultGetGroupDataTask): GetGroupDataTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupSummaryUpdater.kt deleted file mode 100644 index b8f8e84bde..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GroupSummaryUpdater.kt +++ /dev/null @@ -1,91 +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.matrix.android.internal.session.group - -import androidx.work.ExistingWorkPolicy -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.awaitTransaction -import im.vector.matrix.android.internal.database.model.GroupEntity -import im.vector.matrix.android.internal.database.model.GroupSummaryEntity -import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.di.SessionId -import im.vector.matrix.android.internal.di.WorkManagerProvider -import im.vector.matrix.android.internal.worker.WorkerParamsFactory -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmResults -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val GET_GROUP_DATA_WORKER = "GET_GROUP_DATA_WORKER" - -internal class GroupSummaryUpdater @Inject constructor( - private val workManagerProvider: WorkManagerProvider, - @SessionId private val sessionId: String, - @SessionDatabase private val monarchy: Monarchy) - : RealmLiveEntityObserver(monarchy.realmConfiguration) { - - override val query = Monarchy.Query { GroupEntity.where(it) } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - // `insertions` for new groups and `changes` to handle left groups - val modifiedGroupEntity = (changeSet.insertions + changeSet.changes) - .asSequence() - .mapNotNull { results[it] } - - fetchGroupsData(modifiedGroupEntity - .filter { it.membership == Membership.JOIN || it.membership == Membership.INVITE } - .map { it.groupId } - .toList()) - - modifiedGroupEntity - .filter { it.membership == Membership.LEAVE } - .map { it.groupId } - .toList() - .also { - observerScope.launch { - deleteGroups(it) - } - } - } - - private fun fetchGroupsData(groupIds: List) { - val getGroupDataWorkerParams = GetGroupDataWorker.Params(sessionId, groupIds) - - val workData = WorkerParamsFactory.toData(getGroupDataWorkerParams) - - val getGroupWork = workManagerProvider.matrixOneTimeWorkRequestBuilder() - .setInputData(workData) - .setConstraints(WorkManagerProvider.workConstraints) - .build() - - workManagerProvider.workManager - .beginUniqueWork(GET_GROUP_DATA_WORKER, ExistingWorkPolicy.APPEND, getGroupWork) - .enqueue() - } - - /** - * Delete the GroupSummaryEntity of left groups - */ - private suspend fun deleteGroups(groupIds: List) = awaitTransaction(monarchy.realmConfiguration) { realm -> - GroupSummaryEntity.where(realm, groupIds) - .findAll() - .deleteAllFromRealm() - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt index 3f10bf791c..13c97599f7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt @@ -62,6 +62,7 @@ import javax.net.ssl.HttpsURLConnection @SessionScope internal class DefaultIdentityService @Inject constructor( private val identityStore: IdentityStore, + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, private val getOpenIdTokenTask: GetOpenIdTokenTask, private val identityBulkLookupTask: IdentityBulkLookupTask, private val identityRegisterTask: IdentityRegisterTask, @@ -278,7 +279,7 @@ internal class DefaultIdentityService @Inject constructor( } private suspend fun lookUpInternal(canRetry: Boolean, threePids: List): List { - ensureToken() + ensureIdentityTokenTask.execute(Unit) return try { identityBulkLookupTask.execute(IdentityBulkLookupTask.Params(threePids)) @@ -295,17 +296,6 @@ internal class DefaultIdentityService @Inject constructor( } } - private suspend fun ensureToken() { - val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured - val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured - - if (identityData.token == null) { - // Try to get a token - val token = getNewIdentityServerToken(url) - identityStore.setToken(token) - } - } - private suspend fun getNewIdentityServerToken(url: String): String { val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt new file mode 100644 index 0000000000..e727cd69bc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.identity + +import dagger.Lazy +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate +import im.vector.matrix.android.internal.network.RetrofitFactory +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.openid.GetOpenIdTokenTask +import im.vector.matrix.android.internal.task.Task +import okhttp3.OkHttpClient +import javax.inject.Inject + +internal interface EnsureIdentityTokenTask : Task + +internal class DefaultEnsureIdentityTokenTask @Inject constructor( + private val identityStore: IdentityStore, + private val retrofitFactory: RetrofitFactory, + @UnauthenticatedWithCertificate + private val unauthenticatedOkHttpClient: Lazy, + private val getOpenIdTokenTask: GetOpenIdTokenTask, + private val identityRegisterTask: IdentityRegisterTask +) : EnsureIdentityTokenTask { + + override suspend fun execute(params: Unit) { + val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured + val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured + + if (identityData.token == null) { + // Try to get a token + val token = getNewIdentityServerToken(url) + identityStore.setToken(token) + } + } + + private suspend fun getNewIdentityServerToken(url: String): String { + val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) + + val openIdToken = getOpenIdTokenTask.execute(Unit) + val token = identityRegisterTask.execute(IdentityRegisterTask.Params(api, openIdToken)) + + return token.token + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt index 9f902f79f1..79160b8c59 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt @@ -78,6 +78,9 @@ internal abstract class IdentityModule { @Binds abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore + @Binds + abstract fun bindEnsureIdentityTokenTask(task: DefaultEnsureIdentityTokenTask): EnsureIdentityTokenTask + @Binds abstract fun bindIdentityPingTask(task: DefaultIdentityPingTask): IdentityPingTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomDirectoryService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomDirectoryService.kt index ef55702de6..288ee603b6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomDirectoryService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomDirectoryService.kt @@ -24,13 +24,11 @@ import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProt import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.internal.session.room.directory.GetPublicRoomTask import im.vector.matrix.android.internal.session.room.directory.GetThirdPartyProtocolsTask -import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import javax.inject.Inject internal class DefaultRoomDirectoryService @Inject constructor(private val getPublicRoomTask: GetPublicRoomTask, - private val joinRoomTask: JoinRoomTask, private val getThirdPartyProtocolsTask: GetThirdPartyProtocolsTask, private val taskExecutor: TaskExecutor) : RoomDirectoryService { @@ -44,14 +42,6 @@ internal class DefaultRoomDirectoryService @Inject constructor(private val getPu .executeBy(taskExecutor) } - override fun joinRoom(roomIdOrAlias: String, reason: String?, callback: MatrixCallback): Cancelable { - return joinRoomTask - .configureWith(JoinRoomTask.Params(roomIdOrAlias, reason)) { - this.callback = callback - } - .executeBy(taskExecutor) - } - override fun getThirdPartyProtocol(callback: MatrixCallback>): Cancelable { return getThirdPartyProtocolsTask .configureWith { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt index c773682c0f..b8b4c968b1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt @@ -21,12 +21,14 @@ import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.room.RoomSummaryQueryParams +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.internal.session.room.alias.GetRoomIdByAliasTask import im.vector.matrix.android.internal.session.room.create.CreateRoomTask +import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask import im.vector.matrix.android.internal.session.room.summary.RoomSummaryDataSource @@ -43,6 +45,7 @@ internal class DefaultRoomService @Inject constructor( private val roomIdByAliasTask: GetRoomIdByAliasTask, private val roomGetter: RoomGetter, private val roomSummaryDataSource: RoomSummaryDataSource, + private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, private val taskExecutor: TaskExecutor ) : RoomService { @@ -111,4 +114,8 @@ internal class DefaultRoomService @Inject constructor( } .executeBy(taskExecutor) } + + override fun getChangeMembershipsLive(): LiveData> { + return roomChangeMembershipStateDataSource.getLiveStates() + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt similarity index 71% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt index c84b39118e..5214317f3b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt @@ -15,9 +15,7 @@ */ package im.vector.matrix.android.internal.session.room -import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.crypto.CryptoService -import im.vector.matrix.android.api.session.crypto.MXCryptoError import im.vector.matrix.android.api.session.events.model.AggregatedAnnotation import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.EventType @@ -32,13 +30,13 @@ import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessagePollResponseContent import im.vector.matrix.android.api.session.room.model.message.MessageRelationContent import im.vector.matrix.android.api.session.room.model.relation.ReactionContent -import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.EventMapper import im.vector.matrix.android.internal.database.model.EditAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.PollResponseAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntityFields @@ -47,21 +45,12 @@ import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.create import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.task.Task -import im.vector.matrix.android.internal.util.awaitTransaction +import im.vector.matrix.android.internal.di.UserId +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor import io.realm.Realm import timber.log.Timber import javax.inject.Inject -internal interface EventRelationsAggregationTask : Task { - - data class Params( - val events: List, - val userId: String - ) -} - enum class VerificationState { REQUEST, WAITING, @@ -89,161 +78,145 @@ private fun VerificationState?.toState(newState: VerificationState): Verificatio return newState } -/** - * Called by EventRelationAggregationUpdater, when new events that can affect relations are inserted in base. - */ -internal class DefaultEventRelationsAggregationTask @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - private val cryptoService: CryptoService) : EventRelationsAggregationTask { +internal class EventRelationsAggregationProcessor @Inject constructor(@UserId private val userId: String, + private val cryptoService: CryptoService +) : EventInsertLiveProcessor { - // OPT OUT serer aggregation until API mature enough - private val SHOULD_HANDLE_SERVER_AGREGGATION = false + private val allowedTypes = listOf( + EventType.MESSAGE, + EventType.REDACTION, + EventType.REACTION, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_MAC, + // TODO Add ? + // EventType.KEY_VERIFICATION_READY, + EventType.KEY_VERIFICATION_KEY, + EventType.ENCRYPTED + ) - override suspend fun execute(params: EventRelationsAggregationTask.Params) { - val events = params.events - val userId = params.userId - monarchy.awaitTransaction { realm -> - Timber.v(">>> DefaultEventRelationsAggregationTask[${params.hashCode()}] called with ${events.size} events") - update(realm, events, userId) - Timber.v("<<< DefaultEventRelationsAggregationTask[${params.hashCode()}] finished") - } + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + return allowedTypes.contains(eventType) } - private fun update(realm: Realm, events: List, userId: String) { - events.forEach { event -> - try { // Temporary catch, should be removed - val roomId = event.roomId - if (roomId == null) { - Timber.w("Event has no room id ${event.eventId}") - return@forEach + override suspend fun process(realm: Realm, event: Event) { + try { // Temporary catch, should be removed + val roomId = event.roomId + if (roomId == null) { + Timber.w("Event has no room id ${event.eventId}") + return + } + val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") + when (event.type) { + EventType.REACTION -> { + // we got a reaction!! + Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") + handleReaction(event, roomId, realm, userId, isLocalEcho) } - val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") - when (event.type) { - EventType.REACTION -> { - // we got a reaction!! - Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") - handleReaction(event, roomId, realm, userId, isLocalEcho) - } - EventType.MESSAGE -> { - if (event.unsignedData?.relations?.annotations != null) { - Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}") - handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm) + EventType.MESSAGE -> { + if (event.unsignedData?.relations?.annotations != null) { + Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}") + handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm) - EventAnnotationsSummaryEntity.where(realm, event.eventId - ?: "").findFirst()?.let { - TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId - ?: "").findFirst()?.let { tet -> - tet.annotations = it - } - } - } - - val content: MessageContent? = event.content.toModel() - if (content?.relatesTo?.type == RelationType.REPLACE) { - Timber.v("###REPLACE in room $roomId for event ${event.eventId}") - // A replace! - handleReplace(realm, event, content, roomId, isLocalEcho) - } else if (content?.relatesTo?.type == RelationType.RESPONSE) { - Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") - handleResponse(realm, userId, event, content, roomId, isLocalEcho) - } - } - - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_MAC, - EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_KEY -> { - Timber.v("## SAS REF in room $roomId for event ${event.eventId}") - event.content.toModel()?.relatesTo?.let { - if (it.type == RelationType.REFERENCE && it.eventId != null) { - handleVerification(realm, event, roomId, isLocalEcho, it.eventId, userId) + EventAnnotationsSummaryEntity.where(realm, event.eventId + ?: "").findFirst()?.let { + TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId + ?: "").findFirst()?.let { tet -> + tet.annotations = it } } } - EventType.ENCRYPTED -> { - // Relation type is in clear - val encryptedEventContent = event.content.toModel() - if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE - || encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE - ) { - // we need to decrypt if needed - decryptIfNeeded(event) - event.getClearContent().toModel()?.let { - if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) { - Timber.v("###REPLACE in room $roomId for event ${event.eventId}") - // A replace! - handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) - } else if (encryptedEventContent.relatesTo.type == RelationType.RESPONSE) { - Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") - handleResponse(realm, userId, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) - } + val content: MessageContent? = event.content.toModel() + if (content?.relatesTo?.type == RelationType.REPLACE) { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + // A replace! + handleReplace(realm, event, content, roomId, isLocalEcho) + } else if (content?.relatesTo?.type == RelationType.RESPONSE) { + Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") + handleResponse(realm, userId, event, content, roomId, isLocalEcho) + } + } + + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_READY, + EventType.KEY_VERIFICATION_KEY -> { + Timber.v("## SAS REF in room $roomId for event ${event.eventId}") + event.content.toModel()?.relatesTo?.let { + if (it.type == RelationType.REFERENCE && it.eventId != null) { + handleVerification(realm, event, roomId, isLocalEcho, it.eventId, userId) + } + } + } + + EventType.ENCRYPTED -> { + // Relation type is in clear + val encryptedEventContent = event.content.toModel() + if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE + || encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE + ) { + event.getClearContent().toModel()?.let { + if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + // A replace! + handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } else if (encryptedEventContent.relatesTo.type == RelationType.RESPONSE) { + Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") + handleResponse(realm, userId, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) } - } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) { - decryptIfNeeded(event) - when (event.getClearType()) { - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_MAC, - EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_KEY -> { - Timber.v("## SAS REF in room $roomId for event ${event.eventId}") - encryptedEventContent.relatesTo.eventId?.let { - handleVerification(realm, event, roomId, isLocalEcho, it, userId) - } + } + } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) { + when (event.getClearType()) { + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_READY, + EventType.KEY_VERIFICATION_KEY -> { + Timber.v("## SAS REF in room $roomId for event ${event.eventId}") + encryptedEventContent.relatesTo.eventId?.let { + handleVerification(realm, event, roomId, isLocalEcho, it, userId) } } } } - EventType.REDACTION -> { - val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } - ?: return@forEach - when (eventToPrune.type) { - EventType.MESSAGE -> { - Timber.d("REDACTION for message ${eventToPrune.eventId}") + } + EventType.REDACTION -> { + val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } + ?: return + when (eventToPrune.type) { + EventType.MESSAGE -> { + Timber.d("REDACTION for message ${eventToPrune.eventId}") // val unsignedData = EventMapper.map(eventToPrune).unsignedData // ?: UnsignedData(null, null) - // was this event a m.replace - val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() - if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { - handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm) - } - } - EventType.REACTION -> { - handleReactionRedact(eventToPrune, realm, userId) + // was this event a m.replace + val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() + if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { + handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm) } } + EventType.REACTION -> { + handleReactionRedact(eventToPrune, realm, userId) + } } - else -> Timber.v("UnHandled event ${event.eventId}") } - } catch (t: Throwable) { - Timber.e(t, "## Should not happen ") + else -> Timber.v("UnHandled event ${event.eventId}") } + } catch (t: Throwable) { + Timber.e(t, "## Should not happen ") } } - private fun decryptIfNeeded(event: Event) { - if (event.mxDecryptionResult == null) { - try { - val result = cryptoService.decryptEvent(event, "") - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) - } catch (e: MXCryptoError) { - Timber.v("Failed to decrypt e2e replace") - // TODO -> we should keep track of this and retry, or aggregation will be broken - } - } - } + // OPT OUT serer aggregation until API mature enough + private val SHOULD_HANDLE_SERVER_AGREGGATION = false private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean, relatedEventId: String? = null) { val eventId = event.eventId ?: return diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt deleted file mode 100644 index 7ddcf3542d..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt +++ /dev/null @@ -1,76 +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.matrix.android.internal.session.room - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.di.UserId -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -/** - * Acts as a listener of incoming messages in order to incrementally computes a summary of annotations. - * For reactions will build a EventAnnotationsSummaryEntity, ans for edits a EditAggregatedSummaryEntity. - * The summaries can then be extracted and added (as a decoration) to a TimelineEvent for final display. - */ -internal class EventRelationsAggregationUpdater @Inject constructor( - @SessionDatabase realmConfiguration: RealmConfiguration, - @UserId private val userId: String, - private val task: EventRelationsAggregationTask) : - RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity.whereTypes(it, listOf( - EventType.MESSAGE, - EventType.REDACTION, - EventType.REACTION, - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_MAC, - // TODO Add ? - // EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_KEY, - EventType.ENCRYPTED) - ) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - Timber.v("EventRelationsAggregationUpdater called with ${changeSet.insertions.size} insertions") - - val insertedDomains = changeSet.insertions - .asSequence() - .mapNotNull { results[it]?.asDomain() } - .toList() - val params = EventRelationsAggregationTask.Params( - insertedDomains, - userId - ) - observerScope.launch { - task.execute(params) - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index 59fc0efbc0..fd16b1891e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -18,9 +18,6 @@ package im.vector.matrix.android.internal.session.room import im.vector.matrix.android.api.session.events.model.Content import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams -import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse -import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol @@ -28,9 +25,13 @@ import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.internal.network.NetworkConstants import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasBody import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription +import im.vector.matrix.android.internal.session.room.create.CreateRoomBody +import im.vector.matrix.android.internal.session.room.create.CreateRoomResponse +import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody import im.vector.matrix.android.internal.session.room.relation.RelationsResponse import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody import im.vector.matrix.android.internal.session.room.send.SendResponse @@ -79,7 +80,7 @@ internal interface RoomAPI { */ @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom") - fun createRoom(@Body param: CreateRoomParams): Call + fun createRoom(@Body param: CreateRoomBody): Call /** * Get a list of messages starting from a reference. @@ -170,6 +171,14 @@ internal interface RoomAPI { @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite") fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call + /** + * Invite a user to a room, using a ThreePid + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#id101 + * @param roomId Required. The room identifier (not alias) to which to invite the user. + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite") + fun invite3pid(@Path("roomId") roomId: String, @Body body: ThreePidInviteBody): Call + /** * Send a generic state events * diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 5e84920fbd..3eb5427b70 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -44,8 +44,8 @@ import im.vector.matrix.android.internal.session.room.membership.joining.InviteT import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.DefaultLeaveRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask -import im.vector.matrix.android.internal.session.room.prune.DefaultPruneEventTask -import im.vector.matrix.android.internal.session.room.prune.PruneEventTask +import im.vector.matrix.android.internal.session.room.membership.threepid.DefaultInviteThreePidTask +import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask import im.vector.matrix.android.internal.session.room.read.DefaultMarkAllRoomsReadTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask @@ -64,10 +64,10 @@ import im.vector.matrix.android.internal.session.room.tags.AddTagToRoomTask import im.vector.matrix.android.internal.session.room.tags.DefaultAddTagToRoomTask import im.vector.matrix.android.internal.session.room.tags.DefaultDeleteTagFromRoomTask import im.vector.matrix.android.internal.session.room.tags.DeleteTagFromRoomTask -import im.vector.matrix.android.internal.session.room.timeline.DefaultFetchNextTokenAndPaginateTask +import im.vector.matrix.android.internal.session.room.timeline.DefaultFetchTokenAndPaginateTask import im.vector.matrix.android.internal.session.room.timeline.DefaultGetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.DefaultPaginationTask -import im.vector.matrix.android.internal.session.room.timeline.FetchNextTokenAndPaginateTask +import im.vector.matrix.android.internal.session.room.timeline.FetchTokenAndPaginateTask import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.PaginationTask import im.vector.matrix.android.internal.session.room.typing.DefaultSendTypingTask @@ -129,9 +129,6 @@ internal abstract class RoomModule { @Binds abstract fun bindFileService(service: DefaultFileService): FileService - @Binds - abstract fun bindEventRelationsAggregationTask(task: DefaultEventRelationsAggregationTask): EventRelationsAggregationTask - @Binds abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask @@ -144,6 +141,9 @@ internal abstract class RoomModule { @Binds abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask + @Binds + abstract fun bindInviteThreePidTask(task: DefaultInviteThreePidTask): InviteThreePidTask + @Binds abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask @@ -156,9 +156,6 @@ internal abstract class RoomModule { @Binds abstract fun bindLoadRoomMembersTask(task: DefaultLoadRoomMembersTask): LoadRoomMembersTask - @Binds - abstract fun bindPruneEventTask(task: DefaultPruneEventTask): PruneEventTask - @Binds abstract fun bindSetReadMarkersTask(task: DefaultSetReadMarkersTask): SetReadMarkersTask @@ -184,7 +181,7 @@ internal abstract class RoomModule { abstract fun bindPaginationTask(task: DefaultPaginationTask): PaginationTask @Binds - abstract fun bindFetchNextTokenAndPaginateTask(task: DefaultFetchNextTokenAndPaginateTask): FetchNextTokenAndPaginateTask + abstract fun bindFetchNextTokenAndPaginateTask(task: DefaultFetchTokenAndPaginateTask): FetchTokenAndPaginateTask @Binds abstract fun bindFetchEditHistoryTask(task: DefaultFetchEditHistoryTask): FetchEditHistoryTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt new file mode 100644 index 0000000000..7a27da3607 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.create + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.room.model.PowerLevelsContent +import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility +import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody + +/** + * Parameter to create a room + */ +@JsonClass(generateAdapter = true) +internal data class CreateRoomBody( + /** + * A public visibility indicates that the room will be shown in the published room list. + * A private visibility will hide the room from the published room list. + * Rooms default to private visibility if this key is not included. + * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] + */ + @Json(name = "visibility") + val visibility: RoomDirectoryVisibility?, + + /** + * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. + * The alias will belong on the same homeserver which created the room. + * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. + */ + @Json(name = "room_alias_name") + val roomAliasName: String?, + + /** + * If this is included, an m.room.name event will be sent into the room to indicate the name of the room. + * See Room Events for more information on m.room.name. + */ + @Json(name = "name") + val name: String?, + + /** + * If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room. + * See Room Events for more information on m.room.topic. + */ + @Json(name = "topic") + val topic: String?, + + /** + * A list of user IDs to invite to the room. + * This will tell the server to invite everyone in the list to the newly created room. + */ + @Json(name = "invite") + val invitedUserIds: List?, + + /** + * A list of objects representing third party IDs to invite into the room. + */ + @Json(name = "invite_3pid") + val invite3pids: List?, + + /** + * Extra keys to be added to the content of the m.room.create. + * The server will clobber the following keys: creator. + * Future versions of the specification may allow the server to clobber other keys. + */ + @Json(name = "creation_content") + val creationContent: Any?, + + /** + * A list of state events to set in the new room. + * This allows the user to override the default state events set in the new room. + * The expected format of the state events are an object with type, state_key and content keys set. + * Takes precedence over events set by presets, but gets overridden by name and topic keys. + */ + @Json(name = "initial_state") + val initialStates: List?, + + /** + * Convenience parameter for setting various default state events based on a preset. Must be either: + * private_chat => join_rules is set to invite. history_visibility is set to shared. + * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the + * room creator. + * public_chat: => join_rules is set to public. history_visibility is set to shared. + */ + @Json(name = "preset") + val preset: CreateRoomPreset?, + + /** + * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. + * See Direct Messaging for more information. + */ + @Json(name = "is_direct") + val isDirect: Boolean?, + + /** + * The power level content to override in the default power level event + */ + @Json(name = "power_level_content_override") + val powerLevelContentOverride: PowerLevelsContent? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt new file mode 100644 index 0000000000..23eb88bea9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.create + +import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService +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.toContent +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.api.session.identity.toMedium +import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams +import im.vector.matrix.android.internal.crypto.DeviceListManager +import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import im.vector.matrix.android.internal.di.AuthenticatedIdentity +import im.vector.matrix.android.internal.network.token.AccessTokenProvider +import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody +import java.security.InvalidParameterException +import javax.inject.Inject + +internal class CreateRoomBodyBuilder @Inject constructor( + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, + private val crossSigningService: CrossSigningService, + private val deviceListManager: DeviceListManager, + private val identityStore: IdentityStore, + @AuthenticatedIdentity + private val accessTokenProvider: AccessTokenProvider +) { + + suspend fun build(params: CreateRoomParams): CreateRoomBody { + val invite3pids = params.invite3pids + .takeIf { it.isNotEmpty() } + .let { + // This can throw Exception if Identity server is not configured + ensureIdentityTokenTask.execute(Unit) + + val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol() + ?: throw IdentityServiceError.NoIdentityServerConfigured + val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured + + params.invite3pids.map { + ThreePidInviteBody( + id_server = identityServerUrlWithoutProtocol, + id_access_token = identityServerAccessToken, + medium = it.toMedium(), + address = it.value + ) + } + } + + val initialStates = listOfNotNull( + buildEncryptionWithAlgorithmEvent(params), + buildHistoryVisibilityEvent(params) + ) + .takeIf { it.isNotEmpty() } + + return CreateRoomBody( + visibility = params.visibility, + roomAliasName = params.roomAliasName, + name = params.name, + topic = params.topic, + invitedUserIds = params.invitedUserIds, + invite3pids = invite3pids, + creationContent = params.creationContent, + initialStates = initialStates, + preset = params.preset, + isDirect = params.isDirect, + powerLevelContentOverride = params.powerLevelContentOverride + ) + } + + private fun buildHistoryVisibilityEvent(params: CreateRoomParams): Event? { + return params.historyVisibility + ?.let { + val contentMap = mapOf("history_visibility" to it) + + Event( + type = EventType.STATE_ROOM_HISTORY_VISIBILITY, + stateKey = "", + content = contentMap.toContent()) + } + } + + /** + * Add the crypto algorithm to the room creation parameters. + */ + private suspend fun buildEncryptionWithAlgorithmEvent(params: CreateRoomParams): Event? { + if (params.algorithm == null + && canEnableEncryption(params)) { + // Enable the encryption + params.enableEncryption() + } + return params.algorithm + ?.let { + if (it != MXCRYPTO_ALGORITHM_MEGOLM) { + throw InvalidParameterException("Unsupported algorithm: $it") + } + val contentMap = mapOf("algorithm" to it) + + Event( + type = EventType.STATE_ROOM_ENCRYPTION, + stateKey = "", + content = contentMap.toContent() + ) + } + } + + private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean { + return (params.enableEncryptionIfInvitedUsersSupportIt + && crossSigningService.isCrossSigningVerified() + && params.invite3pids.isEmpty()) + && params.invitedUserIds.isNotEmpty() + && params.invitedUserIds.let { userIds -> + val keys = deviceListManager.downloadKeys(userIds, forceDownload = false) + + userIds.all { userId -> + keys.map[userId].let { deviceMap -> + if (deviceMap.isNullOrEmpty()) { + // A user has no device, so do not enable encryption + false + } else { + // Check that every user's device have at least one key + deviceMap.values.all { !it.keys.isNullOrEmpty() } + } + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt similarity index 89% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt index da54b344a2..62208941cc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.matrix.android.api.session.room.model.create +package im.vector.matrix.android.internal.session.room.create import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt index 2071b7736e..791091c549 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt @@ -17,11 +17,9 @@ package im.vector.matrix.android.internal.session.room.create import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams -import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse -import im.vector.matrix.android.internal.crypto.DeviceListManager +import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset import im.vector.matrix.android.internal.database.awaitNotEmptyResult import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntityFields @@ -51,20 +49,15 @@ internal class DefaultCreateRoomTask @Inject constructor( private val readMarkersTask: SetReadMarkersTask, @SessionDatabase private val realmConfiguration: RealmConfiguration, - private val crossSigningService: CrossSigningService, - private val deviceListManager: DeviceListManager, + private val createRoomBodyBuilder: CreateRoomBodyBuilder, private val eventBus: EventBus ) : CreateRoomTask { override suspend fun execute(params: CreateRoomParams): String { - val createRoomParams = if (canEnableEncryption(params)) { - params.enableEncryptionWithAlgorithm() - } else { - params - } + val createRoomBody = createRoomBodyBuilder.build(params) val createRoomResponse = executeRequest(eventBus) { - apiCall = roomAPI.createRoom(createRoomParams) + apiCall = roomAPI.createRoom(createRoomBody) } val roomId = createRoomResponse.roomId // Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before) @@ -76,35 +69,13 @@ internal class DefaultCreateRoomTask @Inject constructor( } catch (exception: TimeoutCancellationException) { throw CreateRoomFailure.CreatedWithTimeout } - if (createRoomParams.isDirect()) { - handleDirectChatCreation(createRoomParams, roomId) + if (params.isDirect()) { + handleDirectChatCreation(params, roomId) } setReadMarkers(roomId) return roomId } - private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean { - return params.enableEncryptionIfInvitedUsersSupportIt - && crossSigningService.isCrossSigningVerified() - && params.invite3pids.isNullOrEmpty() - && params.invitedUserIds?.isNotEmpty() == true - && params.invitedUserIds.let { userIds -> - val keys = deviceListManager.downloadKeys(userIds, forceDownload = false) - - userIds.all { userId -> - keys.map[userId].let { deviceMap -> - if (deviceMap.isNullOrEmpty()) { - // A user has no device, so do not enable encryption - false - } else { - // Check that every user's device have at least one key - deviceMap.values.all { !it.keys.isNullOrEmpty() } - } - } - } - } - } - private suspend fun handleDirectChatCreation(params: CreateRoomParams, roomId: String) { val otherUserId = params.getFirstInvitedUserId() ?: throw IllegalStateException("You can't create a direct room without an invitedUser") @@ -123,4 +94,21 @@ internal class DefaultCreateRoomTask @Inject constructor( val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true) return readMarkersTask.execute(setReadMarkerParams) } + + /** + * Tells if the created room can be a direct chat one. + * + * @return true if it is a direct chat + */ + private fun CreateRoomParams.isDirect(): Boolean { + return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + && isDirect == true + } + + /** + * @return the first invited user id + */ + private fun CreateRoomParams.getFirstInvitedUserId(): String? { + return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventLiveObserver.kt deleted file mode 100644 index fb3880e38d..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventLiveObserver.kt +++ /dev/null @@ -1,72 +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.matrix.android.internal.session.room.create - -import com.zhuinden.monarchy.Monarchy -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.toModel -import im.vector.matrix.android.api.session.room.model.VersioningState -import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.awaitTransaction -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.RoomSummaryEntity -import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import kotlinx.coroutines.launch -import javax.inject.Inject - -internal class RoomCreateEventLiveObserver @Inject constructor(@SessionDatabase - realmConfiguration: RealmConfiguration) - : RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity.whereTypes(it, listOf(EventType.STATE_ROOM_CREATE)) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - changeSet.insertions - .asSequence() - .mapNotNull { - results[it]?.asDomain() - } - .toList() - .also { - observerScope.launch { - handleRoomCreateEvents(it) - } - } - } - - private suspend fun handleRoomCreateEvents(createEvents: List) = awaitTransaction(realmConfiguration) { realm -> - for (event in createEvents) { - val createRoomContent = event.getClearContent().toModel() - val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: continue - - val predecessorRoomSummary = RoomSummaryEntity.where(realm, predecessorRoomId).findFirst() - ?: RoomSummaryEntity(predecessorRoomId) - predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_JOINED - realm.insertOrUpdate(predecessorRoomSummary) - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventProcessor.kt new file mode 100644 index 0000000000..fe2bf846ff --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/RoomCreateEventProcessor.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.create + +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.toModel +import im.vector.matrix.android.api.session.room.model.VersioningState +import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent +import im.vector.matrix.android.internal.database.model.EventInsertType +import im.vector.matrix.android.internal.database.model.RoomSummaryEntity +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor +import io.realm.Realm +import javax.inject.Inject + +internal class RoomCreateEventProcessor @Inject constructor() : EventInsertLiveProcessor { + + override suspend fun process(realm: Realm, event: Event) { + val createRoomContent = event.getClearContent().toModel() + val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: return + + val predecessorRoomSummary = RoomSummaryEntity.where(realm, predecessorRoomId).findFirst() + ?: RoomSummaryEntity(predecessorRoomId) + predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_JOINED + realm.insertOrUpdate(predecessorRoomSummary) + } + + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + return eventType == EventType.STATE_ROOM_CREATE + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt index 8467e8b46c..f413f5c9c0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt @@ -21,6 +21,7 @@ import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.model.Membership @@ -36,6 +37,7 @@ import im.vector.matrix.android.internal.session.room.membership.admin.Membershi import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask +import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.fetchCopied @@ -48,6 +50,7 @@ internal class DefaultMembershipService @AssistedInject constructor( private val taskExecutor: TaskExecutor, private val loadRoomMembersTask: LoadRoomMembersTask, private val inviteTask: InviteTask, + private val inviteThreePidTask: InviteThreePidTask, private val joinTask: JoinRoomTask, private val leaveRoomTask: LeaveRoomTask, private val membershipAdminTask: MembershipAdminTask, @@ -152,6 +155,15 @@ internal class DefaultMembershipService @AssistedInject constructor( .executeBy(taskExecutor) } + override fun invite3pid(threePid: ThreePid, callback: MatrixCallback): Cancelable { + val params = InviteThreePidTask.Params(roomId, threePid) + return inviteThreePidTask + .configureWith(params) { + this.callback = callback + } + .executeBy(taskExecutor) + } + override fun join(reason: String?, viaServers: List, callback: MatrixCallback): Cancelable { val params = JoinRoomTask.Params(roomId, reason, viaServers) return joinTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt index ce4b31b89a..d860ccc7e4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.CurrentStateEventEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.copyToRealmOrIgnore import im.vector.matrix.android.internal.database.query.getOrCreate @@ -76,7 +77,7 @@ internal class DefaultLoadRoomMembersTask @Inject constructor( continue } val ageLocalTs = roomMemberEvent.unsignedData?.age?.let { now - it } - val eventEntity = roomMemberEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = roomMemberEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) CurrentStateEventEntity.getOrCreate(realm, roomId, roomMemberEvent.stateKey, roomMemberEvent.type).apply { eventId = roomMemberEvent.eventId root = eventEntity diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomChangeMembershipStateDataSource.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomChangeMembershipStateDataSource.kt new file mode 100644 index 0000000000..5cf75c3bbd --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomChangeMembershipStateDataSource.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.membership + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState +import im.vector.matrix.android.api.session.room.model.Membership +import im.vector.matrix.android.internal.session.SessionScope +import javax.inject.Inject + +/** + * This class holds information about rooms that current user is joining or leaving. + */ +@SessionScope +internal class RoomChangeMembershipStateDataSource @Inject constructor() { + + private val mutableLiveStates = MutableLiveData>(emptyMap()) + private val states = HashMap() + + /** + * This will update local states to be synced with the server. + */ + fun setMembershipFromSync(roomId: String, membership: Membership) { + if (states.containsKey(roomId)) { + val newState = membership.toMembershipChangeState() + updateState(roomId, newState) + } + } + + fun updateState(roomId: String, state: ChangeMembershipState) { + states[roomId] = state + mutableLiveStates.postValue(states.toMap()) + } + + fun getLiveStates(): LiveData> { + return mutableLiveStates + } + + fun getState(roomId: String): ChangeMembershipState { + return states.getOrElse(roomId) { + ChangeMembershipState.Unknown + } + } + + private fun Membership.toMembershipChangeState(): ChangeMembershipState { + return when { + this == Membership.JOIN -> ChangeMembershipState.Joined + this.isLeft() -> ChangeMembershipState.Left + else -> ChangeMembershipState.Unknown + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberEventHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberEventHandler.kt index d7d578b635..b340766c1b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberEventHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberEventHandler.kt @@ -30,8 +30,15 @@ internal class RoomMemberEventHandler @Inject constructor() { if (event.type != EventType.STATE_ROOM_MEMBER) { return false } - val roomMember = event.content.toModel() ?: return false val userId = event.stateKey ?: return false + val roomMember = event.content.toModel() + return handle(realm, roomId, userId, roomMember) + } + + fun handle(realm: Realm, roomId: String, userId: String, roomMember: RoomMemberContent?): Boolean { + if (roomMember == null) { + return false + } val roomMemberEntity = RoomMemberEntityFactory.create(roomId, userId, roomMember) realm.insertOrUpdate(roomMemberEntity) if (roomMember.membership.isActive()) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt index 635f3955c2..8fb9a1f065 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt @@ -17,13 +17,15 @@ package im.vector.matrix.android.internal.session.room.membership.joining import im.vector.matrix.android.api.session.room.failure.JoinRoomFailure -import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState import im.vector.matrix.android.internal.database.awaitNotEmptyResult import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntityFields import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse +import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.task.Task import io.realm.RealmConfiguration @@ -45,12 +47,19 @@ internal class DefaultJoinRoomTask @Inject constructor( private val readMarkersTask: SetReadMarkersTask, @SessionDatabase private val realmConfiguration: RealmConfiguration, + private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, private val eventBus: EventBus ) : JoinRoomTask { override suspend fun execute(params: JoinRoomTask.Params) { - val joinRoomResponse = executeRequest(eventBus) { - apiCall = roomAPI.join(params.roomIdOrAlias, params.viaServers, mapOf("reason" to params.reason)) + roomChangeMembershipStateDataSource.updateState(params.roomIdOrAlias, ChangeMembershipState.Joining) + val joinRoomResponse = try { + executeRequest(eventBus) { + apiCall = roomAPI.join(params.roomIdOrAlias, params.viaServers, mapOf("reason" to params.reason)) + } + } catch (failure: Throwable) { + roomChangeMembershipStateDataSource.updateState(params.roomIdOrAlias, ChangeMembershipState.FailedJoining(failure)) + throw failure } // Wait for room to come back from the sync (but it can maybe be in the DB is the sync response is received before) val roomId = joinRoomResponse.roomId diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/leaving/LeaveRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/leaving/LeaveRoomTask.kt index 08eb71fc89..94645f3d98 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/leaving/LeaveRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/leaving/LeaveRoomTask.kt @@ -16,10 +16,19 @@ package im.vector.matrix.android.internal.session.room.membership.leaving +import im.vector.matrix.android.api.query.QueryStringValue +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState +import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource +import im.vector.matrix.android.internal.session.room.state.StateEventDataSource +import im.vector.matrix.android.internal.session.room.summary.RoomSummaryDataSource import im.vector.matrix.android.internal.task.Task import org.greenrobot.eventbus.EventBus +import timber.log.Timber import javax.inject.Inject internal interface LeaveRoomTask : Task { @@ -31,12 +40,40 @@ internal interface LeaveRoomTask : Task { internal class DefaultLeaveRoomTask @Inject constructor( private val roomAPI: RoomAPI, - private val eventBus: EventBus + private val eventBus: EventBus, + private val stateEventDataSource: StateEventDataSource, + private val roomSummaryDataSource: RoomSummaryDataSource, + private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource ) : LeaveRoomTask { override suspend fun execute(params: LeaveRoomTask.Params) { - return executeRequest(eventBus) { - apiCall = roomAPI.leave(params.roomId, mapOf("reason" to params.reason)) + leaveRoom(params.roomId, params.reason) + } + + private suspend fun leaveRoom(roomId: String, reason: String?) { + val roomSummary = roomSummaryDataSource.getRoomSummary(roomId) + if (roomSummary?.membership?.isActive() == false) { + Timber.v("Room $roomId is not joined so can't be left") + return + } + roomChangeMembershipStateDataSource.updateState(roomId, ChangeMembershipState.Leaving) + val roomCreateStateEvent = stateEventDataSource.getStateEvent( + roomId = roomId, + eventType = EventType.STATE_ROOM_CREATE, + stateKey = QueryStringValue.NoCondition + ) + // Server is not cleaning predecessor rooms, so we also try to left them + val predecessorRoomId = roomCreateStateEvent?.getClearContent()?.toModel()?.predecessor?.roomId + if (predecessorRoomId != null) { + leaveRoom(predecessorRoomId, reason) + } + try { + executeRequest(eventBus) { + apiCall = roomAPI.leave(roomId, mapOf("reason" to reason)) + } + } catch (failure: Throwable) { + roomChangeMembershipStateDataSource.updateState(roomId, ChangeMembershipState.FailedLeaving(failure)) + throw failure } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt new file mode 100644 index 0000000000..25fe7b4888 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt @@ -0,0 +1,65 @@ +/* + * 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.matrix.android.internal.session.room.membership.threepid + +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.matrix.android.api.session.identity.toMedium +import im.vector.matrix.android.internal.di.AuthenticatedIdentity +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.network.token.AccessTokenProvider +import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.task.Task +import org.greenrobot.eventbus.EventBus +import javax.inject.Inject + +internal interface InviteThreePidTask : Task { + data class Params( + val roomId: String, + val threePid: ThreePid + ) +} + +internal class DefaultInviteThreePidTask @Inject constructor( + private val roomAPI: RoomAPI, + private val eventBus: EventBus, + private val identityStore: IdentityStore, + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, + @AuthenticatedIdentity + private val accessTokenProvider: AccessTokenProvider +) : InviteThreePidTask { + + override suspend fun execute(params: InviteThreePidTask.Params) { + ensureIdentityTokenTask.execute(Unit) + + val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol() ?: throw IdentityServiceError.NoIdentityServerConfigured + val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured + + return executeRequest(eventBus) { + val body = ThreePidInviteBody( + id_server = identityServerUrlWithoutProtocol, + id_access_token = identityServerAccessToken, + medium = params.threePid.toMedium(), + address = params.threePid.value + ) + apiCall = roomAPI.invite3pid(params.roomId, body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt new file mode 100644 index 0000000000..23dd6bad77 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt @@ -0,0 +1,41 @@ +/* + * 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.matrix.android.internal.session.room.membership.threepid + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class ThreePidInviteBody( + /** + * Required. The hostname+port of the identity server which should be used for third party identifier lookups. + */ + @Json(name = "id_server") val id_server: String, + /** + * Required. An access token previously registered with the identity server. Servers can treat this as optional + * to distinguish between r0.5-compatible clients and this specification version. + */ + @Json(name = "id_access_token") val id_access_token: String, + /** + * Required. The kind of address being passed in the address field, for example email. + */ + @Json(name = "medium") val medium: String, + /** + * Required. The invitee's third party identifier. + */ + @Json(name = "address") val address: String +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt deleted file mode 100644 index 27e00c75ab..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt +++ /dev/null @@ -1,56 +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.matrix.android.internal.session.room.prune - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -/** - * Listens to the database for the insertion of any redaction event. - * As it will actually delete the content, it should be called last in the list of listener. - */ -internal class EventsPruner @Inject constructor(@SessionDatabase realmConfiguration: RealmConfiguration, - private val pruneEventTask: PruneEventTask) : - RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { EventEntity.whereTypes(it, listOf(EventType.REDACTION)) } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - Timber.v("Event pruner called with ${changeSet.insertions.size} insertions") - - val insertedDomains = changeSet.insertions - .asSequence() - .mapNotNull { results[it]?.asDomain() } - .toList() - - observerScope.launch { - val params = PruneEventTask.Params(insertedDomains) - pruneEventTask.execute(params) - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/RedactionEventProcessor.kt similarity index 87% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/RedactionEventProcessor.kt index b801843d18..f0e080cb37 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/RedactionEventProcessor.kt @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package im.vector.matrix.android.internal.session.room.prune -import com.zhuinden.monarchy.Monarchy 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 @@ -23,32 +23,28 @@ import im.vector.matrix.android.api.session.events.model.UnsignedData import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.EventMapper import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.findWithSenderMembershipEvent import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.MoshiProvider -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.task.Task -import im.vector.matrix.android.internal.util.awaitTransaction +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor import io.realm.Realm import timber.log.Timber import javax.inject.Inject -internal interface PruneEventTask : Task { +/** + * Listens to the database for the insertion of any redaction event. + * As it will actually delete the content, it should be called last in the list of listener. + */ +internal class RedactionEventProcessor @Inject constructor() : EventInsertLiveProcessor { - data class Params( - val redactionEvents: List - ) -} + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + return eventType == EventType.REDACTION + } -internal class DefaultPruneEventTask @Inject constructor(@SessionDatabase private val monarchy: Monarchy) : PruneEventTask { - - override suspend fun execute(params: PruneEventTask.Params) { - monarchy.awaitTransaction { realm -> - params.redactionEvents.forEach { event -> - pruneEvent(realm, event) - } - } + override suspend fun process(realm: Realm, event: Event) { + pruneEvent(realm, event) } private fun pruneEvent(realm: Realm, redactionEvent: Event) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 370a81e409..4326ef73fa 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -62,7 +62,6 @@ import im.vector.matrix.android.internal.session.content.ThumbnailExtractor import im.vector.matrix.android.internal.session.room.send.pills.TextPillsUtils import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.StringProvider -import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -472,9 +471,7 @@ internal class LocalEchoEventFactory @Inject constructor( fun createLocalEcho(event: Event) { checkNotNull(event.roomId) { "Your event should have a roomId" } - taskExecutor.executorScope.launch { - localEchoRepository.createLocalEcho(event) - } + localEchoRepository.createLocalEcho(event) } companion object { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoRepository.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoRepository.kt index fb47c16943..9ebced26e0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoRepository.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoRepository.kt @@ -29,6 +29,8 @@ import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventInsertEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates @@ -48,7 +50,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private private val eventBus: EventBus, private val timelineEventMapper: TimelineEventMapper) { - suspend fun createLocalEcho(event: Event) { + fun createLocalEcho(event: Event) { val roomId = event.roomId ?: throw IllegalStateException("You should have set a roomId for your event") val senderId = event.senderId ?: throw IllegalStateException("You should have set a senderIf for your event") if (event.eventId == null) { @@ -70,8 +72,12 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } val timelineEvent = timelineEventMapper.map(timelineEventEntity) eventBus.post(DefaultTimeline.OnLocalEchoCreated(roomId = roomId, timelineEvent = timelineEvent)) - monarchy.awaitTransaction { realm -> - val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() ?: return@awaitTransaction + monarchy.writeAsync { realm -> + val eventInsertEntity = EventInsertEntity(event.eventId, event.type).apply { + this.insertType = EventInsertType.LOCAL_ECHO + } + realm.insert(eventInsertEntity) + val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() ?: return@writeAsync roomEntity.sendingTimelineEvents.add(0, timelineEventEntity) roomSummaryUpdater.update(realm, roomId) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/summary/RoomSummaryDataSource.kt index 7c579a2719..b1518b085d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/summary/RoomSummaryDataSource.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/summary/RoomSummaryDataSource.kt @@ -100,6 +100,7 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat private fun roomSummariesQuery(realm: Realm, queryParams: RoomSummaryQueryParams): RealmQuery { val query = RoomSummaryEntity.where(realm) + query.process(RoomSummaryEntityFields.ROOM_ID, queryParams.roomId) query.process(RoomSummaryEntityFields.DISPLAY_NAME, queryParams.displayName) query.process(RoomSummaryEntityFields.CANONICAL_ALIAS, queryParams.canonicalAlias) query.process(RoomSummaryEntityFields.MEMBERSHIP_STR, queryParams.memberships) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 2b1f50d000..567698668b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -29,17 +29,14 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper -import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntityFields -import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.TimelineEventFilter import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.database.query.whereInRoom import im.vector.matrix.android.internal.database.query.whereRoomId import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith @@ -72,7 +69,7 @@ internal class DefaultTimeline( private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor, private val contextOfEventTask: GetContextOfEventTask, - private val fetchNextTokenAndPaginateTask: FetchNextTokenAndPaginateTask, + private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val paginationTask: PaginationTask, private val timelineEventMapper: TimelineEventMapper, private val settings: TimelineSettings, @@ -98,9 +95,7 @@ internal class DefaultTimeline( private lateinit var nonFilteredEvents: RealmResults private lateinit var filteredEvents: RealmResults - private lateinit var eventRelations: RealmResults - - private var roomEntity: RoomEntity? = null + private lateinit var sendingEvents: RealmResults private var prevDisplayIndex: Int? = null private var nextDisplayIndex: Int? = null @@ -122,20 +117,6 @@ internal class DefaultTimeline( handleUpdates(results, changeSet) } - private val relationsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> - var hasChange = false - - (changeSet.insertions + changeSet.changes).forEach { - val eventRelations = collection[it] - if (eventRelations != null) { - hasChange = rebuildEvent(eventRelations.eventId) { te -> - te.copy(annotations = eventRelations.asDomain()) - } || hasChange - } - } - if (hasChange) postSnapshot() - } - // Public methods ****************************************************************************** override fun paginate(direction: Timeline.Direction, count: Int) { @@ -173,26 +154,23 @@ internal class DefaultTimeline( val realm = Realm.getInstance(realmConfiguration) backgroundRealm.set(realm) - roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() - roomEntity?.sendingTimelineEvents?.addChangeListener { events -> + val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() + ?: throw IllegalStateException("Can't open a timeline without a room") + + sendingEvents = roomEntity.sendingTimelineEvents.where().filterEventsWithSettings().findAll() + sendingEvents.addChangeListener { events -> // Remove in memory as soon as they are known by database events.forEach { te -> inMemorySendingEvents.removeAll { te.eventId == it.eventId } } postSnapshot() } - nonFilteredEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll() filteredEvents = nonFilteredEvents.where() .filterEventsWithSettings() .findAll() + filteredEvents.addChangeListener(eventsChangeListener) handleInitialLoad() - nonFilteredEvents.addChangeListener(eventsChangeListener) - - eventRelations = EventAnnotationsSummaryEntity.whereInRoom(realm, roomId) - .findAllAsync() - .also { it.addChangeListener(relationsListener) } - if (settings.shouldHandleHiddenReadReceipts()) { hiddenReadReceipts.start(realm, filteredEvents, nonFilteredEvents, this) } @@ -213,9 +191,8 @@ internal class DefaultTimeline( cancelableBag.cancel() BACKGROUND_HANDLER.removeCallbacksAndMessages(null) BACKGROUND_HANDLER.post { - roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() - if (this::eventRelations.isInitialized) { - eventRelations.removeAllChangeListeners() + if (this::sendingEvents.isInitialized) { + sendingEvents.removeAllChangeListeners() } if (this::nonFilteredEvents.isInitialized) { nonFilteredEvents.removeAllChangeListeners() @@ -314,7 +291,7 @@ internal class DefaultTimeline( listeners.clear() } - // TimelineHiddenReadReceipts.Delegate +// TimelineHiddenReadReceipts.Delegate override fun rebuildEvent(eventId: String, readReceipts: List): Boolean { return rebuildEvent(eventId) { te -> @@ -347,7 +324,7 @@ internal class DefaultTimeline( } } - // Private methods ***************************************************************************** +// Private methods ***************************************************************************** private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { return builtEventsIdMap[eventId]?.let { builtIndex -> @@ -372,7 +349,7 @@ internal class DefaultTimeline( updateState(Timeline.Direction.FORWARDS) { it.copy( - hasMoreInCache = firstBuiltEvent == null || firstBuiltEvent.displayIndex < firstCacheEvent?.displayIndex ?: Int.MIN_VALUE, + hasMoreInCache = firstBuiltEvent != null && firstBuiltEvent.displayIndex < firstCacheEvent?.displayIndex ?: Int.MIN_VALUE, hasReachedEnd = chunkEntity?.isLastForward ?: false ) } @@ -392,6 +369,9 @@ internal class DefaultTimeline( private fun paginateInternal(startDisplayIndex: Int?, direction: Timeline.Direction, count: Int): Boolean { + if (count == 0) { + return false + } updateState(direction) { it.copy(requestedPaginationCount = count, isPaginating = true) } val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) val shouldFetchMore = builtCount < count && !hasReachedEnd(direction) @@ -411,20 +391,16 @@ internal class DefaultTimeline( } private fun buildSendingEvents(): List { - val sendingEvents = ArrayList() + val builtSendingEvents = ArrayList() if (hasReachedEnd(Timeline.Direction.FORWARDS) && !hasMoreInCache(Timeline.Direction.FORWARDS)) { - sendingEvents.addAll(inMemorySendingEvents.filterEventsWithSettings()) - roomEntity?.sendingTimelineEvents - ?.where() - ?.filterEventsWithSettings() - ?.findAll() - ?.forEach { timelineEventEntity -> - if (sendingEvents.find { it.eventId == timelineEventEntity.eventId } == null) { - sendingEvents.add(timelineEventMapper.map(timelineEventEntity)) - } - } + builtSendingEvents.addAll(inMemorySendingEvents.filterEventsWithSettings()) + sendingEvents.forEach { timelineEventEntity -> + if (builtSendingEvents.find { it.eventId == timelineEventEntity.eventId } == null) { + builtSendingEvents.add(timelineEventMapper.map(timelineEventEntity)) + } + } } - return sendingEvents + return builtSendingEvents } private fun canPaginate(direction: Timeline.Direction): Boolean { @@ -525,19 +501,25 @@ internal class DefaultTimeline( val currentChunk = getLiveChunk() val token = if (direction == Timeline.Direction.BACKWARDS) currentChunk?.prevToken else currentChunk?.nextToken if (token == null) { - if (direction == Timeline.Direction.FORWARDS && currentChunk?.hasBeenALastForwardChunk().orFalse()) { - // We are in the case that next event exists, but we do not know the next token. - // Fetch (again) the last event to get a nextToken - val lastKnownEventId = nonFilteredEvents.firstOrNull()?.eventId + if (direction == Timeline.Direction.BACKWARDS + || (direction == Timeline.Direction.FORWARDS && currentChunk?.hasBeenALastForwardChunk().orFalse())) { + // We are in the case where event exists, but we do not know the token. + // Fetch (again) the last event to get a token + val lastKnownEventId = if (direction == Timeline.Direction.FORWARDS) { + nonFilteredEvents.firstOrNull()?.eventId + } else { + nonFilteredEvents.lastOrNull()?.eventId + } if (lastKnownEventId == null) { updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } } else { - val params = FetchNextTokenAndPaginateTask.Params( + val params = FetchTokenAndPaginateTask.Params( roomId = roomId, limit = limit, + direction = direction.toPaginationDirection(), lastKnownEventId = lastKnownEventId ) - cancelableBag += fetchNextTokenAndPaginateTask + cancelableBag += fetchTokenAndPaginateTask .configureWith(params) { this.callback = createPaginationCallback(limit, direction) } @@ -766,7 +748,7 @@ internal class DefaultTimeline( } } -// Extension methods *************************************************************************** + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 449061c2f7..32160a96eb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -21,19 +21,25 @@ import androidx.lifecycle.Transformations import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.events.model.isImageMessage +import im.vector.matrix.android.api.session.events.model.isVideoMessage import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.util.toOptional +import im.vector.matrix.android.internal.crypto.store.db.doWithRealm import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.fetchCopyMap +import io.realm.Sort +import io.realm.kotlin.where import org.greenrobot.eventbus.EventBus internal class DefaultTimelineService @AssistedInject constructor(@Assisted private val roomId: String, @@ -43,7 +49,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv private val contextOfEventTask: GetContextOfEventTask, private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, - private val fetchNextTokenAndPaginateTask: FetchNextTokenAndPaginateTask, + private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val timelineEventMapper: TimelineEventMapper, private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper ) : TimelineService { @@ -66,17 +72,17 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv hiddenReadReceipts = TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), eventBus = eventBus, eventDecryptor = eventDecryptor, - fetchNextTokenAndPaginateTask = fetchNextTokenAndPaginateTask + fetchTokenAndPaginateTask = fetchTokenAndPaginateTask ) } override fun getTimeLineEvent(eventId: String): TimelineEvent? { return monarchy .fetchCopyMap({ - TimelineEventEntity.where(it, roomId = roomId, eventId = eventId).findFirst() - }, { entity, _ -> - timelineEventMapper.map(entity) - }) + TimelineEventEntity.where(it, roomId = roomId, eventId = eventId).findFirst() + }, { entity, _ -> + timelineEventMapper.map(entity) + }) } override fun getTimeLineEventLive(eventId: String): LiveData> { @@ -88,4 +94,16 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv events.firstOrNull().toOptional() } } + + override fun getAttachmentMessages(): List { + // TODO pretty bad query.. maybe we should denormalize clear type in base? + return doWithRealm(monarchy.realmConfiguration) { realm -> + realm.where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + .findAll() + ?.mapNotNull { timelineEventMapper.map(it).takeIf { it.root.isImageMessage() || it.root.isVideoMessage() } } + ?: emptyList() + } + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchTokenAndPaginateTask.kt similarity index 67% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchTokenAndPaginateTask.kt index 6579e0031a..3c1f6b18bd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchNextTokenAndPaginateTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/FetchTokenAndPaginateTask.kt @@ -28,38 +28,48 @@ import im.vector.matrix.android.internal.util.awaitTransaction import org.greenrobot.eventbus.EventBus import javax.inject.Inject -internal interface FetchNextTokenAndPaginateTask : Task { +internal interface FetchTokenAndPaginateTask : Task { data class Params( val roomId: String, val lastKnownEventId: String, + val direction: PaginationDirection, val limit: Int ) } -internal class DefaultFetchNextTokenAndPaginateTask @Inject constructor( +internal class DefaultFetchTokenAndPaginateTask @Inject constructor( private val roomAPI: RoomAPI, @SessionDatabase private val monarchy: Monarchy, private val filterRepository: FilterRepository, private val paginationTask: PaginationTask, private val eventBus: EventBus -) : FetchNextTokenAndPaginateTask { +) : FetchTokenAndPaginateTask { - override suspend fun execute(params: FetchNextTokenAndPaginateTask.Params): TokenChunkEventPersistor.Result { + override suspend fun execute(params: FetchTokenAndPaginateTask.Params): TokenChunkEventPersistor.Result { val filter = filterRepository.getRoomFilter() val response = executeRequest(eventBus) { apiCall = roomAPI.getContextOfEvent(params.roomId, params.lastKnownEventId, 0, filter) } - if (response.end == null) { - throw IllegalStateException("No next token found") + val fromToken = if (params.direction == PaginationDirection.FORWARDS) { + response.end + } else { + response.start } - monarchy.awaitTransaction { - ChunkEntity.findIncludingEvent(it, params.lastKnownEventId)?.nextToken = response.end + ?: throw IllegalStateException("No token found") + + monarchy.awaitTransaction { realm -> + val chunkToUpdate = ChunkEntity.findIncludingEvent(realm, params.lastKnownEventId) + if (params.direction == PaginationDirection.FORWARDS) { + chunkToUpdate?.nextToken = fromToken + } else { + chunkToUpdate?.prevToken = fromToken + } } val paginationParams = PaginationTask.Params( roomId = params.roomId, - from = response.end, - direction = PaginationDirection.FORWARDS, + from = fromToken, + direction = params.direction, limit = params.limit ) return paginationTask.execute(paginationParams) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index 609ebe16fc..b0c697ee6c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -28,6 +28,7 @@ import im.vector.matrix.android.internal.database.helper.deleteOnCascade import im.vector.matrix.android.internal.database.helper.merge import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity @@ -204,7 +205,7 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri for (stateEvent in stateEvents) { val ageLocalTs = stateEvent.unsignedData?.age?.let { now - it } - val stateEventEntity = stateEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val stateEventEntity = stateEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) currentChunk.addStateEvent(roomId, stateEventEntity, direction) if (stateEvent.type == EventType.STATE_ROOM_MEMBER && stateEvent.stateKey != null) { roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel() @@ -217,7 +218,7 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri } val ageLocalTs = event.unsignedData?.age?.let { now - it } eventIds.add(event.eventId) - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { val contentToUse = if (direction == PaginationDirection.BACKWARDS) { event.prevContent @@ -240,12 +241,13 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri chunksToDelete.add(it) } } - val shouldUpdateSummary = chunksToDelete.isNotEmpty() && currentChunk.isLastForward && direction == PaginationDirection.FORWARDS chunksToDelete.forEach { it.deleteOnCascade() } + val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) + val shouldUpdateSummary = roomSummaryEntity.latestPreviewableEvent == null + || (chunksToDelete.isNotEmpty() && currentChunk.isLastForward && direction == PaginationDirection.FORWARDS) if (shouldUpdateSummary) { - val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) val latestPreviewableEvent = TimelineEventEntity.latestEvent( realm, roomId, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventLiveObserver.kt deleted file mode 100644 index 7ca8aaa1d6..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventLiveObserver.kt +++ /dev/null @@ -1,75 +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.matrix.android.internal.session.room.tombstone - -import com.zhuinden.monarchy.Monarchy -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.toModel -import im.vector.matrix.android.api.session.room.model.VersioningState -import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.awaitTransaction -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.RoomSummaryEntity -import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.database.query.whereTypes -import im.vector.matrix.android.internal.di.SessionDatabase -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import kotlinx.coroutines.launch -import javax.inject.Inject - -internal class RoomTombstoneEventLiveObserver @Inject constructor(@SessionDatabase - realmConfiguration: RealmConfiguration) - : RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity.whereTypes(it, listOf(EventType.STATE_ROOM_TOMBSTONE)) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - changeSet.insertions - .asSequence() - .mapNotNull { - results[it]?.asDomain() - } - .toList() - .also { - observerScope.launch { - handleRoomTombstoneEvents(it) - } - } - } - - private suspend fun handleRoomTombstoneEvents(tombstoneEvents: List) = awaitTransaction(realmConfiguration) { realm -> - for (event in tombstoneEvents) { - if (event.roomId == null) continue - val createRoomContent = event.getClearContent().toModel() - if (createRoomContent?.replacementRoomId == null) continue - - val predecessorRoomSummary = RoomSummaryEntity.where(realm, event.roomId).findFirst() - ?: RoomSummaryEntity(event.roomId) - if (predecessorRoomSummary.versioningState == VersioningState.NONE) { - predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_NOT_JOINED - } - realm.insertOrUpdate(predecessorRoomSummary) - } - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt new file mode 100644 index 0000000000..cfdf43c737 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt @@ -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.matrix.android.internal.session.room.tombstone + +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.toModel +import im.vector.matrix.android.api.session.room.model.VersioningState +import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent +import im.vector.matrix.android.internal.database.model.EventInsertType +import im.vector.matrix.android.internal.database.model.RoomSummaryEntity +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.EventInsertLiveProcessor +import io.realm.Realm +import javax.inject.Inject + +internal class RoomTombstoneEventProcessor @Inject constructor() : EventInsertLiveProcessor { + + override suspend fun process(realm: Realm, event: Event) { + if (event.roomId == null) return + val createRoomContent = event.getClearContent().toModel() + if (createRoomContent?.replacementRoomId == null) return + + val predecessorRoomSummary = RoomSummaryEntity.where(realm, event.roomId).findFirst() + ?: RoomSummaryEntity(event.roomId) + if (predecessorRoomSummary.versioningState == VersioningState.NONE) { + predecessorRoomSummary.versioningState = VersioningState.UPGRADED_ROOM_NOT_JOINED + } + realm.insertOrUpdate(predecessorRoomSummary) + } + + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { + return eventType == EventType.STATE_ROOM_TOMBSTONE + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/GroupSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/GroupSyncHandler.kt index 392db0a73c..e3d4eae575 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/GroupSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/GroupSyncHandler.kt @@ -19,6 +19,8 @@ package im.vector.matrix.android.internal.session.sync import im.vector.matrix.android.R import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.internal.database.model.GroupEntity +import im.vector.matrix.android.internal.database.model.GroupSummaryEntity +import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService import im.vector.matrix.android.internal.session.mapWithProgress @@ -64,29 +66,33 @@ internal class GroupSyncHandler @Inject constructor() { handleLeftGroup(realm, it.key) } } - - /** Note: [im.vector.matrix.android.internal.session.group.GroupSummaryUpdater] is observing changes */ realm.insertOrUpdate(groups) } private fun handleJoinedGroup(realm: Realm, groupId: String): GroupEntity { val groupEntity = GroupEntity.where(realm, groupId).findFirst() ?: GroupEntity(groupId) + val groupSummaryEntity = GroupSummaryEntity.getOrCreate(realm, groupId) groupEntity.membership = Membership.JOIN + groupSummaryEntity.membership = Membership.JOIN return groupEntity } private fun handleInvitedGroup(realm: Realm, groupId: String): GroupEntity { val groupEntity = GroupEntity.where(realm, groupId).findFirst() ?: GroupEntity(groupId) + val groupSummaryEntity = GroupSummaryEntity.getOrCreate(realm, groupId) groupEntity.membership = Membership.INVITE + groupSummaryEntity.membership = Membership.INVITE return groupEntity } private fun handleLeftGroup(realm: Realm, groupId: String): GroupEntity { val groupEntity = GroupEntity.where(realm, groupId).findFirst() ?: GroupEntity(groupId) + val groupSummaryEntity = GroupSummaryEntity.getOrCreate(realm, groupId) groupEntity.membership = Membership.LEAVE + groupSummaryEntity.membership = Membership.LEAVE return groupEntity } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index b1d8d7b0b5..df4f52bcc9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -31,10 +31,11 @@ import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResu import im.vector.matrix.android.internal.database.helper.addOrUpdate import im.vector.matrix.android.internal.database.helper.addTimelineEvent import im.vector.matrix.android.internal.database.helper.deleteOnCascade -import im.vector.matrix.android.internal.database.mapper.ContentMapper +import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.CurrentStateEventEntity +import im.vector.matrix.android.internal.database.model.EventInsertType import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomMemberSummaryEntity import im.vector.matrix.android.internal.database.query.copyToRealmOrIgnore @@ -47,9 +48,10 @@ import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService import im.vector.matrix.android.internal.session.mapWithProgress -import im.vector.matrix.android.internal.session.room.summary.RoomSummaryUpdater +import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource import im.vector.matrix.android.internal.session.room.membership.RoomMemberEventHandler import im.vector.matrix.android.internal.session.room.read.FullyReadContent +import im.vector.matrix.android.internal.session.room.summary.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.TimelineEventDecryptor @@ -72,6 +74,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val cryptoService: DefaultCryptoService, private val roomMemberEventHandler: RoomMemberEventHandler, private val roomTypingUsersHandler: RoomTypingUsersHandler, + private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, private val eventBus: EventBus, private val timelineEventDecryptor: TimelineEventDecryptor) { @@ -97,20 +100,25 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle // PRIVATE METHODS ***************************************************************************** private fun handleRoomSync(realm: Realm, handlingStrategy: HandlingStrategy, isInitialSync: Boolean, reporter: DefaultInitialSyncProgressService?) { + val insertType = if (isInitialSync) { + EventInsertType.INITIAL_SYNC + } else { + EventInsertType.INCREMENTAL_SYNC + } val syncLocalTimeStampMillis = System.currentTimeMillis() val rooms = when (handlingStrategy) { is HandlingStrategy.JOINED -> handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_joined_rooms, 0.6f) { - handleJoinedRoom(realm, it.key, it.value, isInitialSync, syncLocalTimeStampMillis) + handleJoinedRoom(realm, it.key, it.value, isInitialSync, insertType, syncLocalTimeStampMillis) } is HandlingStrategy.INVITED -> handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_invited_rooms, 0.1f) { - handleInvitedRoom(realm, it.key, it.value, syncLocalTimeStampMillis) + handleInvitedRoom(realm, it.key, it.value, insertType, syncLocalTimeStampMillis) } is HandlingStrategy.LEFT -> { handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_left_rooms, 0.3f) { - handleLeftRoom(realm, it.key, it.value, syncLocalTimeStampMillis) + handleLeftRoom(realm, it.key, it.value, insertType, syncLocalTimeStampMillis) } } } @@ -121,6 +129,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle roomId: String, roomSync: RoomSync, isInitialSync: Boolean, + insertType: EventInsertType, syncLocalTimestampMillis: Long): RoomEntity { Timber.v("Handle join sync for room $roomId") @@ -147,7 +156,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle continue } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId root = eventEntity @@ -165,6 +174,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle roomSync.timeline.events, roomSync.timeline.prevToken, roomSync.timeline.limited, + insertType, syncLocalTimestampMillis, isInitialSync ) @@ -177,6 +187,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } != null roomTypingUsersHandler.handle(realm, roomId, ephemeralResult) + roomChangeMembershipStateDataSource.setMembershipFromSync(roomId, Membership.JOIN) roomSummaryUpdater.update( realm, roomId, @@ -191,6 +202,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private fun handleInvitedRoom(realm: Realm, roomId: String, roomSync: InvitedRoomSync, + insertType: EventInsertType, syncLocalTimestampMillis: Long): RoomEntity { Timber.v("Handle invited sync for room $roomId") val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: realm.createObject(roomId) @@ -201,7 +213,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle return@forEach } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = eventEntity.eventId root = eventEntity @@ -212,6 +224,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val inviterEvent = roomSync.inviteState?.events?.lastOrNull { it.type == EventType.STATE_ROOM_MEMBER } + roomChangeMembershipStateDataSource.setMembershipFromSync(roomId, Membership.INVITE) roomSummaryUpdater.update(realm, roomId, Membership.INVITE, updateMembers = true, inviterId = inviterEvent?.senderId) return roomEntity } @@ -219,6 +232,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private fun handleLeftRoom(realm: Realm, roomId: String, roomSync: RoomSync, + insertType: EventInsertType, syncLocalTimestampMillis: Long): RoomEntity { val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: realm.createObject(roomId) for (event in roomSync.state?.events.orEmpty()) { @@ -226,7 +240,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle continue } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId root = eventEntity @@ -238,7 +252,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle continue } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId @@ -253,6 +267,8 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val membership = leftMember?.membership ?: Membership.LEAVE roomEntity.membership = membership roomEntity.chunks.deleteAllFromRealm() + roomTypingUsersHandler.handle(realm, roomId, null) + roomChangeMembershipStateDataSource.setMembershipFromSync(roomId, Membership.LEAVE) roomSummaryUpdater.update(realm, roomId, membership, roomSync.summary, roomSync.unreadNotifications) return roomEntity } @@ -263,6 +279,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle eventList: List, prevToken: String? = null, isLimited: Boolean = true, + insertType: EventInsertType, syncLocalTimestampMillis: Long, isInitialSync: Boolean): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) @@ -289,21 +306,22 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId root = eventEntity } if (event.type == EventType.STATE_ROOM_MEMBER) { - roomMemberContentsByUser[event.stateKey] = event.content.toModel() - roomMemberEventHandler.handle(realm, roomEntity.roomId, event) + val fixedContent = event.getFixedRoomMemberContent() + roomMemberContentsByUser[event.stateKey] = fixedContent + roomMemberEventHandler.handle(realm, roomEntity.roomId, event.stateKey, fixedContent) } } roomMemberContentsByUser.getOrPut(event.senderId) { // If we don't have any new state on this user, get it from db val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root - ContentMapper.map(rootStateEvent?.content).toModel() + rootStateEvent?.asDomain()?.getFixedRoomMemberContent() } chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) @@ -394,4 +412,18 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } } } + + private fun Event.getFixedRoomMemberContent(): RoomMemberContent? { + val content = content.toModel() + // if user is leaving, we should grab his last name and avatar from prevContent + return if (content?.membership?.isLeft() == true) { + val prevContent = resolvedPrevContent().toModel() + content.copy( + displayName = prevContent?.displayName, + avatarUrl = prevContent?.avatarUrl + ) + } else { + content + } + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncResponseHandler.kt index 5e8ef5a608..0769895d38 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncResponseHandler.kt @@ -16,23 +16,34 @@ package im.vector.matrix.android.internal.session.sync +import androidx.work.ExistingPeriodicWorkPolicy import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.R import im.vector.matrix.android.api.pushrules.PushRuleService import im.vector.matrix.android.api.pushrules.RuleScope import im.vector.matrix.android.internal.crypto.DefaultCryptoService import im.vector.matrix.android.internal.di.SessionDatabase +import im.vector.matrix.android.internal.di.SessionId +import im.vector.matrix.android.internal.di.WorkManagerProvider import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService +import im.vector.matrix.android.internal.session.group.GetGroupDataWorker import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask import im.vector.matrix.android.internal.session.reportSubtask +import im.vector.matrix.android.internal.session.sync.model.GroupsSyncResponse import im.vector.matrix.android.internal.session.sync.model.RoomsSyncResponse import im.vector.matrix.android.internal.session.sync.model.SyncResponse import im.vector.matrix.android.internal.util.awaitTransaction +import im.vector.matrix.android.internal.worker.WorkerParamsFactory import timber.log.Timber +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.system.measureTimeMillis +private const val GET_GROUP_DATA_WORKER = "GET_GROUP_DATA_WORKER" + internal class SyncResponseHandler @Inject constructor(@SessionDatabase private val monarchy: Monarchy, + @SessionId private val sessionId: String, + private val workManagerProvider: WorkManagerProvider, private val roomSyncHandler: RoomSyncHandler, private val userAccountDataSyncHandler: UserAccountDataSyncHandler, private val groupSyncHandler: GroupSyncHandler, @@ -109,10 +120,39 @@ internal class SyncResponseHandler @Inject constructor(@SessionDatabase private checkPushRules(it, isInitialSync) userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite) } + syncResponse.groups?.also { + scheduleGroupDataFetchingIfNeeded(it) + } + Timber.v("On sync completed") cryptoSyncHandler.onSyncCompleted(syncResponse) } + /** + * At the moment we don't get any group data through the sync, so we poll where every hour. + You can also force to refetch group data using [Group] API. + */ + private fun scheduleGroupDataFetchingIfNeeded(groupsSyncResponse: GroupsSyncResponse) { + val groupIds = ArrayList() + groupIds.addAll(groupsSyncResponse.join.keys) + groupIds.addAll(groupsSyncResponse.invite.keys) + if (groupIds.isEmpty()) { + Timber.v("No new groups to fetch data for.") + return + } + Timber.v("There are ${groupIds.size} new groups to fetch data for.") + val getGroupDataWorkerParams = GetGroupDataWorker.Params(sessionId) + val workData = WorkerParamsFactory.toData(getGroupDataWorkerParams) + + val getGroupWork = workManagerProvider.matrixPeriodicWorkRequestBuilder(1, TimeUnit.HOURS) + .setInputData(workData) + .setConstraints(WorkManagerProvider.workConstraints) + .build() + + workManagerProvider.workManager + .enqueueUniquePeriodicWork(GET_GROUP_DATA_WORKER, ExistingPeriodicWorkPolicy.REPLACE, getGroupWork) + } + private suspend fun checkPushRules(roomsSyncResponse: RoomsSyncResponse, isInitialSync: Boolean) { Timber.v("[PushRules] --> checkPushRules") if (isInitialSync) { diff --git a/multipicker/build.gradle b/multipicker/build.gradle index 8b08a9d3ef..8f2226e884 100644 --- a/multipicker/build.gradle +++ b/multipicker/build.gradle @@ -44,7 +44,7 @@ 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' + implementation 'androidx.core:core-ktx:1.3.0' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' diff --git a/settings.gradle b/settings.gradle index 04307e89d9..76a15a206d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,6 @@ -include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch' +include ':vector' +include ':matrix-sdk-android' +include ':matrix-sdk-android-rx' +include ':diff-match-patch' +include ':attachment-viewer' include ':multipicker' diff --git a/vector/build.gradle b/vector/build.gradle index f966f441b2..f536d08e55 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -17,7 +17,7 @@ androidExtensions { // Note: 2 digits max for each value ext.versionMajor = 0 ext.versionMinor = 91 -ext.versionPatch = 4 +ext.versionPatch = 5 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -262,16 +262,16 @@ android { dependencies { def epoxy_version = '3.11.0' - def fragment_version = '1.2.0' + def fragment_version = '1.2.5' def arrow_version = "0.8.2" def coroutines_version = "1.3.2" def markwon_version = '4.1.2' def big_image_viewer_version = '1.6.2' - def glide_version = '4.10.0' + def glide_version = '4.11.0' def moshi_version = '1.8.0' def daggerVersion = '2.25.4' def autofill_version = "1.0.0" - def work_version = '2.3.3' + def work_version = '2.3.4' def arch_version = '2.1.0' def lifecycle_version = '2.2.0' @@ -279,18 +279,20 @@ dependencies { implementation project(":matrix-sdk-android-rx") implementation project(":diff-match-patch") implementation project(":multipicker") + implementation project(":attachment-viewer") implementation 'com.android.support:multidex:1.0.3' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - implementation "androidx.recyclerview:recyclerview:1.2.0-alpha01" + implementation "androidx.recyclerview:recyclerview:1.2.0-alpha04" implementation 'androidx.appcompat:appcompat:1.1.0' implementation "androidx.fragment:fragment:$fragment_version" implementation "androidx.fragment:fragment-ktx:$fragment_version" + // Keep at 2.0.0-beta4 at the moment, as updating is breaking some UI implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' - implementation 'androidx.core:core-ktx:1.1.0' + implementation 'androidx.core:core-ktx:1.3.0' implementation "org.threeten:threetenbp:1.4.0:no-tzdb" implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0" @@ -326,17 +328,17 @@ dependencies { implementation "androidx.work:work-runtime-ktx:$work_version" // Paging - implementation "androidx.paging:paging-runtime-ktx:2.1.1" + implementation "androidx.paging:paging-runtime-ktx:2.1.2" // Functional Programming implementation "io.arrow-kt:arrow-core:$arrow_version" // Pref - implementation 'androidx.preference:preference:1.1.0' + implementation 'androidx.preference:preference:1.1.1' // UI implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' - implementation 'com.google.android.material:material:1.2.0-alpha03' + implementation 'com.google.android.material:material:1.3.0-alpha01' implementation 'me.gujun.android:span:1.7' implementation "io.noties.markwon:core:$markwon_version" implementation "io.noties.markwon:html:$markwon_version" @@ -368,6 +370,10 @@ dependencies { implementation "com.github.piasy:GlideImageLoader:$big_image_viewer_version" implementation "com.github.piasy:ProgressPieIndicator:$big_image_viewer_version" implementation "com.github.piasy:GlideImageViewFactory:$big_image_viewer_version" + + // implementation 'com.github.MikeOrtiz:TouchImageView:3.0.2' + implementation 'com.github.chrisbanes:PhotoView:2.0.0' + implementation "com.github.bumptech.glide:glide:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" implementation 'com.danikula:videocache:2.7.1' @@ -385,7 +391,7 @@ dependencies { // gplay flavor only // Warning: due to the exclude, Android Studio does not propose to upgrade. Uncomment next line to be proposed to upgrade // implementation 'com.google.firebase:firebase-messaging:20.0.0' - gplayImplementation('com.google.firebase:firebase-messaging:20.0.0') { + gplayImplementation('com.google.firebase:firebase-messaging:20.2.1') { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' @@ -394,7 +400,7 @@ dependencies { // OSS License, gplay flavor only gplayImplementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' - implementation "androidx.emoji:emoji-appcompat:1.0.0" + implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation 'com.github.BillCarsonFr:JsonViewer:0.5' diff --git a/vector/src/fdroid/java/im/vector/riotx/fdroid/features/settings/troubleshoot/TestBatteryOptimization.kt b/vector/src/fdroid/java/im/vector/riotx/fdroid/features/settings/troubleshoot/TestBatteryOptimization.kt index 4d18beac8f..2abbf7a419 100644 --- a/vector/src/fdroid/java/im/vector/riotx/fdroid/features/settings/troubleshoot/TestBatteryOptimization.kt +++ b/vector/src/fdroid/java/im/vector/riotx/fdroid/features/settings/troubleshoot/TestBatteryOptimization.kt @@ -26,7 +26,8 @@ import im.vector.riotx.features.settings.troubleshoot.TroubleshootTest class TestBatteryOptimization(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_battery_title) { override fun perform() { - if (fragment.context != null && isIgnoringBatteryOptimizations(fragment.context!!)) { + val context = fragment.context + if (context != null && isIgnoringBatteryOptimizations(context)) { description = fragment.getString(R.string.settings_troubleshoot_test_battery_success) status = TestStatus.SUCCESS quickFix = null diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index f9b78db17c..155c3bcd64 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -85,6 +85,11 @@ + + + BillCarsonFr/JsonViewer +
  • + Copyright (C) 2018 stfalcon.com +
  •  Apache License
    diff --git a/vector/src/main/java/im/vector/riotx/VectorApplication.kt b/vector/src/main/java/im/vector/riotx/VectorApplication.kt
    index ab7c3e1bf7..db14dba93d 100644
    --- a/vector/src/main/java/im/vector/riotx/VectorApplication.kt
    +++ b/vector/src/main/java/im/vector/riotx/VectorApplication.kt
    @@ -32,8 +32,6 @@ import com.airbnb.epoxy.EpoxyAsyncUtil
     import com.airbnb.epoxy.EpoxyController
     import com.facebook.stetho.Stetho
     import com.gabrielittner.threetenbp.LazyThreeTen
    -import com.github.piasy.biv.BigImageViewer
    -import com.github.piasy.biv.loader.glide.GlideImageLoader
     import im.vector.matrix.android.api.Matrix
     import im.vector.matrix.android.api.MatrixConfiguration
     import im.vector.matrix.android.api.auth.AuthenticationService
    @@ -44,15 +42,12 @@ import im.vector.riotx.core.di.HasVectorInjector
     import im.vector.riotx.core.di.VectorComponent
     import im.vector.riotx.core.extensions.configureAndStart
     import im.vector.riotx.core.rx.RxConfig
    -import im.vector.riotx.features.call.WebRtcPeerConnectionManager
     import im.vector.riotx.features.configuration.VectorConfiguration
     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
     import im.vector.riotx.features.version.VersionProvider
     import im.vector.riotx.push.fcm.FcmHelper
    @@ -79,16 +74,13 @@ class VectorApplication :
         @Inject lateinit var emojiCompatWrapper: EmojiCompatWrapper
         @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler
         @Inject lateinit var activeSessionHolder: ActiveSessionHolder
    -    @Inject lateinit var sessionListener: SessionListener
         @Inject lateinit var notificationDrawerManager: NotificationDrawerManager
    -    @Inject lateinit var pushRuleTriggerListener: PushRuleTriggerListener
         @Inject lateinit var vectorPreferences: VectorPreferences
         @Inject lateinit var versionProvider: VersionProvider
         @Inject lateinit var notificationUtils: NotificationUtils
         @Inject lateinit var appStateHandler: AppStateHandler
         @Inject lateinit var rxConfig: RxConfig
         @Inject lateinit var popupAlertManager: PopupAlertManager
    -    @Inject lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager
     
         lateinit var vectorComponent: VectorComponent
     
    @@ -114,7 +106,6 @@ class VectorApplication :
             logInfo()
             LazyThreeTen.init(this)
     
    -        BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
             EpoxyController.defaultDiffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
             EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
             registerActivityLifecycleCallbacks(VectorActivityLifecycleCallbacks(popupAlertManager))
    @@ -137,8 +128,7 @@ class VectorApplication :
             if (authenticationService.hasAuthenticatedSessions() && !activeSessionHolder.hasActiveSession()) {
                 val lastAuthenticatedSession = authenticationService.getLastAuthenticatedSession()!!
                 activeSessionHolder.setActiveSession(lastAuthenticatedSession)
    -            lastAuthenticatedSession.configureAndStart(applicationContext, pushRuleTriggerListener, sessionListener)
    -            lastAuthenticatedSession.callSignalingService().addCallListener(webRtcPeerConnectionManager)
    +            lastAuthenticatedSession.configureAndStart(applicationContext)
             }
             ProcessLifecycleOwner.get().lifecycle.addObserver(object : LifecycleObserver {
                 @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    diff --git a/vector/src/main/java/im/vector/riotx/core/animations/behavior/PercentViewBehavior.kt b/vector/src/main/java/im/vector/riotx/core/animations/behavior/PercentViewBehavior.kt
    index 967d7d638d..37c07b8293 100644
    --- a/vector/src/main/java/im/vector/riotx/core/animations/behavior/PercentViewBehavior.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/animations/behavior/PercentViewBehavior.kt
    @@ -1,5 +1,5 @@
     /*
    - * Copyright 2019 New Vector Ltd
    + * Copyright 2020 New Vector Ltd
      *
      * Licensed under the Apache License, Version 2.0 (the "License");
      * you may not use this file except in compliance with the License.
    @@ -22,6 +22,7 @@ import android.graphics.drawable.ColorDrawable
     import android.util.AttributeSet
     import android.view.View
     import androidx.coordinatorlayout.widget.CoordinatorLayout
    +import androidx.core.content.withStyledAttributes
     
     import im.vector.riotx.R
     import kotlin.math.abs
    @@ -67,19 +68,19 @@ class PercentViewBehavior(context: Context, attrs: AttributeSet) : Coo
         private var isPrepared: Boolean = false
     
         init {
    -        val a = context.obtainStyledAttributes(attrs, R.styleable.PercentViewBehavior)
    -        dependViewId = a.getResourceId(R.styleable.PercentViewBehavior_behavior_dependsOn, 0)
    -        dependType = a.getInt(R.styleable.PercentViewBehavior_behavior_dependType, DEPEND_TYPE_WIDTH)
    -        dependTarget = a.getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_dependTarget, UNSPECIFIED_INT)
    -        targetX = a.getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetX, UNSPECIFIED_INT)
    -        targetY = a.getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetY, UNSPECIFIED_INT)
    -        targetWidth = a.getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetWidth, UNSPECIFIED_INT)
    -        targetHeight = a.getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetHeight, UNSPECIFIED_INT)
    -        targetBackgroundColor = a.getColor(R.styleable.PercentViewBehavior_behavior_targetBackgroundColor, UNSPECIFIED_INT)
    -        targetAlpha = a.getFloat(R.styleable.PercentViewBehavior_behavior_targetAlpha, UNSPECIFIED_FLOAT)
    -        targetRotateX = a.getFloat(R.styleable.PercentViewBehavior_behavior_targetRotateX, UNSPECIFIED_FLOAT)
    -        targetRotateY = a.getFloat(R.styleable.PercentViewBehavior_behavior_targetRotateY, UNSPECIFIED_FLOAT)
    -        a.recycle()
    +        context.withStyledAttributes(attrs, R.styleable.PercentViewBehavior) {
    +            dependViewId = getResourceId(R.styleable.PercentViewBehavior_behavior_dependsOn, 0)
    +            dependType = getInt(R.styleable.PercentViewBehavior_behavior_dependType, DEPEND_TYPE_WIDTH)
    +            dependTarget = getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_dependTarget, UNSPECIFIED_INT)
    +            targetX = getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetX, UNSPECIFIED_INT)
    +            targetY = getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetY, UNSPECIFIED_INT)
    +            targetWidth = getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetWidth, UNSPECIFIED_INT)
    +            targetHeight = getDimensionPixelOffset(R.styleable.PercentViewBehavior_behavior_targetHeight, UNSPECIFIED_INT)
    +            targetBackgroundColor = getColor(R.styleable.PercentViewBehavior_behavior_targetBackgroundColor, UNSPECIFIED_INT)
    +            targetAlpha = getFloat(R.styleable.PercentViewBehavior_behavior_targetAlpha, UNSPECIFIED_FLOAT)
    +            targetRotateX = getFloat(R.styleable.PercentViewBehavior_behavior_targetRotateX, UNSPECIFIED_FLOAT)
    +            targetRotateY = getFloat(R.styleable.PercentViewBehavior_behavior_targetRotateY, UNSPECIFIED_FLOAT)
    +        }
         }
     
         private fun prepare(parent: CoordinatorLayout, child: View, dependency: View) {
    diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt
    new file mode 100644
    index 0000000000..fd23e495b9
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt
    @@ -0,0 +1,155 @@
    +/*
    + * 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.core.contacts
    +
    +import android.content.Context
    +import android.database.Cursor
    +import android.net.Uri
    +import android.provider.ContactsContract
    +import androidx.annotation.WorkerThread
    +import timber.log.Timber
    +import javax.inject.Inject
    +import kotlin.system.measureTimeMillis
    +
    +class ContactsDataSource @Inject constructor(
    +        private val context: Context
    +) {
    +
    +    /**
    +     * Will return a list of contact from the contacts book of the device, with at least one email or phone.
    +     * If both param are false, you will get en empty list.
    +     * Note: The return list does not contain any matrixId.
    +     */
    +    @WorkerThread
    +    fun getContacts(
    +            withEmails: Boolean,
    +            withMsisdn: Boolean
    +    ): List {
    +        val map = mutableMapOf()
    +        val contentResolver = context.contentResolver
    +
    +        measureTimeMillis {
    +            contentResolver.query(
    +                    ContactsContract.Contacts.CONTENT_URI,
    +                    arrayOf(
    +                            ContactsContract.Contacts._ID,
    +                            ContactsContract.Data.DISPLAY_NAME,
    +                            ContactsContract.Data.PHOTO_URI
    +                    ),
    +                    null,
    +                    null,
    +                    // Sort by Display name
    +                    ContactsContract.Data.DISPLAY_NAME
    +            )
    +                    ?.use { cursor ->
    +                        if (cursor.count > 0) {
    +                            while (cursor.moveToNext()) {
    +                                val id = cursor.getLong(ContactsContract.Contacts._ID) ?: continue
    +                                val displayName = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) ?: continue
    +
    +                                val mappedContactBuilder = MappedContactBuilder(
    +                                        id = id,
    +                                        displayName = displayName
    +                                )
    +
    +                                cursor.getString(ContactsContract.Data.PHOTO_URI)
    +                                        ?.let { Uri.parse(it) }
    +                                        ?.let { mappedContactBuilder.photoURI = it }
    +
    +                                map[id] = mappedContactBuilder
    +                            }
    +                        }
    +                    }
    +
    +            // Get the phone numbers
    +            if (withMsisdn) {
    +                contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
    +                        arrayOf(
    +                                ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
    +                                ContactsContract.CommonDataKinds.Phone.NUMBER
    +                        ),
    +                        null,
    +                        null,
    +                        null)
    +                        ?.use { innerCursor ->
    +                            while (innerCursor.moveToNext()) {
    +                                val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
    +                                        ?.let { map[it] }
    +                                        ?: continue
    +                                innerCursor.getString(ContactsContract.CommonDataKinds.Phone.NUMBER)
    +                                        ?.let {
    +                                            mappedContactBuilder.msisdns.add(
    +                                                    MappedMsisdn(
    +                                                            phoneNumber = it,
    +                                                            matrixId = null
    +                                                    )
    +                                            )
    +                                        }
    +                            }
    +                        }
    +            }
    +
    +            // Get Emails
    +            if (withEmails) {
    +                contentResolver.query(
    +                        ContactsContract.CommonDataKinds.Email.CONTENT_URI,
    +                        arrayOf(
    +                                ContactsContract.CommonDataKinds.Email.CONTACT_ID,
    +                                ContactsContract.CommonDataKinds.Email.DATA
    +                        ),
    +                        null,
    +                        null,
    +                        null)
    +                        ?.use { innerCursor ->
    +                            while (innerCursor.moveToNext()) {
    +                                // This would allow you get several email addresses
    +                                // if the email addresses were stored in an array
    +                                val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Email.CONTACT_ID)
    +                                        ?.let { map[it] }
    +                                        ?: continue
    +                                innerCursor.getString(ContactsContract.CommonDataKinds.Email.DATA)
    +                                        ?.let {
    +                                            mappedContactBuilder.emails.add(
    +                                                    MappedEmail(
    +                                                            email = it,
    +                                                            matrixId = null
    +                                                    )
    +                                            )
    +                                        }
    +                            }
    +                        }
    +            }
    +        }.also { Timber.d("Took ${it}ms to fetch ${map.size} contact(s)") }
    +
    +        return map
    +                .values
    +                .filter { it.emails.isNotEmpty() || it.msisdns.isNotEmpty() }
    +                .map { it.build() }
    +    }
    +
    +    private fun Cursor.getString(column: String): String? {
    +        return getColumnIndex(column)
    +                .takeIf { it != -1 }
    +                ?.let { getString(it) }
    +    }
    +
    +    private fun Cursor.getLong(column: String): Long? {
    +        return getColumnIndex(column)
    +                .takeIf { it != -1 }
    +                ?.let { getLong(it) }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt b/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt
    new file mode 100644
    index 0000000000..c89a3d4b01
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt
    @@ -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.
    + */
    +
    +package im.vector.riotx.core.contacts
    +
    +import android.net.Uri
    +
    +class MappedContactBuilder(
    +        val id: Long,
    +        val displayName: String
    +) {
    +    var photoURI: Uri? = null
    +    val msisdns = mutableListOf()
    +    val emails = mutableListOf()
    +
    +    fun build(): MappedContact {
    +        return MappedContact(
    +                id = id,
    +                displayName = displayName,
    +                photoURI = photoURI,
    +                msisdns = msisdns,
    +                emails = emails
    +        )
    +    }
    +}
    +
    +data class MappedContact(
    +        val id: Long,
    +        val displayName: String,
    +        val photoURI: Uri? = null,
    +        val msisdns: List = emptyList(),
    +        val emails: List = emptyList()
    +)
    +
    +data class MappedEmail(
    +        val email: String,
    +        val matrixId: String?
    +)
    +
    +data class MappedMsisdn(
    +        val phoneNumber: String,
    +        val matrixId: String?
    +)
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    index ff9865c3ea..2dc7b24ebf 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    @@ -20,8 +20,12 @@ import arrow.core.Option
     import im.vector.matrix.android.api.auth.AuthenticationService
     import im.vector.matrix.android.api.session.Session
     import im.vector.riotx.ActiveSessionDataSource
    +import im.vector.riotx.features.call.WebRtcPeerConnectionManager
     import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler
     import im.vector.riotx.features.crypto.verification.IncomingVerificationRequestHandler
    +import im.vector.riotx.features.notifications.PushRuleTriggerListener
    +import im.vector.riotx.features.session.SessionListener
    +import timber.log.Timber
     import java.util.concurrent.atomic.AtomicReference
     import javax.inject.Inject
     import javax.inject.Singleton
    @@ -30,23 +34,42 @@ import javax.inject.Singleton
     class ActiveSessionHolder @Inject constructor(private val authenticationService: AuthenticationService,
                                                   private val sessionObservableStore: ActiveSessionDataSource,
                                                   private val keyRequestHandler: KeyRequestHandler,
    -                                              private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler
    +                                              private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler,
    +                                              private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager,
    +                                              private val pushRuleTriggerListener: PushRuleTriggerListener,
    +                                              private val sessionListener: SessionListener,
    +                                              private val imageManager: ImageManager
     ) {
     
         private var activeSession: AtomicReference = AtomicReference()
     
         fun setActiveSession(session: Session) {
    +        Timber.w("setActiveSession of ${session.myUserId}")
             activeSession.set(session)
             sessionObservableStore.post(Option.just(session))
    +
             keyRequestHandler.start(session)
             incomingVerificationRequestHandler.start(session)
    +        session.addListener(sessionListener)
    +        pushRuleTriggerListener.startWithSession(session)
    +        session.callSignalingService().addCallListener(webRtcPeerConnectionManager)
    +        imageManager.onSessionStarted(session)
         }
     
         fun clearActiveSession() {
    +        // Do some cleanup first
    +        getSafeActiveSession()?.let {
    +            Timber.w("clearActiveSession of ${it.myUserId}")
    +            it.callSignalingService().removeCallListener(webRtcPeerConnectionManager)
    +            it.removeListener(sessionListener)
    +        }
    +
             activeSession.set(null)
             sessionObservableStore.post(Option.empty())
    +
             keyRequestHandler.stop()
             incomingVerificationRequestHandler.stop()
    +        pushRuleTriggerListener.stop()
         }
     
         fun hasActiveSession(): Boolean {
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    index 21cff188d0..8e4f95ed54 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    @@ -23,6 +23,7 @@ import dagger.Binds
     import dagger.Module
     import dagger.multibindings.IntoMap
     import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment
    +import im.vector.riotx.features.contactsbook.ContactsBookFragment
     import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsFragment
     import im.vector.riotx.features.crypto.quads.SharedSecuredStorageKeyFragment
     import im.vector.riotx.features.crypto.quads.SharedSecuredStoragePassphraseFragment
    @@ -528,4 +529,9 @@ interface FragmentModule {
         @IntoMap
         @FragmentKey(WidgetFragment::class)
         fun bindWidgetFragment(fragment: WidgetFragment): Fragment
    +
    +    @Binds
    +    @IntoMap
    +    @FragmentKey(ContactsBookFragment::class)
    +    fun bindPhoneBookFragment(fragment: ContactsBookFragment): Fragment
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ImageManager.kt b/vector/src/main/java/im/vector/riotx/core/di/ImageManager.kt
    new file mode 100644
    index 0000000000..74a01e76ec
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ImageManager.kt
    @@ -0,0 +1,47 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.core.di
    +
    +import android.content.Context
    +import com.bumptech.glide.Glide
    +import com.bumptech.glide.load.model.GlideUrl
    +import com.github.piasy.biv.BigImageViewer
    +import com.github.piasy.biv.loader.glide.GlideImageLoader
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.riotx.ActiveSessionDataSource
    +import im.vector.riotx.core.glide.FactoryUrl
    +import java.io.InputStream
    +import javax.inject.Inject
    +
    +/**
    + * This class is used to configure the library we use for images
    + */
    +class ImageManager @Inject constructor(
    +        private val context: Context,
    +        private val activeSessionDataSource: ActiveSessionDataSource
    +) {
    +
    +    fun onSessionStarted(session: Session) {
    +        // Do this call first
    +        BigImageViewer.initialize(GlideImageLoader.with(context, session.getOkHttpClient()))
    +
    +        val glide = Glide.get(context)
    +
    +        // And this one. FIXME But are losing what BigImageViewer has done to add a Progress listener
    +        glide.registry.replace(GlideUrl::class.java, InputStream::class.java, FactoryUrl(activeSessionDataSource))
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    index ceb276614a..2838a42169 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    @@ -48,6 +48,7 @@ import im.vector.riotx.features.invite.InviteUsersToRoomActivity
     import im.vector.riotx.features.invite.VectorInviteView
     import im.vector.riotx.features.link.LinkHandlerActivity
     import im.vector.riotx.features.login.LoginActivity
    +import im.vector.riotx.features.media.VectorAttachmentViewerActivity
     import im.vector.riotx.features.media.BigImageViewerActivity
     import im.vector.riotx.features.media.ImageMediaViewerActivity
     import im.vector.riotx.features.media.VideoMediaViewerActivity
    @@ -72,6 +73,7 @@ import im.vector.riotx.features.terms.ReviewTermsActivity
     import im.vector.riotx.features.ui.UiStateRepository
     import im.vector.riotx.features.widgets.WidgetActivity
     import im.vector.riotx.features.widgets.permissions.RoomWidgetPermissionBottomSheet
    +import im.vector.riotx.features.workers.signout.SignOutBottomSheetDialogFragment
     
     @Component(
             dependencies = [
    @@ -135,6 +137,7 @@ interface ScreenComponent {
         fun inject(activity: ReviewTermsActivity)
         fun inject(activity: WidgetActivity)
         fun inject(activity: VectorCallActivity)
    +    fun inject(activity: VectorAttachmentViewerActivity)
     
         /* ==========================================================================================
          * BottomSheets
    @@ -152,6 +155,7 @@ interface ScreenComponent {
         fun inject(bottomSheet: RoomWidgetPermissionBottomSheet)
         fun inject(bottomSheet: RoomWidgetsBottomSheet)
         fun inject(bottomSheet: CallControlsBottomSheet)
    +    fun inject(bottomSheet: SignOutBottomSheetDialogFragment)
     
         /* ==========================================================================================
          * Others
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    index badfdd96c1..6ac6fa03da 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    @@ -36,7 +36,6 @@ import im.vector.riotx.features.reactions.EmojiChooserViewModel
     import im.vector.riotx.features.roomdirectory.RoomDirectorySharedActionViewModel
     import im.vector.riotx.features.roomprofile.RoomProfileSharedActionViewModel
     import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel
    -import im.vector.riotx.features.workers.signout.SignOutViewModel
     
     @Module
     interface ViewModelModule {
    @@ -51,11 +50,6 @@ interface ViewModelModule {
          *  Below are bindings for the androidx view models (which extend ViewModel). Will be converted to MvRx ViewModel in the future.
          */
     
    -    @Binds
    -    @IntoMap
    -    @ViewModelKey(SignOutViewModel::class)
    -    fun bindSignOutViewModel(viewModel: SignOutViewModel): ViewModel
    -
         @Binds
         @IntoMap
         @ViewModelKey(EmojiChooserViewModel::class)
    diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt
    index e9f4dba7a5..b89da07984 100644
    --- a/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt
    @@ -20,6 +20,7 @@ package im.vector.riotx.core.epoxy.profiles
     import android.view.View
     import android.widget.ImageView
     import android.widget.TextView
    +import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
    @@ -36,16 +37,21 @@ abstract class ProfileMatrixItem : VectorEpoxyModel()
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
         @EpoxyAttribute lateinit var matrixItem: MatrixItem
    +    @EpoxyAttribute var editable: Boolean = true
         @EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null
         @EpoxyAttribute var clickListener: View.OnClickListener? = null
     
         override fun bind(holder: Holder) {
             super.bind(holder)
             val bestName = matrixItem.getBestName()
    -        val matrixId = matrixItem.id.takeIf { it != bestName }
    -        holder.view.setOnClickListener(clickListener)
    +        val matrixId = matrixItem.id
    +                .takeIf { it != bestName }
    +                // Special case for ThreePid fake matrix item
    +                .takeIf { it != "@" }
    +        holder.view.setOnClickListener(clickListener?.takeIf { editable })
             holder.titleView.text = bestName
             holder.subtitleView.setTextOrHide(matrixId)
    +        holder.editableView.isVisible = editable
             avatarRenderer.render(matrixItem, holder.avatarImageView)
             holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes())
         }
    @@ -55,5 +61,6 @@ abstract class ProfileMatrixItem : VectorEpoxyModel()
             val subtitleView by bind(R.id.matrixItemSubtitle)
             val avatarImageView by bind(R.id.matrixItemAvatar)
             val avatarDecorationImageView by bind(R.id.matrixItemAvatarDecoration)
    +        val editableView by bind(R.id.matrixItemEditable)
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    index b74f143e17..cc6eb54154 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    @@ -23,21 +23,21 @@ import androidx.fragment.app.FragmentTransaction
     import im.vector.riotx.core.platform.VectorBaseActivity
     
     fun VectorBaseActivity.addFragment(frameId: Int, fragment: Fragment) {
    -    supportFragmentManager.commitTransactionNow { add(frameId, fragment) }
    +    supportFragmentManager.commitTransaction { add(frameId, fragment) }
     }
     
     fun  VectorBaseActivity.addFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    supportFragmentManager.commitTransactionNow {
    +    supportFragmentManager.commitTransaction {
             add(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
     
     fun VectorBaseActivity.replaceFragment(frameId: Int, fragment: Fragment, tag: String? = null) {
    -    supportFragmentManager.commitTransactionNow { replace(frameId, fragment, tag) }
    +    supportFragmentManager.commitTransaction { replace(frameId, fragment, tag) }
     }
     
     fun  VectorBaseActivity.replaceFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    supportFragmentManager.commitTransactionNow {
    +    supportFragmentManager.commitTransaction {
             replace(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt
    index 5bd6852e8a..99a5cb5a1a 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt
    @@ -19,6 +19,9 @@ package im.vector.riotx.core.extensions
     import android.os.Bundle
     import android.util.Patterns
     import androidx.fragment.app.Fragment
    +import com.google.i18n.phonenumbers.NumberParseException
    +import com.google.i18n.phonenumbers.PhoneNumberUtil
    +import im.vector.matrix.android.api.extensions.ensurePrefix
     
     fun Boolean.toOnOff() = if (this) "ON" else "OFF"
     
    @@ -33,3 +36,15 @@ fun  T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bu
      * Check if a CharSequence is an email
      */
     fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
    +
    +/**
    + * Check if a CharSequence is a phone number
    + */
    +fun CharSequence.isMsisdn(): Boolean {
    +    return try {
    +        PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null)
    +        true
    +    } catch (e: NumberParseException) {
    +        false
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt
    index c28dcf12d3..2f07c2ade3 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt
    @@ -16,26 +16,32 @@
     
     package im.vector.riotx.core.extensions
     
    +import android.app.Activity
     import android.os.Parcelable
     import androidx.fragment.app.Fragment
    +import im.vector.riotx.R
     import im.vector.riotx.core.platform.VectorBaseFragment
    +import im.vector.riotx.core.utils.selectTxtFileToWrite
    +import java.text.SimpleDateFormat
    +import java.util.Date
    +import java.util.Locale
     
     fun VectorBaseFragment.addFragment(frameId: Int, fragment: Fragment) {
    -    parentFragmentManager.commitTransactionNow { add(frameId, fragment) }
    +    parentFragmentManager.commitTransaction { add(frameId, fragment) }
     }
     
     fun  VectorBaseFragment.addFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    parentFragmentManager.commitTransactionNow {
    +    parentFragmentManager.commitTransaction {
             add(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
     
     fun VectorBaseFragment.replaceFragment(frameId: Int, fragment: Fragment) {
    -    parentFragmentManager.commitTransactionNow { replace(frameId, fragment) }
    +    parentFragmentManager.commitTransaction { replace(frameId, fragment) }
     }
     
     fun  VectorBaseFragment.replaceFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    parentFragmentManager.commitTransactionNow {
    +    parentFragmentManager.commitTransaction {
             replace(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
    @@ -51,21 +57,21 @@ fun  VectorBaseFragment.addFragmentToBackstack(frameId: Int, fragm
     }
     
     fun VectorBaseFragment.addChildFragment(frameId: Int, fragment: Fragment, tag: String? = null) {
    -    childFragmentManager.commitTransactionNow { add(frameId, fragment, tag) }
    +    childFragmentManager.commitTransaction { add(frameId, fragment, tag) }
     }
     
     fun  VectorBaseFragment.addChildFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    childFragmentManager.commitTransactionNow {
    +    childFragmentManager.commitTransaction {
             add(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
     
     fun VectorBaseFragment.replaceChildFragment(frameId: Int, fragment: Fragment, tag: String? = null) {
    -    childFragmentManager.commitTransactionNow { replace(frameId, fragment, tag) }
    +    childFragmentManager.commitTransaction { replace(frameId, fragment, tag) }
     }
     
     fun  VectorBaseFragment.replaceChildFragment(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) {
    -    childFragmentManager.commitTransactionNow {
    +    childFragmentManager.commitTransaction {
             replace(frameId, fragmentClass, params.toMvRxBundle(), tag)
         }
     }
    @@ -89,3 +95,27 @@ fun Fragment.getAllChildFragments(): List {
     
     // Define a missing constant
     const val POP_BACK_STACK_EXCLUSIVE = 0
    +
    +fun Fragment.queryExportKeys(userId: String, requestCode: Int) {
    +    val timestamp = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
    +
    +    selectTxtFileToWrite(
    +            activity = requireActivity(),
    +            fragment = this,
    +            defaultFileName = "riot-megolm-export-$userId-$timestamp.txt",
    +            chooserHint = getString(R.string.keys_backup_setup_step1_manual_export),
    +            requestCode = requestCode
    +    )
    +}
    +
    +fun Activity.queryExportKeys(userId: String, requestCode: Int) {
    +    val timestamp = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
    +
    +    selectTxtFileToWrite(
    +            activity = this,
    +            fragment = null,
    +            defaultFileName = "riot-megolm-export-$userId-$timestamp.txt",
    +            chooserHint = getString(R.string.keys_backup_setup_step1_manual_export),
    +            requestCode = requestCode
    +    )
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt
    index 987194ea2f..b9907f8789 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt
    @@ -38,13 +38,13 @@ inline fun > Iterable.lastMinBy(selector: (T) -> R): T?
     /**
      * Call each for each item, and between between each items
      */
    -inline fun  Collection.join(each: (T) -> Unit, between: (T) -> Unit) {
    +inline fun  Collection.join(each: (Int, T) -> Unit, between: (Int, T) -> Unit) {
         val lastIndex = size - 1
         forEachIndexed { idx, t ->
    -        each(t)
    +        each(idx, t)
     
             if (idx != lastIndex) {
    -            between(t)
    +            between(idx, t)
             }
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    index 29b169ffd4..9d49319896 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    @@ -24,20 +24,14 @@ import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
     import im.vector.matrix.android.api.session.sync.FilterService
     import im.vector.riotx.core.services.VectorSyncService
    -import im.vector.riotx.features.notifications.PushRuleTriggerListener
    -import im.vector.riotx.features.session.SessionListener
     import timber.log.Timber
     
    -fun Session.configureAndStart(context: Context,
    -                              pushRuleTriggerListener: PushRuleTriggerListener,
    -                              sessionListener: SessionListener) {
    +fun Session.configureAndStart(context: Context) {
    +    Timber.i("Configure and start session for $myUserId")
         open()
    -    addListener(sessionListener)
         setFilter(FilterService.FilterPreset.RiotFilter)
    -    Timber.i("Configure and start session for ${this.myUserId}")
         startSyncing(context)
         refreshPushers()
    -    pushRuleTriggerListener.startWithSession(this)
     }
     
     fun Session.startSyncing(context: Context) {
    @@ -65,3 +59,12 @@ fun Session.hasUnsavedKeys(): Boolean {
         return cryptoService().inboundGroupSessionsCount(false) > 0
                 && cryptoService().keysBackupService().state != KeysBackupState.ReadyToBackUp
     }
    +
    +fun Session.cannotLogoutSafely(): Boolean {
    +    // has some encrypted chat
    +    return hasUnsavedKeys()
    +            // has local cross signing keys
    +            || (cryptoService().crossSigningService().allPrivateKeysKnown()
    +            // That are not backed up
    +            && !sharedSecretStorageService.isRecoverySetup())
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/glide/FactoryUrl.kt b/vector/src/main/java/im/vector/riotx/core/glide/FactoryUrl.kt
    new file mode 100644
    index 0000000000..fc037894db
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/glide/FactoryUrl.kt
    @@ -0,0 +1,38 @@
    +/*
    + * 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.core.glide
    +
    +import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
    +import com.bumptech.glide.load.model.GlideUrl
    +import com.bumptech.glide.load.model.ModelLoader
    +import com.bumptech.glide.load.model.ModelLoaderFactory
    +import com.bumptech.glide.load.model.MultiModelLoaderFactory
    +import im.vector.riotx.ActiveSessionDataSource
    +import okhttp3.OkHttpClient
    +import java.io.InputStream
    +
    +class FactoryUrl(private val activeSessionDataSource: ActiveSessionDataSource) : ModelLoaderFactory {
    +
    +    override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader {
    +        val client = activeSessionDataSource.currentValue?.orNull()?.getOkHttpClient() ?: OkHttpClient()
    +        return OkHttpUrlLoader(client)
    +    }
    +
    +    override fun teardown() {
    +        // Do nothing, this instance doesn't own the client.
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/glide/VectorGlideModelLoader.kt b/vector/src/main/java/im/vector/riotx/core/glide/VectorGlideModelLoader.kt
    index 191ab6d972..510eef71e1 100644
    --- a/vector/src/main/java/im/vector/riotx/core/glide/VectorGlideModelLoader.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/glide/VectorGlideModelLoader.kt
    @@ -65,7 +65,7 @@ class VectorGlideDataFetcher(private val activeSessionHolder: ActiveSessionHolde
                                  private val height: Int)
         : DataFetcher {
     
    -    val client = OkHttpClient()
    +    private val client = activeSessionHolder.getSafeActiveSession()?.getOkHttpClient() ?: OkHttpClient()
     
         override fun getDataClass(): Class {
             return InputStream::class.java
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.kt b/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.kt
    deleted file mode 100644
    index f451308c36..0000000000
    --- a/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.kt
    +++ /dev/null
    @@ -1,419 +0,0 @@
    -/*
    - * Copyright (C) 2011 Micah Hainline
    - * Copyright (C) 2012 Triposo
    - * Copyright (C) 2013 Paul Imhoff
    - * Copyright (C) 2014 Shahin Yousefi
    - * Copyright 2020 New Vector Ltd
    - *
    - * Licensed under the Apache License, Version 2.0 (the "License");
    - * you may not use this file except in compliance with the License.
    - * You may obtain a copy of the License at
    - *
    - * http://www.apache.org/licenses/LICENSE-2.0
    - *
    - * Unless required by applicable law or agreed to in writing, software
    - * distributed under the License is distributed on an "AS IS" BASIS,
    - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    - * See the License for the specific language governing permissions and
    - * limitations under the License.
    - *
    - */
    -package im.vector.riotx.core.platform
    -
    -import android.content.Context
    -import android.graphics.Canvas
    -import android.graphics.Color
    -import android.text.Layout
    -import android.text.Spannable
    -import android.text.SpannableString
    -import android.text.SpannableStringBuilder
    -import android.text.Spanned
    -import android.text.StaticLayout
    -import android.text.TextUtils.TruncateAt
    -import android.text.TextUtils.concat
    -import android.text.TextUtils.copySpansFrom
    -import android.text.TextUtils.indexOf
    -import android.text.TextUtils.lastIndexOf
    -import android.text.TextUtils.substring
    -import android.text.style.ForegroundColorSpan
    -import android.util.AttributeSet
    -import androidx.appcompat.widget.AppCompatTextView
    -import timber.log.Timber
    -import java.util.ArrayList
    -import java.util.regex.Pattern
    -
    -/*
    - * Imported from https://gist.github.com/hateum/d2095575b441007d62b8
    - *
    - * Use it in your layout to avoid this issue: https://issuetracker.google.com/issues/121092510
    - */
    -
    -/**
    - * A [android.widget.TextView] that ellipsizes more intelligently.
    - * This class supports ellipsizing multiline text through setting `android:ellipsize`
    - * and `android:maxLines`.
    - *
    - *
    - * Note: [TruncateAt.MARQUEE] ellipsizing type is not supported.
    - * This as to be used to get rid of the StaticLayout issue with maxLines and ellipsize causing some performance issues.
    - */
    -class EllipsizingTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = android.R.attr.textViewStyle)
    -    : AppCompatTextView(context, attrs, defStyle) {
    -
    -    private val ELLIPSIS = SpannableString("\u2026")
    -    private val ellipsizeListeners: MutableList = ArrayList()
    -    private var ellipsizeStrategy: EllipsizeStrategy? = null
    -    var isEllipsized = false
    -        private set
    -    private var isStale = false
    -    private var programmaticChange = false
    -    private var fullText: CharSequence? = null
    -    private var maxLines = 0
    -    private var lineSpacingMult = 1.0f
    -    private var lineAddVertPad = 0.0f
    -    /**
    -     * The end punctuation which will be removed when appending [.ELLIPSIS].
    -     */
    -    private var mEndPunctPattern: Pattern? = null
    -
    -    fun setEndPunctuationPattern(pattern: Pattern?) {
    -        mEndPunctPattern = pattern
    -    }
    -
    -    fun addEllipsizeListener(listener: EllipsizeListener) {
    -        ellipsizeListeners.add(listener)
    -    }
    -
    -    fun removeEllipsizeListener(listener: EllipsizeListener) {
    -        ellipsizeListeners.remove(listener)
    -    }
    -
    -    /**
    -     * @return The maximum number of lines displayed in this [android.widget.TextView].
    -     */
    -    override fun getMaxLines(): Int {
    -        return maxLines
    -    }
    -
    -    override fun setMaxLines(maxLines: Int) {
    -        super.setMaxLines(maxLines)
    -        this.maxLines = maxLines
    -        isStale = true
    -    }
    -
    -    /**
    -     * Determines if the last fully visible line is being ellipsized.
    -     *
    -     * @return `true` if the last fully visible line is being ellipsized;
    -     * otherwise, returns `false`.
    -     */
    -    fun ellipsizingLastFullyVisibleLine(): Boolean {
    -        return maxLines == Int.MAX_VALUE
    -    }
    -
    -    override fun setLineSpacing(add: Float, mult: Float) {
    -        lineAddVertPad = add
    -        lineSpacingMult = mult
    -        super.setLineSpacing(add, mult)
    -    }
    -
    -    override fun setText(text: CharSequence?, type: BufferType) {
    -        if (!programmaticChange) {
    -            fullText = if (text is Spanned) text else text
    -            isStale = true
    -        }
    -        super.setText(text, type)
    -    }
    -
    -    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    -        super.onSizeChanged(w, h, oldw, oldh)
    -        if (ellipsizingLastFullyVisibleLine()) {
    -            isStale = true
    -        }
    -    }
    -
    -    override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
    -        super.setPadding(left, top, right, bottom)
    -        if (ellipsizingLastFullyVisibleLine()) {
    -            isStale = true
    -        }
    -    }
    -
    -    override fun onDraw(canvas: Canvas) {
    -        if (isStale) {
    -            resetText()
    -        }
    -        super.onDraw(canvas)
    -    }
    -
    -    /**
    -     * Sets the ellipsized text if appropriate.
    -     */
    -    private fun resetText() {
    -        val maxLines = maxLines
    -        var workingText = fullText
    -        var ellipsized = false
    -        if (maxLines != -1) {
    -            if (ellipsizeStrategy == null) setEllipsize(null)
    -            workingText = ellipsizeStrategy!!.processText(fullText)
    -            ellipsized = !ellipsizeStrategy!!.isInLayout(fullText)
    -        }
    -        if (workingText != text) {
    -            programmaticChange = true
    -            text = try {
    -                workingText
    -            } finally {
    -                programmaticChange = false
    -            }
    -        }
    -        isStale = false
    -        if (ellipsized != isEllipsized) {
    -            isEllipsized = ellipsized
    -            for (listener in ellipsizeListeners) {
    -                listener.ellipsizeStateChanged(ellipsized)
    -            }
    -        }
    -    }
    -
    -    /**
    -     * Causes words in the text that are longer than the view is wide to be ellipsized
    -     * instead of broken in the middle. Use `null` to turn off ellipsizing.
    -     *
    -     *
    -     * Note: Method does nothing for [TruncateAt.MARQUEE]
    -     * ellipsizing type.
    -     *
    -     * @param where part of text to ellipsize
    -     */
    -    override fun setEllipsize(where: TruncateAt?) {
    -        if (where == null) {
    -            ellipsizeStrategy = EllipsizeNoneStrategy()
    -            return
    -        }
    -        ellipsizeStrategy = when (where) {
    -            TruncateAt.END     -> EllipsizeEndStrategy()
    -            TruncateAt.START   -> EllipsizeStartStrategy()
    -            TruncateAt.MIDDLE  -> EllipsizeMiddleStrategy()
    -            TruncateAt.MARQUEE -> EllipsizeNoneStrategy()
    -            else               -> EllipsizeNoneStrategy()
    -        }
    -    }
    -
    -    /**
    -     * A listener that notifies when the ellipsize state has changed.
    -     */
    -    interface EllipsizeListener {
    -        fun ellipsizeStateChanged(ellipsized: Boolean)
    -    }
    -
    -    /**
    -     * A base class for an ellipsize strategy.
    -     */
    -    private abstract inner class EllipsizeStrategy {
    -        /**
    -         * Returns ellipsized text if the text does not fit inside of the layout;
    -         * otherwise, returns the full text.
    -         *
    -         * @param text text to process
    -         * @return Ellipsized text if the text does not fit inside of the layout;
    -         * otherwise, returns the full text.
    -         */
    -        fun processText(text: CharSequence?): CharSequence? {
    -            return if (!isInLayout(text)) createEllipsizedText(text) else text
    -        }
    -
    -        /**
    -         * Determines if the text fits inside of the layout.
    -         *
    -         * @param text text to fit
    -         * @return `true` if the text fits inside of the layout;
    -         * otherwise, returns `false`.
    -         */
    -        fun isInLayout(text: CharSequence?): Boolean {
    -            val layout = createWorkingLayout(text)
    -            return layout.lineCount <= linesCount
    -        }
    -
    -        /**
    -         * Creates a working layout with the given text.
    -         *
    -         * @param workingText text to create layout with
    -         * @return [android.text.Layout] with the given text.
    -         */
    -        @Suppress("DEPRECATION")
    -        protected fun createWorkingLayout(workingText: CharSequence?): Layout {
    -            return StaticLayout(
    -                    workingText ?: "",
    -                    paint,
    -                    width - compoundPaddingLeft - compoundPaddingRight,
    -                    Layout.Alignment.ALIGN_NORMAL,
    -                    lineSpacingMult,
    -                    lineAddVertPad,
    -                    false
    -            )
    -        }
    -
    -        /**
    -         * Get how many lines of text we are allowed to display.
    -         */
    -        protected val linesCount: Int
    -            get() = if (ellipsizingLastFullyVisibleLine()) {
    -                val fullyVisibleLinesCount = fullyVisibleLinesCount
    -                if (fullyVisibleLinesCount == -1) 1 else fullyVisibleLinesCount
    -            } else {
    -                maxLines
    -            }
    -
    -        /**
    -         * Get how many lines of text we can display so their full height is visible.
    -         */
    -        protected val fullyVisibleLinesCount: Int
    -            get() {
    -                val layout = createWorkingLayout("")
    -                val height = height - compoundPaddingTop - compoundPaddingBottom
    -                val lineHeight = layout.getLineBottom(0)
    -                return height / lineHeight
    -            }
    -
    -        /**
    -         * Creates ellipsized text from the given text.
    -         *
    -         * @param fullText text to ellipsize
    -         * @return Ellipsized text
    -         */
    -        protected abstract fun createEllipsizedText(fullText: CharSequence?): CharSequence?
    -    }
    -
    -    /**
    -     * An [EllipsizingTextView.EllipsizeStrategy] that
    -     * does not ellipsize text.
    -     */
    -    private inner class EllipsizeNoneStrategy : EllipsizeStrategy() {
    -        override fun createEllipsizedText(fullText: CharSequence?): CharSequence? {
    -            return fullText
    -        }
    -    }
    -
    -    /**
    -     * An [EllipsizingTextView.EllipsizeStrategy] that
    -     * ellipsizes text at the end.
    -     */
    -    private inner class EllipsizeEndStrategy : EllipsizeStrategy() {
    -        override fun createEllipsizedText(fullText: CharSequence?): CharSequence? {
    -            val layout = createWorkingLayout(fullText)
    -            val cutOffIndex = try {
    -                layout.getLineEnd(maxLines - 1)
    -            } catch (exception: IndexOutOfBoundsException) {
    -                // Not sure to understand why this is happening
    -                Timber.e(exception, "IndexOutOfBoundsException, maxLine: $maxLines")
    -                0
    -            }
    -            val textLength = fullText!!.length
    -            var cutOffLength = textLength - cutOffIndex
    -            if (cutOffLength < ELLIPSIS.length) cutOffLength = ELLIPSIS.length
    -            var workingText: CharSequence = substring(fullText, 0, textLength - cutOffLength).trim()
    -            while (!isInLayout(concat(stripEndPunctuation(workingText), ELLIPSIS))) {
    -                val lastSpace = lastIndexOf(workingText, ' ')
    -                if (lastSpace == -1) {
    -                    break
    -                }
    -                workingText = substring(workingText, 0, lastSpace).trim()
    -            }
    -            workingText = concat(stripEndPunctuation(workingText), ELLIPSIS)
    -            val dest = SpannableStringBuilder(workingText)
    -            if (fullText is Spanned) {
    -                copySpansFrom(fullText as Spanned?, 0, workingText.length, null, dest, 0)
    -            }
    -            return dest
    -        }
    -
    -        /**
    -         * Strips the end punctuation from a given text according to [.mEndPunctPattern].
    -         *
    -         * @param workingText text to strip end punctuation from
    -         * @return Text without end punctuation.
    -         */
    -        fun stripEndPunctuation(workingText: CharSequence): String {
    -            return mEndPunctPattern!!.matcher(workingText).replaceFirst("")
    -        }
    -    }
    -
    -    /**
    -     * An [EllipsizingTextView.EllipsizeStrategy] that
    -     * ellipsizes text at the start.
    -     */
    -    private inner class EllipsizeStartStrategy : EllipsizeStrategy() {
    -        override fun createEllipsizedText(fullText: CharSequence?): CharSequence? {
    -            val layout = createWorkingLayout(fullText)
    -            val cutOffIndex = layout.getLineEnd(maxLines - 1)
    -            val textLength = fullText!!.length
    -            var cutOffLength = textLength - cutOffIndex
    -            if (cutOffLength < ELLIPSIS.length) cutOffLength = ELLIPSIS.length
    -            var workingText: CharSequence = substring(fullText, cutOffLength, textLength).trim()
    -            while (!isInLayout(concat(ELLIPSIS, workingText))) {
    -                val firstSpace = indexOf(workingText, ' ')
    -                if (firstSpace == -1) {
    -                    break
    -                }
    -                workingText = substring(workingText, firstSpace, workingText.length).trim()
    -            }
    -            workingText = concat(ELLIPSIS, workingText)
    -            val dest = SpannableStringBuilder(workingText)
    -            if (fullText is Spanned) {
    -                copySpansFrom(fullText as Spanned?, textLength - workingText.length,
    -                        textLength, null, dest, 0)
    -            }
    -            return dest
    -        }
    -    }
    -
    -    /**
    -     * An [EllipsizingTextView.EllipsizeStrategy] that
    -     * ellipsizes text in the middle.
    -     */
    -    private inner class EllipsizeMiddleStrategy : EllipsizeStrategy() {
    -        override fun createEllipsizedText(fullText: CharSequence?): CharSequence? {
    -            val layout = createWorkingLayout(fullText)
    -            val cutOffIndex = layout.getLineEnd(maxLines - 1)
    -            val textLength = fullText!!.length
    -            var cutOffLength = textLength - cutOffIndex
    -            if (cutOffLength < ELLIPSIS.length) cutOffLength = ELLIPSIS.length
    -            cutOffLength += cutOffIndex % 2 // Make it even.
    -            var firstPart = substring(
    -                    fullText, 0, textLength / 2 - cutOffLength / 2).trim()
    -            var secondPart = substring(
    -                    fullText, textLength / 2 + cutOffLength / 2, textLength).trim()
    -            while (!isInLayout(concat(firstPart, ELLIPSIS, secondPart))) {
    -                val lastSpaceFirstPart = firstPart.lastIndexOf(' ')
    -                val firstSpaceSecondPart = secondPart.indexOf(' ')
    -                if (lastSpaceFirstPart == -1 || firstSpaceSecondPart == -1) break
    -                firstPart = firstPart.substring(0, lastSpaceFirstPart).trim()
    -                secondPart = secondPart.substring(firstSpaceSecondPart, secondPart.length).trim()
    -            }
    -            val firstDest = SpannableStringBuilder(firstPart)
    -            val secondDest = SpannableStringBuilder(secondPart)
    -            if (fullText is Spanned) {
    -                copySpansFrom(fullText as Spanned?, 0, firstPart.length,
    -                        null, firstDest, 0)
    -                copySpansFrom(fullText as Spanned?, textLength - secondPart.length,
    -                        textLength, null, secondDest, 0)
    -            }
    -            return concat(firstDest, ELLIPSIS, secondDest)
    -        }
    -    }
    -
    -    companion object {
    -        const val ELLIPSIZE_ALPHA = 0x88
    -        private val DEFAULT_END_PUNCTUATION = Pattern.compile("[.!?,;:\u2026]*$", Pattern.DOTALL)
    -    }
    -
    -    init {
    -        val a = context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.maxLines, android.R.attr.ellipsize), defStyle, 0)
    -        maxLines = a.getInt(0, Int.MAX_VALUE)
    -        a.recycle()
    -        setEndPunctuationPattern(DEFAULT_END_PUNCTUATION)
    -        val currentTextColor = currentTextColor
    -        val ellipsizeColor = Color.argb(ELLIPSIZE_ALPHA, Color.red(currentTextColor), Color.green(currentTextColor), Color.blue(currentTextColor))
    -        ELLIPSIS.setSpan(ForegroundColorSpan(ellipsizeColor), 0, ELLIPSIS.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    -    }
    -}
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt b/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt
    index b8587750a3..99c158252f 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/MaxHeightScrollView.kt
    @@ -1,5 +1,5 @@
     /*
    - * Copyright 2019 New Vector Ltd
    + * Copyright 2020 New Vector Ltd
      *
      * Licensed under the Apache License, Version 2.0 (the "License");
      * you may not use this file except in compliance with the License.
    @@ -18,6 +18,7 @@ package im.vector.riotx.core.platform
     
     import android.content.Context
     import android.util.AttributeSet
    +import androidx.core.content.withStyledAttributes
     import androidx.core.widget.NestedScrollView
     import im.vector.riotx.R
     
    @@ -34,9 +35,9 @@ class MaxHeightScrollView @JvmOverloads constructor(context: Context, attrs: Att
     
         init {
             if (attrs != null) {
    -            val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView)
    -            maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.MaxHeightScrollView_maxHeight, DEFAULT_MAX_HEIGHT)
    -            styledAttrs.recycle()
    +            context.withStyledAttributes(attrs, R.styleable.MaxHeightScrollView) {
    +                maxHeight = getDimensionPixelSize(R.styleable.MaxHeightScrollView_maxHeight, DEFAULT_MAX_HEIGHT)
    +            }
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    index bdd873d0cd..59bf7a8aeb 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    @@ -162,9 +162,8 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
             return this
         }
     
    -    protected fun Disposable.disposeOnDestroy(): Disposable {
    +    protected fun Disposable.disposeOnDestroy() {
             uiDisposables.add(this)
    -        return this
         }
     
         override fun onCreate(savedInstanceState: Bundle?) {
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    index c0b1b54c09..f4343a3e58 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    @@ -234,9 +234,8 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), HasScreenInjector {
     
         private val uiDisposables = CompositeDisposable()
     
    -    protected fun Disposable.disposeOnDestroyView(): Disposable {
    +    protected fun Disposable.disposeOnDestroyView() {
             uiDisposables.add(this)
    -        return this
         }
     
         /* ==========================================================================================
    diff --git a/vector/src/main/java/im/vector/riotx/core/preference/VectorListPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/VectorListPreference.kt
    index d85d343155..174c52d831 100644
    --- a/vector/src/main/java/im/vector/riotx/core/preference/VectorListPreference.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/preference/VectorListPreference.kt
    @@ -90,8 +90,6 @@ class VectorListPreference : ListPreference {
         fun setWarningIconVisible(isVisible: Boolean) {
             mIsWarningIconVisible = isVisible
     
    -        if (null != mWarningIconView) {
    -            mWarningIconView!!.visibility = if (mIsWarningIconVisible) View.VISIBLE else View.GONE
    -        }
    +        mWarningIconView?.visibility = if (mIsWarningIconVisible) View.VISIBLE else View.GONE
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/BottomSheetActionButton.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/BottomSheetActionButton.kt
    index d29982c9e4..455e856833 100644
    --- a/vector/src/main/java/im/vector/riotx/core/ui/views/BottomSheetActionButton.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/BottomSheetActionButton.kt
    @@ -25,6 +25,7 @@ import android.view.View
     import android.widget.FrameLayout
     import android.widget.ImageView
     import android.widget.TextView
    +import androidx.core.content.withStyledAttributes
     import androidx.core.view.isGone
     import androidx.core.view.isInvisible
     import androidx.core.view.isVisible
    @@ -117,16 +118,15 @@ class BottomSheetActionButton @JvmOverloads constructor(
             inflate(context, R.layout.item_verification_action, this)
             ButterKnife.bind(this)
     
    -        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BottomSheetActionButton, 0, 0)
    -        title = typedArray.getString(R.styleable.BottomSheetActionButton_actionTitle) ?: ""
    -        subTitle = typedArray.getString(R.styleable.BottomSheetActionButton_actionDescription) ?: ""
    -        forceStartPadding = typedArray.getBoolean(R.styleable.BottomSheetActionButton_forceStartPadding, false)
    -        leftIcon = typedArray.getDrawable(R.styleable.BottomSheetActionButton_leftIcon)
    +        context.withStyledAttributes(attrs, R.styleable.BottomSheetActionButton) {
    +            title = getString(R.styleable.BottomSheetActionButton_actionTitle) ?: ""
    +            subTitle = getString(R.styleable.BottomSheetActionButton_actionDescription) ?: ""
    +            forceStartPadding = getBoolean(R.styleable.BottomSheetActionButton_forceStartPadding, false)
    +            leftIcon = getDrawable(R.styleable.BottomSheetActionButton_leftIcon)
     
    -        rightIcon = typedArray.getDrawable(R.styleable.BottomSheetActionButton_rightIcon)
    +            rightIcon = getDrawable(R.styleable.BottomSheetActionButton_rightIcon)
     
    -        tint = typedArray.getColor(R.styleable.BottomSheetActionButton_tint, ThemeUtils.getColor(context, android.R.attr.textColor))
    -
    -        typedArray.recycle()
    +            tint = getColor(R.styleable.BottomSheetActionButton_tint, ThemeUtils.getColor(context, android.R.attr.textColor))
    +        }
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt
    index 817575d91a..0152f7c2a8 100755
    --- a/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt
    @@ -17,16 +17,13 @@
     package im.vector.riotx.core.ui.views
     
     import android.content.Context
    -import androidx.preference.PreferenceManager
     import android.util.AttributeSet
     import android.view.View
    -import android.view.ViewGroup
    -import android.widget.AbsListView
     import android.widget.TextView
     import androidx.constraintlayout.widget.ConstraintLayout
     import androidx.core.content.edit
     import androidx.core.view.isVisible
    -import androidx.transition.TransitionManager
    +import androidx.preference.PreferenceManager
     import butterknife.BindView
     import butterknife.ButterKnife
     import butterknife.OnClick
    @@ -58,22 +55,12 @@ class KeysBackupBanner @JvmOverloads constructor(
         var delegate: Delegate? = null
         private var state: State = State.Initial
     
    -    private var scrollState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE
    -        set(value) {
    -            field = value
    -
    -            val pendingV = pendingVisibility
    -
    -            if (pendingV != null) {
    -                pendingVisibility = null
    -                visibility = pendingV
    -            }
    -        }
    -
    -    private var pendingVisibility: Int? = null
    -
         init {
             setupView()
    +        PreferenceManager.getDefaultSharedPreferences(context).edit {
    +            putBoolean(BANNER_SETUP_DO_NOT_SHOW_AGAIN, false)
    +            putString(BANNER_RECOVER_DO_NOT_SHOW_FOR_VERSION, "")
    +        }
         }
     
         /**
    @@ -91,7 +78,6 @@ class KeysBackupBanner @JvmOverloads constructor(
             state = newState
     
             hideAll()
    -
             when (newState) {
                 State.Initial    -> renderInitial()
                 State.Hidden     -> renderHidden()
    @@ -102,22 +88,6 @@ class KeysBackupBanner @JvmOverloads constructor(
             }
         }
     
    -    override fun setVisibility(visibility: Int) {
    -        if (scrollState != AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
    -            // Wait for scroll state to be idle
    -            pendingVisibility = visibility
    -            return
    -        }
    -
    -        if (visibility != getVisibility()) {
    -            // Schedule animation
    -            val parent = parent as ViewGroup
    -            TransitionManager.beginDelayedTransition(parent)
    -        }
    -
    -        super.setVisibility(visibility)
    -    }
    -
         override fun onClick(v: View?) {
             when (state) {
                 is State.Setup   -> {
    @@ -166,6 +136,8 @@ class KeysBackupBanner @JvmOverloads constructor(
             ButterKnife.bind(this)
     
             setOnClickListener(this)
    +        textView1.setOnClickListener(this)
    +        textView2.setOnClickListener(this)
         }
     
         private fun renderInitial() {
    @@ -184,9 +156,9 @@ class KeysBackupBanner @JvmOverloads constructor(
             } else {
                 isVisible = true
     
    -            textView1.setText(R.string.keys_backup_banner_setup_line1)
    +            textView1.setText(R.string.secure_backup_banner_setup_line1)
                 textView2.isVisible = true
    -            textView2.setText(R.string.keys_backup_banner_setup_line2)
    +            textView2.setText(R.string.secure_backup_banner_setup_line2)
                 close.isVisible = true
             }
         }
    @@ -218,10 +190,10 @@ class KeysBackupBanner @JvmOverloads constructor(
         }
     
         private fun renderBackingUp() {
    -        // Do not render when backing up anymore
    -        isVisible = false
    -
    -        textView1.setText(R.string.keys_backup_banner_in_progress)
    +        isVisible = true
    +        textView1.setText(R.string.secure_backup_banner_setup_line1)
    +        textView2.isVisible = true
    +        textView2.setText(R.string.keys_backup_banner_in_progress)
             loading.isVisible = true
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/DataSource.kt b/vector/src/main/java/im/vector/riotx/core/utils/DataSource.kt
    index 4c4a553e5c..6f6057cb43 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/DataSource.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/DataSource.kt
    @@ -36,6 +36,9 @@ open class BehaviorDataSource(private val defaultValue: T? = null) : MutableD
     
         private val behaviorRelay = createRelay()
     
    +    val currentValue: T?
    +        get() = behaviorRelay.value
    +
         override fun observe(): Observable {
             return behaviorRelay.hide().observeOn(AndroidSchedulers.mainThread())
         }
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
    index 2520f44f50..9c2d12514a 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
    @@ -424,6 +424,33 @@ fun openPlayStore(activity: Activity, appId: String = BuildConfig.APPLICATION_ID
         }
     }
     
    +/**
    + * Ask the user to select a location and a file name to write in
    + */
    +fun selectTxtFileToWrite(
    +        activity: Activity,
    +        fragment: Fragment?,
    +        defaultFileName: String,
    +        chooserHint: String,
    +        requestCode: Int
    +) {
    +    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
    +    intent.addCategory(Intent.CATEGORY_OPENABLE)
    +    intent.type = "text/plain"
    +    intent.putExtra(Intent.EXTRA_TITLE, defaultFileName)
    +
    +    try {
    +        val chooserIntent = Intent.createChooser(intent, chooserHint)
    +        if (fragment != null) {
    +            fragment.startActivityForResult(chooserIntent, requestCode)
    +        } else {
    +            activity.startActivityForResult(chooserIntent, requestCode)
    +        }
    +    } catch (activityNotFoundException: ActivityNotFoundException) {
    +        activity.toast(R.string.error_no_external_application_found)
    +    }
    +}
    +
     // ==============================================================================================================
     // Media utils
     // ==============================================================================================================
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt
    index 4790b26ad0..6f081d52de 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt
    @@ -63,12 +63,12 @@ const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA = 569
     const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA = 570
     const val PERMISSION_REQUEST_CODE_AUDIO_CALL = 571
     const val PERMISSION_REQUEST_CODE_VIDEO_CALL = 572
    -const val PERMISSION_REQUEST_CODE_EXPORT_KEYS = 573
     const val PERMISSION_REQUEST_CODE_CHANGE_AVATAR = 574
     const val PERMISSION_REQUEST_CODE_DOWNLOAD_FILE = 575
     const val PERMISSION_REQUEST_CODE_PICK_ATTACHMENT = 576
     const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577
     const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578
    +const val PERMISSION_REQUEST_CODE_READ_CONTACTS = 579
     
     /**
      * Log the used permissions statuses.
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    index 9e5af038ef..900d5565dc 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    @@ -162,7 +162,7 @@ fun startImportTextFromFileIntent(fragment: Fragment, requestCode: Int) {
         val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
             type = "text/plain"
         }
    -    if (intent.resolveActivity(fragment.activity!!.packageManager) != null) {
    +    if (intent.resolveActivity(fragment.requireActivity().packageManager) != null) {
             fragment.startActivityForResult(intent, requestCode)
         } else {
             fragment.activity?.toast(R.string.error_no_external_application_found)
    diff --git a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt
    index 05f14ae4f2..070375d201 100644
    --- a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt
    @@ -22,6 +22,7 @@ import android.os.Build
     import androidx.annotation.RequiresApi
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.extensions.tryThis
    +import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.call.CallState
     import im.vector.matrix.android.api.session.call.CallsListener
     import im.vector.matrix.android.api.session.call.EglUtils
    @@ -31,7 +32,7 @@ import im.vector.matrix.android.api.session.room.model.call.CallAnswerContent
     import im.vector.matrix.android.api.session.room.model.call.CallCandidatesContent
     import im.vector.matrix.android.api.session.room.model.call.CallHangupContent
     import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
    -import im.vector.riotx.core.di.ActiveSessionHolder
    +import im.vector.riotx.ActiveSessionDataSource
     import im.vector.riotx.core.services.BluetoothHeadsetReceiver
     import im.vector.riotx.core.services.CallService
     import im.vector.riotx.core.services.WiredHeadsetStateReceiver
    @@ -71,9 +72,12 @@ import javax.inject.Singleton
     @Singleton
     class WebRtcPeerConnectionManager @Inject constructor(
             private val context: Context,
    -        private val sessionHolder: ActiveSessionHolder
    +        private val activeSessionDataSource: ActiveSessionDataSource
     ) : CallsListener {
     
    +    private val currentSession: Session?
    +        get() = activeSessionDataSource.currentValue?.orNull()
    +
         interface CurrentCallListener {
             fun onCurrentCallChange(call: MxCall?)
             fun onCaptureStateChanged(mgr: WebRtcPeerConnectionManager) {}
    @@ -288,15 +292,16 @@ class WebRtcPeerConnectionManager @Inject constructor(
         }
     
         private fun getTurnServer(callback: ((TurnServerResponse?) -> Unit)) {
    -        sessionHolder.getActiveSession().callSignalingService().getTurnServer(object : MatrixCallback {
    -            override fun onSuccess(data: TurnServerResponse?) {
    -                callback(data)
    -            }
    +        currentSession?.callSignalingService()
    +                ?.getTurnServer(object : MatrixCallback {
    +                    override fun onSuccess(data: TurnServerResponse?) {
    +                        callback(data)
    +                    }
     
    -            override fun onFailure(failure: Throwable) {
    -                callback(null)
    -            }
    -        })
    +                    override fun onFailure(failure: Throwable) {
    +                        callback(null)
    +                    }
    +                })
         }
     
         fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) {
    @@ -310,7 +315,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
             currentCall?.mxCall
                     ?.takeIf { it.state is CallState.Connected }
                     ?.let { mxCall ->
    -                    val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName()
    +                    val name = currentSession?.getUser(mxCall.otherUserId)?.getBestName()
                                 ?: mxCall.roomId
                         // Start background service with notification
                         CallService.onPendingCall(
    @@ -318,7 +323,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
                                 isVideo = mxCall.isVideoCall,
                                 roomName = name,
                                 roomId = mxCall.roomId,
    -                            matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                            matrixId = currentSession?.myUserId ?: "",
                                 callId = mxCall.callId)
                     }
     
    @@ -373,14 +378,14 @@ class WebRtcPeerConnectionManager @Inject constructor(
             val mxCall = callContext.mxCall
             // Update service state
     
    -        val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName()
    +        val name = currentSession?.getUser(mxCall.otherUserId)?.getBestName()
                     ?: mxCall.roomId
             CallService.onPendingCall(
                     context = context,
                     isVideo = mxCall.isVideoCall,
                     roomName = name,
                     roomId = mxCall.roomId,
    -                matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                matrixId = currentSession?.myUserId ?: "",
                     callId = mxCall.callId
             )
             executor.execute {
    @@ -563,14 +568,14 @@ class WebRtcPeerConnectionManager @Inject constructor(
                         ?.let { mxCall ->
                             // Start background service with notification
     
    -                        val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName()
    +                        val name = currentSession?.getUser(mxCall.otherUserId)?.getBestName()
                                     ?: mxCall.otherUserId
                             CallService.onOnGoingCallBackground(
                                     context = context,
                                     isVideo = mxCall.isVideoCall,
                                     roomName = name,
                                     roomId = mxCall.roomId,
    -                                matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                                matrixId = currentSession?.myUserId ?: "",
                                     callId = mxCall.callId
                             )
                         }
    @@ -631,20 +636,20 @@ class WebRtcPeerConnectionManager @Inject constructor(
             }
     
             Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall")
    -        val createdCall = sessionHolder.getSafeActiveSession()?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
    +        val createdCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
             val callContext = CallContext(createdCall)
     
             audioManager.startForCall(createdCall)
             currentCall = callContext
     
    -        val name = sessionHolder.getSafeActiveSession()?.getUser(createdCall.otherUserId)?.getBestName()
    +        val name = currentSession?.getUser(createdCall.otherUserId)?.getBestName()
                     ?: createdCall.otherUserId
             CallService.onOutgoingCallRinging(
                     context = context.applicationContext,
                     isVideo = createdCall.isVideoCall,
                     roomName = name,
                     roomId = createdCall.roomId,
    -                matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                matrixId = currentSession?.myUserId ?: "",
                     callId = createdCall.callId)
     
             executor.execute {
    @@ -693,14 +698,14 @@ class WebRtcPeerConnectionManager @Inject constructor(
             }
     
             // Start background service with notification
    -        val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName()
    +        val name = currentSession?.getUser(mxCall.otherUserId)?.getBestName()
                     ?: mxCall.otherUserId
             CallService.onIncomingCallRinging(
                     context = context,
                     isVideo = mxCall.isVideoCall,
                     roomName = name,
                     roomId = mxCall.roomId,
    -                matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                matrixId = currentSession?.myUserId ?: "",
                     callId = mxCall.callId
             )
     
    @@ -818,14 +823,14 @@ class WebRtcPeerConnectionManager @Inject constructor(
             }
             val mxCall = call.mxCall
             // Update service state
    -        val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName()
    +        val name = currentSession?.getUser(mxCall.otherUserId)?.getBestName()
                     ?: mxCall.otherUserId
             CallService.onPendingCall(
                     context = context,
                     isVideo = mxCall.isVideoCall,
                     roomName = name,
                     roomId = mxCall.roomId,
    -                matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "",
    +                matrixId = currentSession?.myUserId ?: "",
                     callId = mxCall.callId
             )
             executor.execute {
    diff --git a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt
    index 7c32a34aff..2b38a1ac25 100644
    --- a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt
    @@ -17,6 +17,9 @@
     package im.vector.riotx.features.command
     
     import im.vector.matrix.android.api.MatrixPatterns
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +import im.vector.riotx.core.extensions.isEmail
    +import im.vector.riotx.core.extensions.isMsisdn
     import timber.log.Timber
     
     object CommandParser {
    @@ -139,15 +142,24 @@ object CommandParser {
                         if (messageParts.size >= 2) {
                             val userId = messageParts[1]
     
    -                        if (MatrixPatterns.isUserId(userId)) {
    -                            ParsedCommand.Invite(
    -                                    userId,
    -                                    textMessage.substring(Command.INVITE.length + userId.length)
    -                                            .trim()
    -                                            .takeIf { it.isNotBlank() }
    -                            )
    -                        } else {
    -                            ParsedCommand.ErrorSyntax(Command.INVITE)
    +                        when {
    +                            MatrixPatterns.isUserId(userId) -> {
    +                                ParsedCommand.Invite(
    +                                        userId,
    +                                        textMessage.substring(Command.INVITE.length + userId.length)
    +                                                .trim()
    +                                                .takeIf { it.isNotBlank() }
    +                                )
    +                            }
    +                            userId.isEmail()                -> {
    +                                ParsedCommand.Invite3Pid(ThreePid.Email(userId))
    +                            }
    +                            userId.isMsisdn()               -> {
    +                                ParsedCommand.Invite3Pid(ThreePid.Msisdn(userId))
    +                            }
    +                            else                            -> {
    +                                ParsedCommand.ErrorSyntax(Command.INVITE)
    +                            }
                             }
                         } else {
                             ParsedCommand.ErrorSyntax(Command.INVITE)
    diff --git a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt
    index 44ad2265e1..041da3dcac 100644
    --- a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt
    @@ -16,6 +16,8 @@
     
     package im.vector.riotx.features.command
     
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +
     /**
      * Represent a parsed command
      */
    @@ -41,6 +43,7 @@ sealed class ParsedCommand {
         class UnbanUser(val userId: String, val reason: String?) : ParsedCommand()
         class SetUserPowerLevel(val userId: String, val powerLevel: Int?) : ParsedCommand()
         class Invite(val userId: String, val reason: String?) : ParsedCommand()
    +    class Invite3Pid(val threePid: ThreePid) : ParsedCommand()
         class JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
         class PartRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
         class ChangeTopic(val topic: String) : ParsedCommand()
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt
    new file mode 100644
    index 0000000000..8615838571
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt
    @@ -0,0 +1,47 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.contactsbook
    +
    +import android.widget.TextView
    +import com.airbnb.epoxy.EpoxyAttribute
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
    +import im.vector.riotx.core.epoxy.ClickListener
    +import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    +import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.epoxy.onClick
    +import im.vector.riotx.core.extensions.setTextOrHide
    +
    +@EpoxyModelClass(layout = R.layout.item_contact_detail)
    +abstract class ContactDetailItem : VectorEpoxyModel() {
    +
    +    @EpoxyAttribute lateinit var threePid: String
    +    @EpoxyAttribute var matrixId: String? = null
    +    @EpoxyAttribute var clickListener: ClickListener? = null
    +
    +    override fun bind(holder: Holder) {
    +        super.bind(holder)
    +        holder.view.onClick(clickListener)
    +        holder.nameView.text = threePid
    +        holder.matrixIdView.setTextOrHide(matrixId)
    +    }
    +
    +    class Holder : VectorEpoxyHolder() {
    +        val nameView by bind(R.id.contactDetailName)
    +        val matrixIdView by bind(R.id.contactDetailMatrixId)
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt
    new file mode 100644
    index 0000000000..9a6bf8f144
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt
    @@ -0,0 +1,46 @@
    +/*
    + * 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.contactsbook
    +
    +import android.widget.ImageView
    +import android.widget.TextView
    +import com.airbnb.epoxy.EpoxyAttribute
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
    +import im.vector.riotx.core.contacts.MappedContact
    +import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    +import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.features.home.AvatarRenderer
    +
    +@EpoxyModelClass(layout = R.layout.item_contact_main)
    +abstract class ContactItem : VectorEpoxyModel() {
    +
    +    @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    +    @EpoxyAttribute lateinit var mappedContact: MappedContact
    +
    +    override fun bind(holder: Holder) {
    +        super.bind(holder)
    +        // If name is empty, use userId as name and force it being centered
    +        holder.nameView.text = mappedContact.displayName
    +        avatarRenderer.render(mappedContact, holder.avatarImageView)
    +    }
    +
    +    class Holder : VectorEpoxyHolder() {
    +        val nameView by bind(R.id.contactDisplayName)
    +        val avatarImageView by bind(R.id.contactAvatar)
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt
    new file mode 100644
    index 0000000000..001630d398
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt
    @@ -0,0 +1,24 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.contactsbook
    +
    +import im.vector.riotx.core.platform.VectorViewModelAction
    +
    +sealed class ContactsBookAction : VectorViewModelAction {
    +    data class FilterWith(val filter: String) : ContactsBookAction()
    +    data class OnlyBoundContacts(val onlyBoundContacts: Boolean) : ContactsBookAction()
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt
    new file mode 100644
    index 0000000000..796ed0d80c
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt
    @@ -0,0 +1,148 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.contactsbook
    +
    +import com.airbnb.epoxy.EpoxyController
    +import com.airbnb.mvrx.Fail
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.Success
    +import com.airbnb.mvrx.Uninitialized
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +import im.vector.riotx.R
    +import im.vector.riotx.core.contacts.MappedContact
    +import im.vector.riotx.core.epoxy.errorWithRetryItem
    +import im.vector.riotx.core.epoxy.loadingItem
    +import im.vector.riotx.core.epoxy.noResultItem
    +import im.vector.riotx.core.error.ErrorFormatter
    +import im.vector.riotx.core.resources.StringProvider
    +import im.vector.riotx.features.home.AvatarRenderer
    +import javax.inject.Inject
    +
    +class ContactsBookController @Inject constructor(
    +        private val stringProvider: StringProvider,
    +        private val avatarRenderer: AvatarRenderer,
    +        private val errorFormatter: ErrorFormatter) : EpoxyController() {
    +
    +    private var state: ContactsBookViewState? = null
    +
    +    var callback: Callback? = null
    +
    +    init {
    +        requestModelBuild()
    +    }
    +
    +    fun setData(state: ContactsBookViewState) {
    +        this.state = state
    +        requestModelBuild()
    +    }
    +
    +    override fun buildModels() {
    +        val currentState = state ?: return
    +        val hasSearch = currentState.searchTerm.isNotEmpty()
    +        when (val asyncMappedContacts = currentState.mappedContacts) {
    +            is Uninitialized -> renderEmptyState(false)
    +            is Loading       -> renderLoading()
    +            is Success       -> renderSuccess(currentState.filteredMappedContacts, hasSearch, currentState.onlyBoundContacts)
    +            is Fail          -> renderFailure(asyncMappedContacts.error)
    +        }
    +    }
    +
    +    private fun renderLoading() {
    +        loadingItem {
    +            id("loading")
    +            loadingText(stringProvider.getString(R.string.loading_contact_book))
    +        }
    +    }
    +
    +    private fun renderFailure(failure: Throwable) {
    +        errorWithRetryItem {
    +            id("error")
    +            text(errorFormatter.toHumanReadable(failure))
    +        }
    +    }
    +
    +    private fun renderSuccess(mappedContacts: List,
    +                              hasSearch: Boolean,
    +                              onlyBoundContacts: Boolean) {
    +        if (mappedContacts.isEmpty()) {
    +            renderEmptyState(hasSearch)
    +        } else {
    +            renderContacts(mappedContacts, onlyBoundContacts)
    +        }
    +    }
    +
    +    private fun renderContacts(mappedContacts: List, onlyBoundContacts: Boolean) {
    +        for (mappedContact in mappedContacts) {
    +            contactItem {
    +                id(mappedContact.id)
    +                mappedContact(mappedContact)
    +                avatarRenderer(avatarRenderer)
    +            }
    +            mappedContact.emails
    +                    .forEachIndexed { index, it ->
    +                        if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed
    +
    +                        contactDetailItem {
    +                            id("${mappedContact.id}-e-$index-${it.email}")
    +                            threePid(it.email)
    +                            matrixId(it.matrixId)
    +                            clickListener {
    +                                if (it.matrixId != null) {
    +                                    callback?.onMatrixIdClick(it.matrixId)
    +                                } else {
    +                                    callback?.onThreePidClick(ThreePid.Email(it.email))
    +                                }
    +                            }
    +                        }
    +                    }
    +            mappedContact.msisdns
    +                    .forEachIndexed { index, it ->
    +                        if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed
    +
    +                        contactDetailItem {
    +                            id("${mappedContact.id}-m-$index-${it.phoneNumber}")
    +                            threePid(it.phoneNumber)
    +                            matrixId(it.matrixId)
    +                            clickListener {
    +                                if (it.matrixId != null) {
    +                                    callback?.onMatrixIdClick(it.matrixId)
    +                                } else {
    +                                    callback?.onThreePidClick(ThreePid.Msisdn(it.phoneNumber))
    +                                }
    +                            }
    +                        }
    +                    }
    +        }
    +    }
    +
    +    private fun renderEmptyState(hasSearch: Boolean) {
    +        val noResultRes = if (hasSearch) {
    +            R.string.no_result_placeholder
    +        } else {
    +            R.string.empty_contact_book
    +        }
    +        noResultItem {
    +            id("noResult")
    +            text(stringProvider.getString(noResultRes))
    +        }
    +    }
    +
    +    interface Callback {
    +        fun onMatrixIdClick(matrixId: String)
    +        fun onThreePidClick(threePid: ThreePid)
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt
    new file mode 100644
    index 0000000000..2a2fd9fb5d
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt
    @@ -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.features.contactsbook
    +
    +import android.os.Bundle
    +import android.view.View
    +import androidx.core.view.isVisible
    +import com.airbnb.mvrx.activityViewModel
    +import com.airbnb.mvrx.withState
    +import com.jakewharton.rxbinding3.widget.checkedChanges
    +import com.jakewharton.rxbinding3.widget.textChanges
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +import im.vector.matrix.android.api.session.user.model.User
    +import im.vector.riotx.R
    +import im.vector.riotx.core.extensions.cleanup
    +import im.vector.riotx.core.extensions.configureWith
    +import im.vector.riotx.core.extensions.hideKeyboard
    +import im.vector.riotx.core.platform.VectorBaseFragment
    +import im.vector.riotx.features.userdirectory.PendingInvitee
    +import im.vector.riotx.features.userdirectory.UserDirectoryAction
    +import im.vector.riotx.features.userdirectory.UserDirectorySharedAction
    +import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel
    +import im.vector.riotx.features.userdirectory.UserDirectoryViewModel
    +import kotlinx.android.synthetic.main.fragment_contacts_book.*
    +import java.util.concurrent.TimeUnit
    +import javax.inject.Inject
    +
    +class ContactsBookFragment @Inject constructor(
    +        val contactsBookViewModelFactory: ContactsBookViewModel.Factory,
    +        private val contactsBookController: ContactsBookController
    +) : VectorBaseFragment(), ContactsBookController.Callback {
    +
    +    override fun getLayoutResId() = R.layout.fragment_contacts_book
    +    private val viewModel: UserDirectoryViewModel by activityViewModel()
    +
    +    // Use activityViewModel to avoid loading several times the data
    +    private val contactsBookViewModel: ContactsBookViewModel by activityViewModel()
    +
    +    private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
    +
    +    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    +        super.onViewCreated(view, savedInstanceState)
    +        sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
    +        setupRecyclerView()
    +        setupFilterView()
    +        setupOnlyBoundContactsView()
    +        setupCloseView()
    +    }
    +
    +    private fun setupOnlyBoundContactsView() {
    +        phoneBookOnlyBoundContacts.checkedChanges()
    +                .subscribe {
    +                    contactsBookViewModel.handle(ContactsBookAction.OnlyBoundContacts(it))
    +                }
    +                .disposeOnDestroyView()
    +    }
    +
    +    private fun setupFilterView() {
    +        phoneBookFilter
    +                .textChanges()
    +                .skipInitialValue()
    +                .debounce(300, TimeUnit.MILLISECONDS)
    +                .subscribe {
    +                    contactsBookViewModel.handle(ContactsBookAction.FilterWith(it.toString()))
    +                }
    +                .disposeOnDestroyView()
    +    }
    +
    +    override fun onDestroyView() {
    +        phoneBookRecyclerView.cleanup()
    +        contactsBookController.callback = null
    +        super.onDestroyView()
    +    }
    +
    +    private fun setupRecyclerView() {
    +        contactsBookController.callback = this
    +        phoneBookRecyclerView.configureWith(contactsBookController)
    +    }
    +
    +    private fun setupCloseView() {
    +        phoneBookClose.debouncedClicks {
    +            sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
    +        }
    +    }
    +
    +    override fun invalidate() = withState(contactsBookViewModel) { state ->
    +        phoneBookOnlyBoundContacts.isVisible = state.isBoundRetrieved
    +        contactsBookController.setData(state)
    +    }
    +
    +    override fun onMatrixIdClick(matrixId: String) {
    +        view?.hideKeyboard()
    +        viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId))))
    +        sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
    +    }
    +
    +    override fun onThreePidClick(threePid: ThreePid) {
    +        view?.hideKeyboard()
    +        viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid)))
    +        sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt
    new file mode 100644
    index 0000000000..3eb6b165b8
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt
    @@ -0,0 +1,192 @@
    +/*
    + * 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.contactsbook
    +
    +import androidx.fragment.app.FragmentActivity
    +import androidx.lifecycle.viewModelScope
    +import com.airbnb.mvrx.ActivityViewModelContext
    +import com.airbnb.mvrx.FragmentViewModelContext
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.MvRxViewModelFactory
    +import com.airbnb.mvrx.Success
    +import com.airbnb.mvrx.ViewModelContext
    +import com.squareup.inject.assisted.Assisted
    +import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.identity.FoundThreePid
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +import im.vector.riotx.core.contacts.ContactsDataSource
    +import im.vector.riotx.core.contacts.MappedContact
    +import im.vector.riotx.core.extensions.exhaustive
    +import im.vector.riotx.core.platform.EmptyViewEvents
    +import im.vector.riotx.core.platform.VectorViewModel
    +import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
    +import im.vector.riotx.features.invite.InviteUsersToRoomActivity
    +import kotlinx.coroutines.Dispatchers
    +import kotlinx.coroutines.launch
    +import timber.log.Timber
    +
    +private typealias PhoneBookSearch = String
    +
    +class ContactsBookViewModel @AssistedInject constructor(@Assisted
    +                                                     initialState: ContactsBookViewState,
    +                                                        private val contactsDataSource: ContactsDataSource,
    +                                                        private val session: Session)
    +    : VectorViewModel(initialState) {
    +
    +    @AssistedInject.Factory
    +    interface Factory {
    +        fun create(initialState: ContactsBookViewState): ContactsBookViewModel
    +    }
    +
    +    companion object : MvRxViewModelFactory {
    +
    +        override fun create(viewModelContext: ViewModelContext, state: ContactsBookViewState): ContactsBookViewModel? {
    +            return when (viewModelContext) {
    +                is FragmentViewModelContext -> (viewModelContext.fragment() as ContactsBookFragment).contactsBookViewModelFactory.create(state)
    +                is ActivityViewModelContext -> {
    +                    when (viewModelContext.activity()) {
    +                        is CreateDirectRoomActivity  -> viewModelContext.activity().contactsBookViewModelFactory.create(state)
    +                        is InviteUsersToRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state)
    +                        else                         -> error("Wrong activity or fragment")
    +                    }
    +                }
    +                else                        -> error("Wrong activity or fragment")
    +            }
    +        }
    +    }
    +
    +    private var allContacts: List = emptyList()
    +    private var mappedContacts: List = emptyList()
    +
    +    init {
    +        loadContacts()
    +
    +        selectSubscribe(ContactsBookViewState::searchTerm, ContactsBookViewState::onlyBoundContacts) { _, _ ->
    +            updateFilteredMappedContacts()
    +        }
    +    }
    +
    +    private fun loadContacts() {
    +        setState {
    +            copy(
    +                    mappedContacts = Loading()
    +            )
    +        }
    +
    +        viewModelScope.launch(Dispatchers.IO) {
    +            allContacts = contactsDataSource.getContacts(
    +                    withEmails = true,
    +                    // Do not handle phone numbers for the moment
    +                    withMsisdn = false
    +            )
    +            mappedContacts = allContacts
    +
    +            setState {
    +                copy(
    +                        mappedContacts = Success(allContacts)
    +                )
    +            }
    +
    +            performLookup(allContacts)
    +            updateFilteredMappedContacts()
    +        }
    +    }
    +
    +    private fun performLookup(data: List) {
    +        viewModelScope.launch {
    +            val threePids = data.flatMap { contact ->
    +                contact.emails.map { ThreePid.Email(it.email) } +
    +                        contact.msisdns.map { ThreePid.Msisdn(it.phoneNumber) }
    +            }
    +            session.identityService().lookUp(threePids, object : MatrixCallback> {
    +                override fun onFailure(failure: Throwable) {
    +                    // Ignore
    +                    Timber.w(failure, "Unable to perform the lookup")
    +                }
    +
    +                override fun onSuccess(data: List) {
    +                    mappedContacts = allContacts.map { contactModel ->
    +                        contactModel.copy(
    +                                emails = contactModel.emails.map { email ->
    +                                    email.copy(
    +                                            matrixId = data
    +                                                    .firstOrNull { foundThreePid -> foundThreePid.threePid.value == email.email }
    +                                                    ?.matrixId
    +                                    )
    +                                },
    +                                msisdns = contactModel.msisdns.map { msisdn ->
    +                                    msisdn.copy(
    +                                            matrixId = data
    +                                                    .firstOrNull { foundThreePid -> foundThreePid.threePid.value == msisdn.phoneNumber }
    +                                                    ?.matrixId
    +                                    )
    +                                }
    +                        )
    +                    }
    +
    +                    setState {
    +                        copy(
    +                                isBoundRetrieved = true
    +                        )
    +                    }
    +
    +                    updateFilteredMappedContacts()
    +                }
    +            })
    +        }
    +    }
    +
    +    private fun updateFilteredMappedContacts() = withState { state ->
    +        val filteredMappedContacts = mappedContacts
    +                .filter { it.displayName.contains(state.searchTerm, true) }
    +                .filter { contactModel ->
    +                    !state.onlyBoundContacts
    +                            || contactModel.emails.any { it.matrixId != null } || contactModel.msisdns.any { it.matrixId != null }
    +                }
    +
    +        setState {
    +            copy(
    +                    filteredMappedContacts = filteredMappedContacts
    +            )
    +        }
    +    }
    +
    +    override fun handle(action: ContactsBookAction) {
    +        when (action) {
    +            is ContactsBookAction.FilterWith        -> handleFilterWith(action)
    +            is ContactsBookAction.OnlyBoundContacts -> handleOnlyBoundContacts(action)
    +        }.exhaustive
    +    }
    +
    +    private fun handleOnlyBoundContacts(action: ContactsBookAction.OnlyBoundContacts) {
    +        setState {
    +            copy(
    +                    onlyBoundContacts = action.onlyBoundContacts
    +            )
    +        }
    +    }
    +
    +    private fun handleFilterWith(action: ContactsBookAction.FilterWith) {
    +        setState {
    +            copy(
    +                    searchTerm = action.filter
    +            )
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt
    new file mode 100644
    index 0000000000..8f59403d6a
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt
    @@ -0,0 +1,35 @@
    +/*
    + * 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.contactsbook
    +
    +import com.airbnb.mvrx.Async
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.MvRxState
    +import im.vector.riotx.core.contacts.MappedContact
    +
    +data class ContactsBookViewState(
    +        // All the contacts on the phone
    +        val mappedContacts: Async> = Loading(),
    +        // Use to filter contacts by display name
    +        val searchTerm: String = "",
    +        // Tru to display only bound contacts with their bound 2pid
    +        val onlyBoundContacts: Boolean = false,
    +        // All contacts, filtered by searchTerm and onlyBoundContacts
    +        val filteredMappedContacts: List = emptyList(),
    +        // True when the identity service has return some data
    +        val isBoundRetrieved: Boolean = false
    +) : MvRxState
    diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt
    index f995f82ff7..fad36cc281 100644
    --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt
    @@ -16,9 +16,9 @@
     
     package im.vector.riotx.features.createdirect
     
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.riotx.core.platform.VectorViewModelAction
    +import im.vector.riotx.features.userdirectory.PendingInvitee
     
     sealed class CreateDirectRoomAction : VectorViewModelAction {
    -    data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set) : CreateDirectRoomAction()
    +    data class CreateRoomAndInviteSelectedUsers(val invitees: Set) : CreateDirectRoomAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt
    index ef3e9bdeff..72244d1c94 100644
    --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt
    @@ -35,8 +35,15 @@ import im.vector.riotx.core.di.ScreenComponent
     import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.extensions.addFragment
     import im.vector.riotx.core.extensions.addFragmentToBackstack
    +import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.SimpleFragmentActivity
     import im.vector.riotx.core.platform.WaitingViewData
    +import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
    +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
    +import im.vector.riotx.core.utils.allGranted
    +import im.vector.riotx.core.utils.checkPermissions
    +import im.vector.riotx.features.contactsbook.ContactsBookFragment
    +import im.vector.riotx.features.contactsbook.ContactsBookViewModel
     import im.vector.riotx.features.userdirectory.KnownUsersFragment
     import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
     import im.vector.riotx.features.userdirectory.UserDirectoryFragment
    @@ -53,6 +60,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
         private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
         @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
         @Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory
    +    @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
         @Inject lateinit var errorFormatter: ErrorFormatter
     
         override fun injectWith(injector: ScreenComponent) {
    @@ -68,12 +76,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
                     .observe()
                     .subscribe { sharedAction ->
                         when (sharedAction) {
    -                        UserDirectorySharedAction.OpenUsersDirectory ->
    +                        UserDirectorySharedAction.OpenUsersDirectory    ->
                                 addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java)
    -                        UserDirectorySharedAction.Close           -> finish()
    -                        UserDirectorySharedAction.GoBack          -> onBackPressed()
    +                        UserDirectorySharedAction.Close                 -> finish()
    +                        UserDirectorySharedAction.GoBack                -> onBackPressed()
                             is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
    -                    }
    +                        UserDirectorySharedAction.OpenPhoneBook         -> openPhoneBook()
    +                    }.exhaustive
                     }
                     .disposeOnDestroy()
             if (isFirstCreation()) {
    @@ -91,9 +100,27 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
             }
         }
     
    +    private fun openPhoneBook() {
    +        // Check permission first
    +        if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
    +                        this,
    +                        PERMISSION_REQUEST_CODE_READ_CONTACTS,
    +                        0)) {
    +            addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
    +        }
    +    }
    +
    +    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    +        if (allGranted(grantResults)) {
    +            if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) {
    +                addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
    +            }
    +        }
    +    }
    +
         private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
             if (action.itemId == R.id.action_create_direct_room) {
    -            viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selectedUsers))
    +            viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.invitees))
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt
    index 1800759da6..319671b230 100644
    --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt
    @@ -23,9 +23,10 @@ import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.matrix.rx.rx
    +import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.VectorViewModel
    +import im.vector.riotx.features.userdirectory.PendingInvitee
     
     class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
                                                                 initialState: CreateDirectRoomViewState,
    @@ -48,16 +49,22 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
     
         override fun handle(action: CreateDirectRoomAction) {
             when (action) {
    -            is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers)
    +            is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.invitees)
             }
         }
     
    -    private fun createRoomAndInviteSelectedUsers(selectedUsers: Set) {
    -        val roomParams = CreateRoomParams(
    -                invitedUserIds = selectedUsers.map { it.userId }
    -        )
    -                .setDirectMessage()
    -                .enableEncryptionIfInvitedUsersSupportIt()
    +    private fun createRoomAndInviteSelectedUsers(invitees: Set) {
    +        val roomParams = CreateRoomParams()
    +                .apply {
    +                    invitees.forEach {
    +                        when (it) {
    +                            is PendingInvitee.UserPendingInvitee     -> invitedUserIds.add(it.user.userId)
    +                            is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid)
    +                        }.exhaustive
    +                    }
    +                    setDirectMessage()
    +                    enableEncryptionIfInvitedUsersSupportIt = true
    +                }
     
             session.rx()
                     .createRoom(roomParams)
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt
    index b9b75588f1..2467334f69 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt
    @@ -17,37 +17,34 @@
     package im.vector.riotx.features.crypto.keys
     
     import android.content.Context
    -import android.os.Environment
    +import android.net.Uri
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.internal.extensions.foldToCallback
     import im.vector.matrix.android.internal.util.awaitCallback
    -import im.vector.riotx.core.files.addEntryToDownloadManager
    -import im.vector.riotx.core.files.writeToFile
     import kotlinx.coroutines.Dispatchers
     import kotlinx.coroutines.GlobalScope
     import kotlinx.coroutines.launch
     import kotlinx.coroutines.withContext
    -import java.io.File
     
     class KeysExporter(private val session: Session) {
     
         /**
          * Export keys and return the file path with the callback
          */
    -    fun export(context: Context, password: String, callback: MatrixCallback) {
    +    fun export(context: Context, password: String, uri: Uri, callback: MatrixCallback) {
             GlobalScope.launch(Dispatchers.Main) {
                 runCatching {
    -                val data = awaitCallback { session.cryptoService().exportRoomKeys(password, it) }
                     withContext(Dispatchers.IO) {
    -                    val parentDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
    -                    val file = File(parentDir, "riotx-keys-" + System.currentTimeMillis() + ".txt")
    -
    -                    writeToFile(data, file)
    -
    -                    addEntryToDownloadManager(context, file, "text/plain")
    -
    -                    file.absolutePath
    +                    val data = awaitCallback { session.cryptoService().exportRoomKeys(password, it) }
    +                    val os = context.contentResolver?.openOutputStream(uri)
    +                    if (os == null) {
    +                        false
    +                    } else {
    +                        os.write(data)
    +                        os.flush()
    +                        true
    +                    }
                     }
                 }.foldToCallback(callback)
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    index faada7ba3e..2faff3d112 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    @@ -49,7 +49,7 @@ class KeysBackupRestoreFromKeyViewModel @Inject constructor(
             viewModelScope.launch(Dispatchers.IO) {
                 val recoveryKey = recoveryCode.value!!
                 try {
    -                sharedViewModel.recoverUsingBackupPass(recoveryKey)
    +                sharedViewModel.recoverUsingBackupRecoveryKey(recoveryKey)
                 } catch (failure: Throwable) {
                     recoveryCodeErrorText.postValue(stringProvider.getString(R.string.keys_backup_recovery_code_error_decrypt))
                 }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt
    index c7d3da30ea..f42fee0030 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt
    @@ -15,6 +15,7 @@
      */
     package im.vector.riotx.features.crypto.keysbackup.setup
     
    +import android.app.Activity
     import android.content.Context
     import android.content.Intent
     import androidx.appcompat.app.AlertDialog
    @@ -25,12 +26,9 @@ import im.vector.matrix.android.api.MatrixCallback
     import im.vector.riotx.R
     import im.vector.riotx.core.dialogs.ExportKeysDialog
     import im.vector.riotx.core.extensions.observeEvent
    +import im.vector.riotx.core.extensions.queryExportKeys
     import im.vector.riotx.core.extensions.replaceFragment
     import im.vector.riotx.core.platform.SimpleFragmentActivity
    -import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
    -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_EXPORT_KEYS
    -import im.vector.riotx.core.utils.allGranted
    -import im.vector.riotx.core.utils.checkPermissions
     import im.vector.riotx.core.utils.toast
     import im.vector.riotx.features.crypto.keys.KeysExporter
     
    @@ -95,7 +93,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() {
                                 .show()
                     }
                     KeysBackupSetupSharedViewModel.NAVIGATE_MANUAL_EXPORT  -> {
    -                    exportKeysManually()
    +                    queryExportKeys(session.myUserId, REQUEST_CODE_SAVE_MEGOLM_EXPORT)
                     }
                 }
             }
    @@ -127,50 +125,45 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() {
             })
         }
     
    -    private fun exportKeysManually() {
    -        if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES,
    -                        this,
    -                        PERMISSION_REQUEST_CODE_EXPORT_KEYS,
    -                        R.string.permissions_rationale_msg_keys_backup_export)) {
    -            ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener {
    -                override fun onPassphrase(passphrase: String) {
    -                    showWaitingView()
    +    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    +        if (requestCode == REQUEST_CODE_SAVE_MEGOLM_EXPORT) {
    +            val uri = data?.data
    +            if (resultCode == Activity.RESULT_OK && uri != null) {
    +                ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener {
    +                    override fun onPassphrase(passphrase: String) {
    +                        showWaitingView()
     
    -                    KeysExporter(session)
    -                            .export(this@KeysBackupSetupActivity,
    -                                    passphrase,
    -                                    object : MatrixCallback {
    -                                        override fun onSuccess(data: String) {
    -                                            hideWaitingView()
    -
    -                                            AlertDialog.Builder(this@KeysBackupSetupActivity)
    -                                                    .setMessage(getString(R.string.encryption_export_saved_as, data))
    -                                                    .setCancelable(false)
    -                                                    .setPositiveButton(R.string.ok) { _, _ ->
    -                                                        val resultIntent = Intent()
    -                                                        resultIntent.putExtra(MANUAL_EXPORT, true)
    -                                                        setResult(RESULT_OK, resultIntent)
    +                        KeysExporter(session)
    +                                .export(this@KeysBackupSetupActivity,
    +                                        passphrase,
    +                                        uri,
    +                                        object : MatrixCallback {
    +                                            override fun onSuccess(data: Boolean) {
    +                                                if (data) {
    +                                                    toast(getString(R.string.encryption_exported_successfully))
    +                                                    Intent().apply {
    +                                                        putExtra(MANUAL_EXPORT, true)
    +                                                    }.let {
    +                                                        setResult(Activity.RESULT_OK, it)
                                                             finish()
                                                         }
    -                                                    .show()
    -                                        }
    +                                                }
    +                                                hideWaitingView()
    +                                            }
     
    -                                        override fun onFailure(failure: Throwable) {
    -                                            toast(failure.localizedMessage ?: getString(R.string.unexpected_error))
    -                                            hideWaitingView()
    -                                        }
    -                                    })
    -                }
    -            })
    -        }
    -    }
    -
    -    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    -        if (allGranted(grantResults)) {
    -            if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) {
    -                exportKeysManually()
    +                                            override fun onFailure(failure: Throwable) {
    +                                                toast(failure.localizedMessage ?: getString(R.string.unexpected_error))
    +                                                hideWaitingView()
    +                                            }
    +                                        })
    +                    }
    +                })
    +            } else {
    +                toast(getString(R.string.unexpected_error))
    +                hideWaitingView()
                 }
             }
    +        super.onActivityResult(requestCode, resultCode, data)
         }
     
         override fun onBackPressed() {
    @@ -205,6 +198,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() {
             const val KEYS_VERSION = "KEYS_VERSION"
             const val MANUAL_EXPORT = "MANUAL_EXPORT"
             const val EXTRA_SHOW_MANUAL_EXPORT = "SHOW_MANUAL_EXPORT"
    +        const val REQUEST_CODE_SAVE_MEGOLM_EXPORT = 101
     
             fun intent(context: Context, showManualExport: Boolean): Intent {
                 val intent = Intent(context, KeysBackupSetupActivity::class.java)
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupSharedViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupSharedViewModel.kt
    index d9a90eb457..6381786e57 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupSharedViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupSharedViewModel.kt
    @@ -48,6 +48,9 @@ class KeysBackupSetupSharedViewModel @Inject constructor() : ViewModel() {
     
         lateinit var session: Session
     
    +    val userId: String
    +        get() = session.myUserId
    +
         var showManualExport: MutableLiveData = MutableLiveData()
     
         var navigateEvent: MutableLiveData> = MutableLiveData()
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt
    index 93d6f43763..40ea79eb6d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt
    @@ -15,13 +15,13 @@
      */
     package im.vector.riotx.features.crypto.keysbackup.setup
     
    -import android.os.AsyncTask
     import android.os.Bundle
     import android.view.ViewGroup
     import android.view.inputmethod.EditorInfo
     import android.widget.EditText
     import android.widget.ImageView
     import androidx.lifecycle.Observer
    +import androidx.lifecycle.viewModelScope
     import androidx.transition.TransitionManager
     import butterknife.BindView
     import butterknife.OnClick
    @@ -33,6 +33,8 @@ import im.vector.riotx.core.extensions.showPassword
     import im.vector.riotx.core.platform.VectorBaseFragment
     import im.vector.riotx.core.ui.views.PasswordStrengthBar
     import im.vector.riotx.features.settings.VectorLocale
    +import kotlinx.coroutines.Dispatchers
    +import kotlinx.coroutines.launch
     import javax.inject.Inject
     
     class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment() {
    @@ -117,9 +119,9 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment()
                 if (newValue.isEmpty()) {
                     viewModel.passwordStrength.value = null
                 } else {
    -                AsyncTask.execute {
    +                viewModel.viewModelScope.launch(Dispatchers.IO) {
                         val strength = zxcvbn.measure(newValue)
    -                    activity?.runOnUiThread {
    +                    launch(Dispatchers.Main) {
                             viewModel.passwordStrength.value = strength
                         }
                     }
    @@ -176,7 +178,7 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment()
                 else                                                            -> {
                     viewModel.megolmBackupCreationInfo = null
     
    -                viewModel.prepareRecoveryKey(activity!!, viewModel.passphrase.value)
    +                viewModel.prepareRecoveryKey(requireActivity(), viewModel.passphrase.value)
                 }
             }
         }
    @@ -188,7 +190,7 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment()
                     // Generate a recovery key for the user
                     viewModel.megolmBackupCreationInfo = null
     
    -                viewModel.prepareRecoveryKey(activity!!, null)
    +                viewModel.prepareRecoveryKey(requireActivity(), null)
                 }
                 else                                       -> {
                     // User has entered a passphrase but want to skip this step.
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt
    index 1478b99d3b..28711115c3 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt
    @@ -15,8 +15,10 @@
      */
     package im.vector.riotx.features.crypto.keysbackup.setup
     
    +import android.app.Activity
    +import android.content.Intent
    +import android.net.Uri
     import android.os.Bundle
    -import android.os.Environment
     import android.view.View
     import android.widget.Button
     import android.widget.TextView
    @@ -29,25 +31,27 @@ import butterknife.BindView
     import butterknife.OnClick
     import com.google.android.material.bottomsheet.BottomSheetDialog
     import im.vector.riotx.R
    -import im.vector.riotx.core.files.addEntryToDownloadManager
    -import im.vector.riotx.core.files.writeToFile
     import im.vector.riotx.core.platform.VectorBaseFragment
     import im.vector.riotx.core.utils.LiveEvent
    -import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
    -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_EXPORT_KEYS
    -import im.vector.riotx.core.utils.allGranted
    -import im.vector.riotx.core.utils.checkPermissions
     import im.vector.riotx.core.utils.copyToClipboard
    +import im.vector.riotx.core.utils.selectTxtFileToWrite
     import im.vector.riotx.core.utils.startSharePlainTextIntent
     import kotlinx.coroutines.Dispatchers
     import kotlinx.coroutines.GlobalScope
     import kotlinx.coroutines.launch
     import kotlinx.coroutines.withContext
    -import java.io.File
    +import java.io.IOException
    +import java.text.SimpleDateFormat
    +import java.util.Date
    +import java.util.Locale
     import javax.inject.Inject
     
     class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment() {
     
    +    companion object {
    +        private const val SAVE_RECOVERY_KEY_REQUEST_CODE = 2754
    +    }
    +
         override fun getLayoutResId() = R.layout.fragment_keys_backup_setup_step3
     
         @BindView(R.id.keys_backup_setup_step3_button)
    @@ -105,7 +109,7 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
     
         @OnClick(R.id.keys_backup_setup_step3_copy_button)
         fun onCopyButtonClicked() {
    -        val dialog = BottomSheetDialog(activity!!)
    +        val dialog = BottomSheetDialog(requireActivity())
             dialog.setContentView(R.layout.bottom_sheet_save_recovery_key)
             dialog.setCanceledOnTouchOutside(true)
             val recoveryKey = viewModel.recoveryKey.value!!
    @@ -124,21 +128,21 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
                             }
     
                     it.debouncedClicks {
    -                    copyToClipboard(activity!!, recoveryKey)
    +                    copyToClipboard(requireActivity(), recoveryKey)
                     }
                 }
             }
     
             dialog.findViewById(R.id.keys_backup_setup_save)?.setOnClickListener {
    -            val permissionsChecked = checkPermissions(
    -                    PERMISSIONS_FOR_WRITING_FILES,
    -                    this,
    -                    PERMISSION_REQUEST_CODE_EXPORT_KEYS,
    -                    R.string.permissions_rationale_msg_keys_backup_export
    +            val userId = viewModel.userId
    +            val timestamp = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
    +            selectTxtFileToWrite(
    +                    activity = requireActivity(),
    +                    fragment = this,
    +                    defaultFileName = "recovery-key-$userId-$timestamp.txt",
    +                    chooserHint = getString(R.string.save_recovery_key_chooser_hint),
    +                    requestCode = SAVE_RECOVERY_KEY_REQUEST_CODE
                 )
    -            if (permissionsChecked) {
    -                exportRecoveryKeyToFile(recoveryKey)
    -            }
                 dialog.dismiss()
             }
     
    @@ -159,38 +163,36 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
             viewModel.recoveryKey.value?.let {
                 viewModel.copyHasBeenMade = true
     
    -            copyToClipboard(activity!!, it)
    +            copyToClipboard(requireActivity(), it)
             }
         }
     
    -    private fun exportRecoveryKeyToFile(data: String) {
    +    private fun exportRecoveryKeyToFile(uri: Uri, data: String) {
             GlobalScope.launch(Dispatchers.Main) {
                 Try {
                     withContext(Dispatchers.IO) {
    -                    val parentDir = context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
    -                    val file = File(parentDir, "recovery-key-" + System.currentTimeMillis() + ".txt")
    -
    -                    writeToFile(data, file)
    -
    -                    addEntryToDownloadManager(requireContext(), file, "text/plain")
    -
    -                    file.absolutePath
    +                    requireContext().contentResolver.openOutputStream(uri)
    +                            ?.use { os ->
    +                                os.write(data.toByteArray())
    +                                os.flush()
    +                            }
                     }
    +                        ?: throw IOException("Unable to write the file")
                 }
                         .fold(
                                 { throwable ->
    -                                context?.let {
    +                                activity?.let {
                                         AlertDialog.Builder(it)
                                                 .setTitle(R.string.dialog_title_error)
    -                                            .setMessage(throwable.localizedMessage)
    +                                            .setMessage(errorFormatter.toHumanReadable(throwable))
                                     }
                                 },
    -                            { path ->
    +                            {
                                     viewModel.copyHasBeenMade = true
    -
    -                                context?.let {
    +                                activity?.let {
                                         AlertDialog.Builder(it)
    -                                            .setMessage(getString(R.string.recovery_key_export_saved_as_warning, path))
    +                                            .setTitle(R.string.dialog_title_success)
    +                                            .setMessage(R.string.recovery_key_export_saved)
                                     }
                                 }
                         )
    @@ -200,11 +202,14 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
             }
         }
     
    -    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    -        if (allGranted(grantResults)) {
    -            if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) {
    -                viewModel.recoveryKey.value?.let {
    -                    exportRecoveryKeyToFile(it)
    +    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    +        when (requestCode) {
    +            SAVE_RECOVERY_KEY_REQUEST_CODE -> {
    +                val uri = data?.data
    +                if (resultCode == Activity.RESULT_OK && uri != null) {
    +                    viewModel.recoveryKey.value?.let {
    +                        exportRecoveryKeyToFile(uri, it)
    +                    }
                     }
                 }
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapBottomSheet.kt
    index f14d27b3d9..945a8c2866 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapBottomSheet.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapBottomSheet.kt
    @@ -45,7 +45,8 @@ class BootstrapBottomSheet : VectorBaseBottomSheetDialogFragment() {
     
         @Parcelize
         data class Args(
    -            val initCrossSigningOnly: Boolean
    +            val initCrossSigningOnly: Boolean,
    +            val forceReset4S: Boolean
         ) : Parcelable
     
         override val showExpanded = true
    @@ -180,10 +181,15 @@ class BootstrapBottomSheet : VectorBaseBottomSheetDialogFragment() {
     
             const val EXTRA_ARGS = "EXTRA_ARGS"
     
    -        fun show(fragmentManager: FragmentManager, initCrossSigningOnly: Boolean) {
    +        fun show(fragmentManager: FragmentManager, initCrossSigningOnly: Boolean, forceReset4S: Boolean) {
                 BootstrapBottomSheet().apply {
                     isCancelable = false
    -                arguments = Bundle().apply { this.putParcelable(EXTRA_ARGS, Args(initCrossSigningOnly)) }
    +                arguments = Bundle().apply {
    +                    this.putParcelable(EXTRA_ARGS, Args(
    +                            initCrossSigningOnly,
    +                            forceReset4S
    +                    ))
    +                }
                 }.show(fragmentManager, "BootstrapBottomSheet")
             }
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt
    index 6a3fadbcb3..8781cbe570 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt
    @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.securestorage.SsssKeySpec
     import im.vector.matrix.android.internal.crypto.crosssigning.toBase64NoPadding
     import im.vector.matrix.android.internal.crypto.keysbackup.model.MegolmBackupCreationInfo
     import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersion
    +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersionResult
     import im.vector.matrix.android.internal.crypto.keysbackup.util.extractCurveKeyFromRecoveryKey
     import im.vector.matrix.android.internal.crypto.model.rest.UserPasswordAuth
     import im.vector.matrix.android.internal.util.awaitCallback
    @@ -84,8 +85,10 @@ class BootstrapCrossSigningTask @Inject constructor(
         override suspend fun execute(params: Params): BootstrapResult {
             val crossSigningService = session.cryptoService().crossSigningService()
     
    +        Timber.d("## BootstrapCrossSigningTask: initXSOnly:${params.initOnlyCrossSigning} Starting...")
             // Ensure cross-signing is initialized. Due to migration it is maybe not always correctly initialized
             if (!crossSigningService.isCrossSigningInitialized()) {
    +            Timber.d("## BootstrapCrossSigningTask: Cross signing not enabled, so initialize")
                 params.progressListener?.onProgress(
                         WaitingViewData(
                                 stringProvider.getString(R.string.bootstrap_crosssigning_progress_initializing),
    @@ -104,8 +107,9 @@ class BootstrapCrossSigningTask @Inject constructor(
                     return handleInitializeXSigningError(failure)
                 }
             } else {
    -            // not sure how this can happen??
    +            Timber.d("## BootstrapCrossSigningTask: Cross signing already setup, go to 4S setup")
                 if (params.initOnlyCrossSigning) {
    +                // not sure how this can happen??
                     return handleInitializeXSigningError(IllegalArgumentException("Cross signing already setup"))
                 }
             }
    @@ -119,6 +123,8 @@ class BootstrapCrossSigningTask @Inject constructor(
                             stringProvider.getString(R.string.bootstrap_crosssigning_progress_pbkdf2),
                             isIndeterminate = true)
             )
    +
    +        Timber.d("## BootstrapCrossSigningTask: Creating 4S key with pass: ${params.passphrase != null}")
             try {
                 keyInfo = awaitCallback {
                     params.passphrase?.let { passphrase ->
    @@ -141,6 +147,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                     }
                 }
             } catch (failure: Failure) {
    +            Timber.e("## BootstrapCrossSigningTask: Creating 4S - Failed to generate key <${failure.localizedMessage}>")
                 return BootstrapResult.FailedToCreateSSSSKey(failure)
             }
     
    @@ -149,19 +156,24 @@ class BootstrapCrossSigningTask @Inject constructor(
                             stringProvider.getString(R.string.bootstrap_crosssigning_progress_default_key),
                             isIndeterminate = true)
             )
    +
    +        Timber.d("## BootstrapCrossSigningTask: Creating 4S - Set default key")
             try {
                 awaitCallback {
                     ssssService.setDefaultKey(keyInfo.keyId, it)
                 }
             } catch (failure: Failure) {
                 // Maybe we could just ignore this error?
    +            Timber.e("## BootstrapCrossSigningTask: Creating 4S - Set default key error <${failure.localizedMessage}>")
                 return BootstrapResult.FailedToSetDefaultSSSSKey(failure)
             }
     
    +        Timber.d("## BootstrapCrossSigningTask: Creating 4S - gathering private keys")
             val xKeys = crossSigningService.getCrossSigningPrivateKeys()
             val mskPrivateKey = xKeys?.master ?: return BootstrapResult.MissingPrivateKey
             val sskPrivateKey = xKeys.selfSigned ?: return BootstrapResult.MissingPrivateKey
             val uskPrivateKey = xKeys.user ?: return BootstrapResult.MissingPrivateKey
    +        Timber.d("## BootstrapCrossSigningTask: Creating 4S - gathering private keys success")
     
             try {
                 params.progressListener?.onProgress(
    @@ -170,6 +182,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                                 isIndeterminate = true
                         )
                 )
    +            Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing MSK...")
                 awaitCallback {
                     ssssService.storeSecret(
                             MASTER_KEY_SSSS_NAME,
    @@ -183,6 +196,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                                 isIndeterminate = true
                         )
                 )
    +            Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing USK...")
                 awaitCallback {
                     ssssService.storeSecret(
                             USER_SIGNING_KEY_SSSS_NAME,
    @@ -196,6 +210,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                                 stringProvider.getString(R.string.bootstrap_crosssigning_progress_save_ssk), isIndeterminate = true
                         )
                 )
    +            Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing SSK...")
                 awaitCallback {
                     ssssService.storeSecret(
                             SELF_SIGNING_KEY_SSSS_NAME,
    @@ -204,6 +219,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                     )
                 }
             } catch (failure: Failure) {
    +            Timber.e("## BootstrapCrossSigningTask: Creating 4S - Failed to store keys <${failure.localizedMessage}>")
                 // Maybe we could just ignore this error?
                 return BootstrapResult.FailedToStorePrivateKeyInSSSS(failure)
             }
    @@ -215,7 +231,14 @@ class BootstrapCrossSigningTask @Inject constructor(
                     )
             )
             try {
    -            if (session.cryptoService().keysBackupService().keysBackupVersion == null) {
    +            Timber.d("## BootstrapCrossSigningTask: Creating 4S - Checking megolm backup")
    +
    +            // First ensure that in sync
    +            val serverVersion = awaitCallback {
    +                session.cryptoService().keysBackupService().getCurrentVersion(it)
    +            }
    +            if (serverVersion == null) {
    +                Timber.d("## BootstrapCrossSigningTask: Creating 4S - Create megolm backup")
                     val creationInfo = awaitCallback {
                         session.cryptoService().keysBackupService().prepareKeysBackupVersion(null, null, it)
                     }
    @@ -223,6 +246,7 @@ class BootstrapCrossSigningTask @Inject constructor(
                         session.cryptoService().keysBackupService().createKeysBackupVersion(creationInfo, it)
                     }
                     // Save it for gossiping
    +                Timber.d("## BootstrapCrossSigningTask: Creating 4S - Save megolm backup key for gossiping")
                     session.cryptoService().keysBackupService().saveBackupRecoveryKey(creationInfo.recoveryKey, version = version.version)
     
                     awaitCallback {
    @@ -234,11 +258,36 @@ class BootstrapCrossSigningTask @Inject constructor(
                             )
                         }
                     }
    +            } else {
    +                Timber.d("## BootstrapCrossSigningTask: Creating 4S - Existing megolm backup found")
    +                // ensure we store existing backup secret if we have it!
    +                val knownSecret = session.cryptoService().keysBackupService().getKeyBackupRecoveryKeyInfo()
    +                if (knownSecret != null && knownSecret.version == serverVersion.version) {
    +                    // check it matches
    +                    val isValid = awaitCallback {
    +                        session.cryptoService().keysBackupService().isValidRecoveryKeyForCurrentVersion(knownSecret.recoveryKey, it)
    +                    }
    +                    if (isValid) {
    +                        Timber.d("## BootstrapCrossSigningTask: Creating 4S - Megolm key valid and known")
    +                        awaitCallback {
    +                            extractCurveKeyFromRecoveryKey(knownSecret.recoveryKey)?.toBase64NoPadding()?.let { secret ->
    +                                ssssService.storeSecret(
    +                                        KEYBACKUP_SECRET_SSSS_NAME,
    +                                        secret,
    +                                        listOf(SharedSecretStorageService.KeyRef(keyInfo.keyId, keyInfo.keySpec)), it
    +                                )
    +                            }
    +                        }
    +                    } else {
    +                        Timber.d("## BootstrapCrossSigningTask: Creating 4S - Megolm key is unknown by this session")
    +                    }
    +                }
                 }
             } catch (failure: Throwable) {
                 Timber.e("## BootstrapCrossSigningTask: Failed to init keybackup")
             }
     
    +        Timber.d("## BootstrapCrossSigningTask: initXSOnly:${params.initOnlyCrossSigning} Finished")
             return BootstrapResult.Success(keyInfo)
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt
    index 3ab48e44ff..2c31474122 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt
    @@ -78,7 +78,7 @@ class BootstrapSaveRecoveryKeyFragment @Inject constructor(
                 if (resultCode == RESULT_OK && uri != null) {
                     GlobalScope.launch(Dispatchers.IO) {
                         try {
    -                        sharedViewModel.handle(BootstrapActions.SaveKeyToUri(context!!.contentResolver!!.openOutputStream(uri)!!))
    +                        sharedViewModel.handle(BootstrapActions.SaveKeyToUri(requireContext().contentResolver!!.openOutputStream(uri)!!))
                         } catch (failure: Throwable) {
                             sharedViewModel.handle(BootstrapActions.SaveReqFailed)
                         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt
    index 156acf845f..ea558145c0 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt
    @@ -58,6 +58,13 @@ class BootstrapSetupRecoveryKeyFragment @Inject constructor() : VectorBaseFragme
                     bootstrapSetupSecureUseSecurityPassphrase.isVisible = false
                     bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = false
                 } else {
    +                if (state.step.reset) {
    +                    bootstrapSetupSecureText.text = getString(R.string.reset_secure_backup_title)
    +                    bootstrapSetupWarningTextView.isVisible = true
    +                } else {
    +                    bootstrapSetupSecureText.text = getString(R.string.bottom_sheet_setup_secure_backup_subtitle)
    +                    bootstrapSetupWarningTextView.isVisible = false
    +                }
                     // Choose between create a passphrase or use a recovery key
                     bootstrapSetupSecureSubmit.isVisible = false
                     bootstrapSetupSecureUseSecurityKey.isVisible = true
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt
    index 3a95a575f4..8b247bd975 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt
    @@ -69,7 +69,11 @@ class BootstrapSharedViewModel @AssistedInject constructor(
     
         init {
     
    -        if (args.initCrossSigningOnly) {
    +        if (args.forceReset4S) {
    +            setState {
    +                copy(step = BootstrapStep.FirstForm(keyBackUpExist = false, reset = true))
    +            }
    +        } else if (args.initCrossSigningOnly) {
                 // Go straight to account password
                 setState {
                     copy(step = BootstrapStep.AccountPassword(false))
    @@ -406,7 +410,10 @@ class BootstrapSharedViewModel @AssistedInject constructor(
                             setState {
                                 copy(
                                         recoveryKeyCreationInfo = bootstrapResult.keyInfo,
    -                                    step = BootstrapStep.SaveRecoveryKey(false)
    +                                    step = BootstrapStep.SaveRecoveryKey(
    +                                            // If a passphrase was used, saving key is optional
    +                                            state.passphrase != null
    +                                    )
                                 )
                             }
                         }
    @@ -551,7 +558,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
             override fun create(viewModelContext: ViewModelContext, state: BootstrapViewState): BootstrapSharedViewModel? {
                 val fragment: BootstrapBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
                 val args: BootstrapBottomSheet.Args = fragment.arguments?.getParcelable(BootstrapBottomSheet.EXTRA_ARGS)
    -                    ?: BootstrapBottomSheet.Args(initCrossSigningOnly = true)
    +                    ?: BootstrapBottomSheet.Args(initCrossSigningOnly = true, forceReset4S = false)
                 return fragment.bootstrapViewModelFactory.create(state, args)
             }
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapStep.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapStep.kt
    index c7639068d1..71b00016ab 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapStep.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapStep.kt
    @@ -89,7 +89,7 @@ sealed class BootstrapStep {
         object CheckingMigration : BootstrapStep()
     
         // Use will be asked to choose between passphrase or recovery key, or to start process if a key backup exists
    -    data class FirstForm(val keyBackUpExist: Boolean) : BootstrapStep()
    +    data class FirstForm(val keyBackUpExist: Boolean, val reset: Boolean = false) : BootstrapStep()
     
         data class SetupPassphrase(val isPasswordVisible: Boolean) : BootstrapStep()
         data class ConfirmPassphrase(val isPasswordVisible: Boolean) : BootstrapStep()
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheet.kt
    index 7a3d38f649..cd9fed108b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheet.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheet.kt
    @@ -250,7 +250,10 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
                 is VerificationTxState.Started,
                 is VerificationTxState.WaitingOtherReciprocateConfirm -> {
                     showFragment(VerificationQRWaitingFragment::class, Bundle().apply {
    -                    putParcelable(MvRx.KEY_ARG, VerificationQRWaitingFragment.Args(state.isMe, state.otherUserMxItem?.getBestName() ?: ""))
    +                    putParcelable(MvRx.KEY_ARG, VerificationQRWaitingFragment.Args(
    +                            isMe = state.isMe,
    +                            otherUserName = state.otherUserMxItem?.getBestName() ?: ""
    +                    ))
                     })
                     return@withState
                 }
    @@ -353,6 +356,17 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() {
                     }
                 }
             }
    +        fun forSelfVerification(session: Session, outgoingRequest: String): VerificationBottomSheet {
    +            return VerificationBottomSheet().apply {
    +                arguments = Bundle().apply {
    +                    putParcelable(MvRx.KEY_ARG, VerificationArgs(
    +                            otherUserId = session.myUserId,
    +                            selfVerificationMode = true,
    +                            verificationId = outgoingRequest
    +                    ))
    +                }
    +            }
    +        }
     
             const val WAITING_SELF_VERIF_TAG: String = "WAITING_SELF_VERIF_TAG"
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt
    index 9b454436d9..53c9deb296 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt
    @@ -235,11 +235,12 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
                                     pendingRequest = Loading()
                             )
                         }
    -                    val roomParams = CreateRoomParams(
    -                            invitedUserIds = listOf(otherUserId)
    -                    )
    -                            .setDirectMessage()
    -                            .enableEncryptionIfInvitedUsersSupportIt()
    +                    val roomParams = CreateRoomParams()
    +                            .apply {
    +                                invitedUserIds.add(otherUserId)
    +                                setDirectMessage()
    +                                enableEncryptionIfInvitedUsersSupportIt = true
    +                            }
     
                         session.createRoom(roomParams, object : MatrixCallback {
                             override fun onSuccess(data: String) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/verification/request/VerificationRequestController.kt b/vector/src/main/java/im/vector/riotx/features/crypto/verification/request/VerificationRequestController.kt
    index 88f6607a41..8ac2ce72cb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/verification/request/VerificationRequestController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/verification/request/VerificationRequestController.kt
    @@ -146,7 +146,7 @@ class VerificationRequestController @Inject constructor(
                 }
             }
     
    -        if (state.isMe && state.currentDeviceCanCrossSign) {
    +        if (state.isMe && state.currentDeviceCanCrossSign && !state.selfVerificationMode) {
                 dividerItem {
                     id("sep_notMe")
                 }
    diff --git a/vector/src/main/java/im/vector/riotx/features/grouplist/GroupListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/grouplist/GroupListViewModel.kt
    index f14583c5d5..4c8e5c2333 100644
    --- a/vector/src/main/java/im/vector/riotx/features/grouplist/GroupListViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/grouplist/GroupListViewModel.kt
    @@ -23,6 +23,7 @@ import com.airbnb.mvrx.MvRxViewModelFactory
     import com.airbnb.mvrx.ViewModelContext
     import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.NoOpMatrixCallback
     import im.vector.matrix.android.api.query.QueryStringValue
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.group.groupSummaryQueryParams
    @@ -74,6 +75,11 @@ class GroupListViewModel @AssistedInject constructor(@Assisted initialState: Gro
                     }
                     val optionGroup = Option.just(groupSummary)
                     selectedGroupStore.post(optionGroup)
    +            } else {
    +                // If selected group is null we force to default. It can happens when leaving the selected group.
    +                setState {
    +                    copy(selectedGroup = this.asyncGroups()?.find { it.groupId == ALL_COMMUNITIES_GROUP_ID })
    +                }
                 }
             }
         }
    @@ -88,6 +94,8 @@ class GroupListViewModel @AssistedInject constructor(@Assisted initialState: Gro
     
         private fun handleSelectGroup(action: GroupListAction.SelectGroup) = withState { state ->
             if (state.selectedGroup?.groupId != action.groupSummary.groupId) {
    +            // We take care of refreshing group data when selecting to be sure we get all the rooms and users
    +            session.getGroup(action.groupSummary.groupId)?.fetchGroupData(NoOpMatrixCallback())
                 setState { copy(selectedGroup = action.groupSummary) }
             }
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    index 687c280910..3bf2f13d48 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    @@ -30,6 +30,7 @@ import com.bumptech.glide.request.target.DrawableImageViewTarget
     import com.bumptech.glide.request.target.Target
     import im.vector.matrix.android.api.session.content.ContentUrlResolver
     import im.vector.matrix.android.api.util.MatrixItem
    +import im.vector.riotx.core.contacts.MappedContact
     import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.glide.GlideApp
     import im.vector.riotx.core.glide.GlideRequest
    @@ -63,21 +64,38 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
                     DrawableImageViewTarget(imageView))
         }
     
    +    @UiThread
    +    fun render(mappedContact: MappedContact, imageView: ImageView) {
    +        // Create a Fake MatrixItem, for the placeholder
    +        val matrixItem = MatrixItem.UserItem(
    +                // Need an id starting with @
    +                id = "@${mappedContact.displayName}",
    +                displayName = mappedContact.displayName
    +        )
    +
    +        val placeholder = getPlaceholderDrawable(imageView.context, matrixItem)
    +        GlideApp.with(imageView)
    +                .load(mappedContact.photoURI)
    +                .apply(RequestOptions.circleCropTransform())
    +                .placeholder(placeholder)
    +                .into(imageView)
    +    }
    +
         @UiThread
         fun render(context: Context,
    -               glideRequest: GlideRequests,
    +               glideRequests: GlideRequests,
                    matrixItem: MatrixItem,
                    target: Target) {
             val placeholder = getPlaceholderDrawable(context, matrixItem)
    -        buildGlideRequest(glideRequest, matrixItem.avatarUrl)
    +        buildGlideRequest(glideRequests, matrixItem.avatarUrl)
                     .placeholder(placeholder)
                     .into(target)
         }
     
         @AnyThread
         @Throws
    -    fun shortcutDrawable(context: Context, glideRequest: GlideRequests, matrixItem: MatrixItem, iconSize: Int): Bitmap {
    -        return glideRequest
    +    fun shortcutDrawable(context: Context, glideRequests: GlideRequests, matrixItem: MatrixItem, iconSize: Int): Bitmap {
    +        return glideRequests
                     .asBitmap()
                     .apply {
                         val resolvedUrl = resolvedUrl(matrixItem.avatarUrl)
    @@ -98,8 +116,8 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
         }
     
         @AnyThread
    -    fun getCachedDrawable(glideRequest: GlideRequests, matrixItem: MatrixItem): Drawable {
    -        return buildGlideRequest(glideRequest, matrixItem.avatarUrl)
    +    fun getCachedDrawable(glideRequests: GlideRequests, matrixItem: MatrixItem): Drawable {
    +        return buildGlideRequest(glideRequests, matrixItem.avatarUrl)
                     .onlyRetrieveFromCache(true)
                     .submit()
                     .get()
    @@ -117,9 +135,9 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
     
         // PRIVATE API *********************************************************************************
     
    -    private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest {
    +    private fun buildGlideRequest(glideRequests: GlideRequests, avatarUrl: String?): GlideRequest {
             val resolvedUrl = resolvedUrl(avatarUrl)
    -        return glideRequest
    +        return glideRequests
                     .load(resolvedUrl)
                     .apply(RequestOptions.circleCropTransform())
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    index 8d5fc5f564..5bed5b1f78 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    @@ -46,7 +46,8 @@ import im.vector.riotx.features.popup.PopupAlertManager
     import im.vector.riotx.features.popup.VerificationVectorAlert
     import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
     import im.vector.riotx.features.settings.VectorPreferences
    -import im.vector.riotx.features.workers.signout.SignOutViewModel
    +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewModel
    +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewState
     import im.vector.riotx.push.fcm.FcmHelper
     import kotlinx.android.parcel.Parcelize
     import kotlinx.android.synthetic.main.activity_home.*
    @@ -60,13 +61,16 @@ data class HomeActivityArgs(
             val accountCreation: Boolean
     ) : Parcelable
     
    -class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory {
    +class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory, ServerBackupStatusViewModel.Factory {
     
         private lateinit var sharedActionViewModel: HomeSharedActionViewModel
     
         private val homeActivityViewModel: HomeActivityViewModel by viewModel()
         @Inject lateinit var viewModelFactory: HomeActivityViewModel.Factory
     
    +    private val serverBackupStatusViewModel: ServerBackupStatusViewModel by viewModel()
    +    @Inject lateinit var  serverBackupviewModelFactory: ServerBackupStatusViewModel.Factory
    +
         @Inject lateinit var activeSessionHolder: ActiveSessionHolder
         @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler
         @Inject lateinit var pushManager: PushersManager
    @@ -92,6 +96,10 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDet
             return unknownDeviceViewModelFactory.create(initialState)
         }
     
    +    override fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel {
    +        return serverBackupviewModelFactory.create(initialState)
    +    }
    +
         override fun onCreate(savedInstanceState: Bundle?) {
             super.onCreate(savedInstanceState)
             FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice())
    @@ -177,7 +185,11 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDet
                     R.string.crosssigning_verify_this_session,
                     R.string.confirm_your_identity
             ) {
    -            it.navigator.waitSessionVerification(it)
    +            if (event.waitForIncomingRequest) {
    +                it.navigator.waitSessionVerification(it)
    +            } else {
    +                it.navigator.requestSelfSessionVerification(it)
    +            }
             }
         }
     
    @@ -230,7 +242,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDet
             }
     
             // Force remote backup state update to update the banner if needed
    -        viewModelProvider.get(SignOutViewModel::class.java).refreshRemoteStateIfNeeded()
    +        serverBackupStatusViewModel.refreshRemoteStateIfNeeded()
         }
     
         override fun configure(toolbar: Toolbar) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewEvents.kt
    index 2f1d8b2705..1cdabe824c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewEvents.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewEvents.kt
    @@ -21,5 +21,5 @@ import im.vector.riotx.core.platform.VectorViewEvents
     
     sealed class HomeActivityViewEvents : VectorViewEvents {
         data class AskPasswordToInitCrossSigning(val userItem: MatrixItem.UserItem?) : HomeActivityViewEvents()
    -    data class OnNewSession(val userItem: MatrixItem.UserItem?) : HomeActivityViewEvents()
    +    data class OnNewSession(val userItem: MatrixItem.UserItem?, val waitForIncomingRequest: Boolean = true) : HomeActivityViewEvents()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewModel.kt
    index fdf0936d58..f89bb5a547 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivityViewModel.kt
    @@ -130,7 +130,14 @@ class HomeActivityViewModel @AssistedInject constructor(
                         // Cross-signing is already set up for this user, is it trusted?
                         if (!mxCrossSigningInfo.isTrusted()) {
                             // New session
    -                        _viewEvents.post(HomeActivityViewEvents.OnNewSession(session.getUser(session.myUserId)?.toMatrixItem()))
    +                        _viewEvents.post(
    +                                HomeActivityViewEvents.OnNewSession(
    +                                        session.getUser(session.myUserId)?.toMatrixItem(),
    +                                        // If it's an old unverified, we should send requests
    +                                        // instead of waiting for an incoming one
    +                                        reAuthHelper.data != null
    +                                )
    +                        )
                         }
                     } else {
                         // Initialize cross-signing
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    index c92c28079f..65a599665e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    @@ -17,22 +17,18 @@
     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
    -import com.google.android.material.bottomnavigation.BottomNavigationMenuView
    -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
    +import com.google.android.material.badge.BadgeDrawable
     import im.vector.matrix.android.api.session.group.model.GroupSummary
     import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
     import im.vector.riotx.R
    -import im.vector.riotx.core.extensions.commitTransactionNow
    +import im.vector.riotx.core.extensions.commitTransaction
     import im.vector.riotx.core.glide.GlideApp
     import im.vector.riotx.core.platform.ToolbarConfigurable
     import im.vector.riotx.core.platform.VectorBaseActivity
    @@ -45,35 +41,34 @@ import im.vector.riotx.features.call.VectorCallActivity
     import im.vector.riotx.features.call.WebRtcPeerConnectionManager
     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.settings.VectorPreferences
     import im.vector.riotx.features.settings.VectorSettingsActivity.Companion.EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS
    -import im.vector.riotx.features.workers.signout.SignOutViewModel
    +import im.vector.riotx.features.themes.ThemeUtils
    +import im.vector.riotx.features.workers.signout.BannerState
    +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewModel
    +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewState
     import kotlinx.android.synthetic.main.fragment_home_detail.*
    -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiP
    -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiPWrap
    -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallView
    -import kotlinx.android.synthetic.main.fragment_home_detail.syncStateView
    -import kotlinx.android.synthetic.main.fragment_room_detail.*
     import timber.log.Timber
     import javax.inject.Inject
     
    -private const val INDEX_CATCHUP = 0
    -private const val INDEX_PEOPLE = 1
    -private const val INDEX_ROOMS = 2
    +private const val INDEX_PEOPLE = 0
    +private const val INDEX_ROOMS = 1
    +private const val INDEX_CATCHUP = 2
     
     class HomeDetailFragment @Inject constructor(
             val homeDetailViewModelFactory: HomeDetailViewModel.Factory,
    +        private val serverBackupStatusViewModelFactory: ServerBackupStatusViewModel.Factory,
             private val avatarRenderer: AvatarRenderer,
             private val alertManager: PopupAlertManager,
    -        private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager
    -) : VectorBaseFragment(), KeysBackupBanner.Delegate, ActiveCallView.Callback {
    -
    -    private val unreadCounterBadgeViews = arrayListOf()
    +        private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager,
    +        private val vectorPreferences: VectorPreferences
    +) : VectorBaseFragment(), KeysBackupBanner.Delegate, ActiveCallView.Callback, ServerBackupStatusViewModel.Factory {
     
         private val viewModel: HomeDetailViewModel by fragmentViewModel()
         private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel()
    +    private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel()
     
         private lateinit var sharedActionViewModel: HomeSharedActionViewModel
         private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel
    @@ -130,6 +125,25 @@ class HomeDetailFragment @Inject constructor(
                     })
         }
     
    +    override fun onResume() {
    +        super.onResume()
    +        // update notification tab if needed
    +        checkNotificationTabStatus()
    +    }
    +
    +    private fun checkNotificationTabStatus() {
    +        val wasVisible = bottomNavigationView.menu.findItem(R.id.bottom_action_notification).isVisible
    +        bottomNavigationView.menu.findItem(R.id.bottom_action_notification).isVisible = vectorPreferences.labAddNotificationTab()
    +        if (wasVisible && !vectorPreferences.labAddNotificationTab()) {
    +            // As we hide it check if it's not the current item!
    +            withState(viewModel) {
    +                if (it.displayMode.toMenuId() == R.id.bottom_action_notification) {
    +                    viewModel.handle(HomeDetailAction.SwitchDisplayMode(RoomListDisplayMode.PEOPLE))
    +                }
    +            }
    +        }
    +    }
    +
         private fun promptForNewUnknownDevices(uid: String, state: UnknownDevicesState, newest: DeviceInfo) {
             val user = state.myMatrixItem
             alertManager.postVectorAlert(
    @@ -195,34 +209,15 @@ class HomeDetailFragment @Inject constructor(
         }
     
         private fun setupKeysBackupBanner() {
    -        // Keys backup banner
    -        // Use the SignOutViewModel, it observe the keys backup state and this is what we need here
    -        val model = fragmentViewModelProvider.get(SignOutViewModel::class.java)
    -
    -        model.keysBackupState.observe(viewLifecycleOwner, Observer { keysBackupState ->
    -            when (keysBackupState) {
    -                null                               ->
    -                    homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false)
    -                KeysBackupState.Disabled           ->
    -                    homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(model.getNumberOfKeysToBackup()), false)
    -                KeysBackupState.NotTrusted,
    -                KeysBackupState.WrongBackUpVersion ->
    -                    // In this case, getCurrentBackupVersion() should not return ""
    -                    homeKeysBackupBanner.render(KeysBackupBanner.State.Recover(model.getCurrentBackupVersion()), false)
    -                KeysBackupState.WillBackUp,
    -                KeysBackupState.BackingUp          ->
    -                    homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false)
    -                KeysBackupState.ReadyToBackUp      ->
    -                    if (model.canRestoreKeys()) {
    -                        homeKeysBackupBanner.render(KeysBackupBanner.State.Update(model.getCurrentBackupVersion()), false)
    -                    } else {
    -                        homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false)
    +        serverBackupStatusViewModel
    +                .subscribe(this) {
    +                    when (val banState = it.bannerState.invoke()) {
    +                        is BannerState.Setup  -> homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false)
    +                        BannerState.BackingUp -> homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false)
    +                        null,
    +                        BannerState.Hidden    -> homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false)
                         }
    -                else                               ->
    -                    homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false)
    -            }
    -        })
    -
    +                }
             homeKeysBackupBanner.delegate = this
         }
     
    @@ -247,24 +242,27 @@ class HomeDetailFragment @Inject constructor(
         }
     
         private fun setupBottomNavigationView() {
    +        bottomNavigationView.menu.findItem(R.id.bottom_action_notification).isVisible = vectorPreferences.labAddNotificationTab()
             bottomNavigationView.setOnNavigationItemSelectedListener {
                 val displayMode = when (it.itemId) {
                     R.id.bottom_action_people -> RoomListDisplayMode.PEOPLE
                     R.id.bottom_action_rooms  -> RoomListDisplayMode.ROOMS
    -                else                      -> RoomListDisplayMode.HOME
    +                else                      -> RoomListDisplayMode.NOTIFICATIONS
                 }
                 viewModel.handle(HomeDetailAction.SwitchDisplayMode(displayMode))
                 true
             }
     
    -        val menuView = bottomNavigationView.getChildAt(0) as BottomNavigationMenuView
    -        menuView.forEachIndexed { index, view ->
    -            val itemView = view as BottomNavigationItemView
    -            val badgeLayout = LayoutInflater.from(requireContext()).inflate(R.layout.vector_home_badge_unread_layout, menuView, false)
    -            val unreadCounterBadgeView: UnreadCounterBadgeView = badgeLayout.findViewById(R.id.actionUnreadCounterBadgeView)
    -            itemView.addView(badgeLayout)
    -            unreadCounterBadgeViews.add(index, unreadCounterBadgeView)
    -        }
    +//        val menuView = bottomNavigationView.getChildAt(0) as BottomNavigationMenuView
    +
    +//        bottomNavigationView.getOrCreateBadge()
    +//        menuView.forEachIndexed { index, view ->
    +//            val itemView = view as BottomNavigationItemView
    +//            val badgeLayout = LayoutInflater.from(requireContext()).inflate(R.layout.vector_home_badge_unread_layout, menuView, false)
    +//            val unreadCounterBadgeView: UnreadCounterBadgeView = badgeLayout.findViewById(R.id.actionUnreadCounterBadgeView)
    +//            itemView.addView(badgeLayout)
    +//            unreadCounterBadgeViews.add(index, unreadCounterBadgeView)
    +//        }
         }
     
         private fun switchDisplayMode(displayMode: RoomListDisplayMode) {
    @@ -275,7 +273,7 @@ class HomeDetailFragment @Inject constructor(
         private fun updateSelectedFragment(displayMode: RoomListDisplayMode) {
             val fragmentTag = "FRAGMENT_TAG_${displayMode.name}"
             val fragmentToShow = childFragmentManager.findFragmentByTag(fragmentTag)
    -        childFragmentManager.commitTransactionNow {
    +        childFragmentManager.commitTransaction {
                 childFragmentManager.fragments
                         .filter { it != fragmentToShow }
                         .forEach {
    @@ -304,16 +302,28 @@ class HomeDetailFragment @Inject constructor(
     
         override fun invalidate() = withState(viewModel) {
             Timber.v(it.toString())
    -        unreadCounterBadgeViews[INDEX_CATCHUP].render(UnreadCounterBadgeView.State(it.notificationCountCatchup, it.notificationHighlightCatchup))
    -        unreadCounterBadgeViews[INDEX_PEOPLE].render(UnreadCounterBadgeView.State(it.notificationCountPeople, it.notificationHighlightPeople))
    -        unreadCounterBadgeViews[INDEX_ROOMS].render(UnreadCounterBadgeView.State(it.notificationCountRooms, it.notificationHighlightRooms))
    +        bottomNavigationView.getOrCreateBadge(R.id.bottom_action_people).render(it.notificationCountPeople, it.notificationHighlightPeople)
    +        bottomNavigationView.getOrCreateBadge(R.id.bottom_action_rooms).render(it.notificationCountRooms, it.notificationHighlightRooms)
    +        bottomNavigationView.getOrCreateBadge(R.id.bottom_action_notification).render(it.notificationCountCatchup, it.notificationHighlightCatchup)
             syncStateView.render(it.syncState)
         }
     
    +    private fun BadgeDrawable.render(count: Int, highlight: Boolean) {
    +        isVisible = count > 0
    +        number = count
    +        maxCharacterCount = 3
    +        badgeTextColor = ContextCompat.getColor(requireContext(), R.color.white)
    +        backgroundColor = if (highlight) {
    +            ContextCompat.getColor(requireContext(), R.color.riotx_notice)
    +        } else {
    +            ThemeUtils.getColor(requireContext(), R.attr.riotx_unread_room_badge)
    +        }
    +    }
    +
         private fun RoomListDisplayMode.toMenuId() = when (this) {
             RoomListDisplayMode.PEOPLE -> R.id.bottom_action_people
             RoomListDisplayMode.ROOMS  -> R.id.bottom_action_rooms
    -        else                       -> R.id.bottom_action_home
    +        else                       -> R.id.bottom_action_notification
         }
     
         override fun onTapToReturnToCall() {
    @@ -331,4 +341,8 @@ class HomeDetailFragment @Inject constructor(
                 }
             }
         }
    +
    +    override fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel {
    +        return serverBackupStatusViewModelFactory.create(initialState)
    +    }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/RoomListDisplayMode.kt b/vector/src/main/java/im/vector/riotx/features/home/RoomListDisplayMode.kt
    index 6d7f49750d..365eda74a8 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/RoomListDisplayMode.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/RoomListDisplayMode.kt
    @@ -20,7 +20,7 @@ import androidx.annotation.StringRes
     import im.vector.riotx.R
     
     enum class RoomListDisplayMode(@StringRes val titleRes: Int) {
    -        HOME(R.string.bottom_action_home),
    +        NOTIFICATIONS(R.string.bottom_action_notification),
             PEOPLE(R.string.bottom_action_people_x),
             ROOMS(R.string.bottom_action_rooms),
             FILTERED(/* Not used */ 0)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/JumpToBottomViewVisibilityManager.kt
    index 4be5502678..50a28b8a8b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/JumpToBottomViewVisibilityManager.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/JumpToBottomViewVisibilityManager.kt
    @@ -20,7 +20,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
     import androidx.recyclerview.widget.RecyclerView
     import com.google.android.material.floatingactionbutton.FloatingActionButton
     import im.vector.riotx.core.utils.Debouncer
    -import timber.log.Timber
     
     /**
      * Show or hide the jumpToBottomView, depending on the scrolling and if the timeline is displaying the more recent event
    @@ -67,7 +66,6 @@ class JumpToBottomViewVisibilityManager(
         }
     
         private fun maybeShowJumpToBottomViewVisibility() {
    -        Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}")
             if (layoutManager.findFirstVisibleItemPosition() != 0) {
                 jumpToBottomView.show()
             } else {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    index 9d7ea58bb5..3c65b6281f 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    @@ -69,6 +69,7 @@ import im.vector.matrix.android.api.session.events.model.Event
     import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.file.FileService
     import im.vector.matrix.android.api.session.room.model.Membership
    +import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.session.room.model.message.MessageContent
     import im.vector.matrix.android.api.session.room.model.message.MessageFormat
     import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent
    @@ -636,7 +637,7 @@ class RoomDetailFragment @Inject constructor(
                 val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
                 formattedBody = eventHtmlRenderer.render(document)
             }
    -        composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
    +        composerLayout.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody)
     
             updateComposerText(defaultContent)
     
    @@ -853,12 +854,14 @@ class RoomDetailFragment @Inject constructor(
         }
     
         override fun invalidate() = withState(roomDetailViewModel) { state ->
    -        renderRoomSummary(state)
             invalidateOptionsMenu()
             val summary = state.asyncRoomSummary()
    +        renderToolbar(summary, state.typingMessage)
             val inviter = state.asyncInviter()
             if (summary?.membership == Membership.JOIN) {
                 roomWidgetsBannerView.render(state.activeRoomWidgets())
    +            jumpToBottomView.count = summary.notificationCount
    +            jumpToBottomView.drawBadge = summary.hasUnreadMessages
                 scrollOnHighlightedEventCallback.timeline = roomDetailViewModel.timeline
                 timelineEventController.update(state)
                 inviteView.visibility = View.GONE
    @@ -880,7 +883,7 @@ class RoomDetailFragment @Inject constructor(
                 }
             } else if (summary?.membership == Membership.INVITE && inviter != null) {
                 inviteView.visibility = View.VISIBLE
    -            inviteView.render(inviter, VectorInviteView.Mode.LARGE)
    +            inviteView.render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState)
                 // Intercept click event
                 inviteView.setOnClickListener { }
             } else if (state.asyncInviter.complete) {
    @@ -888,15 +891,15 @@ class RoomDetailFragment @Inject constructor(
             }
         }
     
    -    private fun renderRoomSummary(state: RoomDetailViewState) {
    -        state.asyncRoomSummary()?.let { roomSummary ->
    +    private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) {
    +        if (roomSummary == null) {
    +            roomToolbarContentView.isClickable = false
    +        } else {
    +            roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN
                 roomToolbarTitleView.text = roomSummary.displayName
                 avatarRenderer.render(roomSummary.toMatrixItem(), roomToolbarAvatarImageView)
     
    -            renderSubTitle(state.typingMessage, roomSummary.topic)
    -            jumpToBottomView.count = roomSummary.notificationCount
    -            jumpToBottomView.drawBadge = roomSummary.hasUnreadMessages
    -
    +            renderSubTitle(typingMessage, roomSummary.topic)
                 roomToolbarDecorationImageView.let {
                     it.setImageResource(roomSummary.roomEncryptionTrustLevel.toImageRes())
                     it.isVisible = roomSummary.roomEncryptionTrustLevel != null
    @@ -957,7 +960,7 @@ class RoomDetailFragment @Inject constructor(
                     updateComposerText("")
                 }
                 is RoomDetailViewEvents.SlashCommandResultError    -> {
    -                displayCommandError(sendMessageResult.throwable.localizedMessage ?: getString(R.string.unexpected_error))
    +                displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))
                 }
                 is RoomDetailViewEvents.SlashCommandNotImplemented -> {
                     displayCommandError(getString(R.string.not_implemented))
    @@ -1171,14 +1174,27 @@ class RoomDetailFragment @Inject constructor(
         }
     
         override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) {
    -        navigator.openImageViewer(requireActivity(), mediaData, view) { pairs ->
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = roomDetailArgs.roomId,
    +                mediaData = mediaData,
    +                view = view
    +        ) { pairs ->
                 pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: ""))
                 pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: ""))
             }
         }
     
         override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) {
    -        navigator.openVideoViewer(requireActivity(), mediaData)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = roomDetailArgs.roomId,
    +                mediaData = mediaData,
    +                view = view
    +        ) { pairs ->
    +            pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: ""))
    +            pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: ""))
    +        }
         }
     
     //    override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
    @@ -1196,7 +1212,7 @@ class RoomDetailFragment @Inject constructor(
         override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
             if (allGranted(grantResults)) {
                 when (requestCode) {
    -                SAVE_ATTACHEMENT_REQUEST_CODE -> {
    +                SAVE_ATTACHEMENT_REQUEST_CODE           -> {
                         sharedActionViewModel.pendingAction?.let {
                             handleActions(it)
                             sharedActionViewModel.pendingAction = null
    @@ -1337,13 +1353,13 @@ class RoomDetailFragment @Inject constructor(
     
         private fun onShareActionClicked(action: EventSharedAction.Share) {
             session.fileService().downloadFile(
    -                FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    -                action.eventId,
    -                action.messageContent.body,
    -                action.messageContent.getFileUrl(),
    -                action.messageContent.mimeType,
    -                action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
    -                object : MatrixCallback {
    +                downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                id = action.eventId,
    +                fileName = action.messageContent.body,
    +                mimeType = action.messageContent.mimeType,
    +                url = action.messageContent.getFileUrl(),
    +                elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
    +                callback = object : MatrixCallback {
                         override fun onSuccess(data: File) {
                             if (isAdded) {
                                 shareMedia(requireContext(), data, getMimeTypeFromUri(requireContext(), data.toUri()))
    @@ -1541,7 +1557,7 @@ class RoomDetailFragment @Inject constructor(
         }
     
         private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) {
    -        Snackbar.make(view!!, message, duration).show()
    +        Snackbar.make(requireView(), message, duration).show()
         }
     
         // VectorInviteView.Callback
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    index 62830a1c63..a396152f6b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    @@ -40,6 +40,7 @@ import im.vector.matrix.android.api.session.events.model.toContent
     import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.file.FileService
     import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.members.roomMemberQueryParams
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
    @@ -166,6 +167,7 @@ class RoomDetailViewModel @AssistedInject constructor(
             timeline.start()
             timeline.addListener(this)
             observeRoomSummary()
    +        observeMembershipChanges()
             observeSummaryState()
             getUnreadState()
             observeSyncState()
    @@ -405,17 +407,22 @@ class RoomDetailViewModel @AssistedInject constructor(
     
         private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
     
    -    fun isMenuItemVisible(@IdRes itemId: Int) = when (itemId) {
    -        R.id.clear_message_queue ->
    -            // For now always disable when not in developer mode, worker cancellation is not working properly
    -            timeline.pendingEventCount() > 0 && vectorPreferences.developerMode()
    -        R.id.resend_all          -> timeline.failedToDeliverEventCount() > 0
    -        R.id.clear_all           -> timeline.failedToDeliverEventCount() > 0
    -        R.id.open_matrix_apps    -> true
    -        R.id.voice_call,
    -        R.id.video_call          -> room.canStartCall() && webRtcPeerConnectionManager.currentCall == null
    -        R.id.hangup_call         -> webRtcPeerConnectionManager.currentCall != null
    -        else                     -> false
    +    fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->
    +        if (state.asyncRoomSummary()?.membership != Membership.JOIN) {
    +            return@withState false
    +        }
    +        when (itemId) {
    +            R.id.clear_message_queue ->
    +                // For now always disable when not in developer mode, worker cancellation is not working properly
    +                timeline.pendingEventCount() > 0 && vectorPreferences.developerMode()
    +            R.id.resend_all          -> timeline.failedToDeliverEventCount() > 0
    +            R.id.clear_all           -> timeline.failedToDeliverEventCount() > 0
    +            R.id.open_matrix_apps    -> true
    +            R.id.voice_call,
    +            R.id.video_call          -> room.canStartCall() && webRtcPeerConnectionManager.currentCall == null
    +            R.id.hangup_call         -> webRtcPeerConnectionManager.currentCall != null
    +            else                     -> false
    +        }
         }
     
     // PRIVATE METHODS *****************************************************************************
    @@ -450,6 +457,10 @@ class RoomDetailViewModel @AssistedInject constructor(
                                 handleInviteSlashCommand(slashCommandResult)
                                 popDraft()
                             }
    +                        is ParsedCommand.Invite3Pid               -> {
    +                            handleInvite3pidSlashCommand(slashCommandResult)
    +                            popDraft()
    +                        }
                             is ParsedCommand.SetUserPowerLevel        -> {
                                 handleSetUserPowerLevel(slashCommandResult)
                                 popDraft()
    @@ -624,7 +635,7 @@ class RoomDetailViewModel @AssistedInject constructor(
         }
     
         private fun handleJoinToAnotherRoomSlashCommand(command: ParsedCommand.JoinRoom) {
    -        session.joinRoom(command.roomAlias, command.reason, object : MatrixCallback {
    +        session.joinRoom(command.roomAlias, command.reason, emptyList(), object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
                     session.getRoomSummary(command.roomAlias)
                             ?.roomId
    @@ -671,6 +682,12 @@ class RoomDetailViewModel @AssistedInject constructor(
             }
         }
     
    +    private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) {
    +        launchSlashCommandFlow {
    +            room.invite3pid(invite.threePid, it)
    +        }
    +    }
    +
         private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) {
             val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)
                     ?.content
    @@ -846,17 +863,14 @@ class RoomDetailViewModel @AssistedInject constructor(
             }
         }
     
    -    private fun handleExitSpecialMode(action: RoomDetailAction.ExitSpecialMode) {
    -        setState { copy(sendMode = SendMode.REGULAR(action.text)) }
    -        withState { state ->
    -            // For edit, just delete the current draft
    -            if (state.sendMode is SendMode.EDIT) {
    -                room.deleteDraft(NoOpMatrixCallback())
    -            } else {
    -                // Save a new draft and keep the previously entered text
    -                room.saveDraft(UserDraft.REGULAR(action.text), NoOpMatrixCallback())
    -            }
    +    private fun handleExitSpecialMode(action: RoomDetailAction.ExitSpecialMode) = withState {
    +        if (it.sendMode is SendMode.EDIT) {
    +            room.deleteDraft(NoOpMatrixCallback())
    +        } else {
    +            // Save a new draft and keep the previously entered text
    +            room.saveDraft(UserDraft.REGULAR(action.text), NoOpMatrixCallback())
             }
    +        setState { copy(sendMode = SendMode.REGULAR(action.text)) }
         }
     
         private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
    @@ -873,13 +887,13 @@ class RoomDetailViewModel @AssistedInject constructor(
                 }
             } else {
                 session.fileService().downloadFile(
    -                    FileService.DownloadMode.FOR_INTERNAL_USE,
    -                    action.eventId,
    -                    action.messageFileContent.getFileName(),
    -                    action.messageFileContent.mimeType,
    -                    mxcUrl,
    -                    action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
    -                    object : MatrixCallback {
    +                    downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
    +                    id = action.eventId,
    +                    fileName = action.messageFileContent.getFileName(),
    +                    mimeType = action.messageFileContent.mimeType,
    +                    url = mxcUrl,
    +                    elementToDecrypt = action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
    +                    callback = object : MatrixCallback {
                             override fun onSuccess(data: File) {
                                 _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
                                         action.messageFileContent.mimeType,
    @@ -1145,6 +1159,19 @@ class RoomDetailViewModel @AssistedInject constructor(
             }
         }
     
    +    private fun observeMembershipChanges() {
    +        session.rx()
    +                .liveRoomChangeMembershipState()
    +                .map {
    +                    it[initialState.roomId] ?: ChangeMembershipState.Unknown
    +                }
    +                .distinctUntilChanged()
    +                .subscribe {
    +                    setState { copy(changeMembershipState = it) }
    +                }
    +                .disposeOnClear()
    +    }
    +
         private fun observeSummaryState() {
             asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
                 roomSummaryHolder.set(summary)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    index 224dd61b65..6800850c48 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    @@ -20,6 +20,7 @@ import com.airbnb.mvrx.Async
     import com.airbnb.mvrx.MvRxState
     import com.airbnb.mvrx.Uninitialized
     import im.vector.matrix.android.api.session.events.model.Event
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
    @@ -64,6 +65,7 @@ data class RoomDetailViewState(
             val highlightedEventId: String? = null,
             val unreadState: UnreadState = UnreadState.Unknown,
             val canShowJumpToReadMarker: Boolean = true,
    +        val changeMembershipState: ChangeMembershipState = ChangeMembershipState.Unknown,
             val canSendMessage: Boolean = true
     ) : MvRxState {
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    index 2174556098..4f5f34cbf0 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    @@ -337,7 +337,7 @@ class MessageItemFactory @Inject constructor(
                     .playable(true)
                     .highlighted(highlight)
                     .mediaData(thumbnailData)
    -                .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
    +                .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view.findViewById(R.id.messageThumbnailView)) }
         }
     
         private fun buildItemForTextContent(messageContent: MessageTextContent,
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
    index 22fd4eb5ec..72da87415c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
    @@ -50,6 +50,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
                     EventType.STATE_ROOM_TOPIC,
                     EventType.STATE_ROOM_AVATAR,
                     EventType.STATE_ROOM_MEMBER,
    +                EventType.STATE_ROOM_THIRD_PARTY_INVITE,
                     EventType.STATE_ROOM_ALIASES,
                     EventType.STATE_ROOM_CANONICAL_ALIAS,
                     EventType.STATE_ROOM_JOIN_RULES,
    @@ -96,8 +97,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
                         verificationConclusionItemFactory.create(event, highlight, callback)
                     }
     
    -                // Unhandled event types (yet)
    -                EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback)
    +                // Unhandled event types
                     else                                    -> {
                         // Should only happen when shouldShowHiddenEvents() settings is ON
                         Timber.v("Type ${event.root.getClearType()} not handled")
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
    index c1f4187e0b..032ad4fb62 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
    @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.RoomJoinRules
     import im.vector.matrix.android.api.session.room.model.RoomJoinRulesContent
     import im.vector.matrix.android.api.session.room.model.RoomMemberContent
     import im.vector.matrix.android.api.session.room.model.RoomNameContent
    +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
     import im.vector.matrix.android.api.session.room.model.RoomTopicContent
     import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
     import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent
    @@ -40,17 +41,20 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     import im.vector.matrix.android.api.session.widgets.model.WidgetContent
     import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
     import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent
    +import im.vector.riotx.ActiveSessionDataSource
     import im.vector.riotx.R
    -import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.resources.StringProvider
     import timber.log.Timber
     import javax.inject.Inject
     
    -class NoticeEventFormatter @Inject constructor(private val sessionHolder: ActiveSessionHolder,
    +class NoticeEventFormatter @Inject constructor(private val activeSessionDataSource: ActiveSessionDataSource,
                                                    private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter,
                                                    private val sp: StringProvider) {
     
    -    private fun Event.isSentByCurrentUser() = senderId != null && senderId == sessionHolder.getSafeActiveSession()?.myUserId
    +    private val currentUserId: String?
    +        get() = activeSessionDataSource.currentValue?.orNull()?.myUserId
    +
    +    private fun Event.isSentByCurrentUser() = senderId != null && senderId == currentUserId
     
         fun format(timelineEvent: TimelineEvent): CharSequence? {
             return when (val type = timelineEvent.root.getClearType()) {
    @@ -60,6 +64,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
                 EventType.STATE_ROOM_TOPIC              -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
                 EventType.STATE_ROOM_AVATAR             -> formatRoomAvatarEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
                 EventType.STATE_ROOM_MEMBER             -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
    +            EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
                 EventType.STATE_ROOM_ALIASES            -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
                 EventType.STATE_ROOM_CANONICAL_ALIAS    -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
                 EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
    @@ -92,7 +97,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
     
         private fun formatRoomPowerLevels(event: Event, disambiguatedDisplayName: String): CharSequence? {
             val powerLevelsContent: PowerLevelsContent = event.getClearContent().toModel() ?: return null
    -        val previousPowerLevelsContent: PowerLevelsContent = event.prevContent.toModel() ?: return null
    +        val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null
             val userIds = HashSet()
             userIds.addAll(powerLevelsContent.users.keys)
             userIds.addAll(previousPowerLevelsContent.users.keys)
    @@ -120,7 +125,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
     
         private fun formatWidgetEvent(event: Event, disambiguatedDisplayName: String): CharSequence? {
             val widgetContent: WidgetContent = event.getClearContent().toModel() ?: return null
    -        val previousWidgetContent: WidgetContent? = event.prevContent.toModel()
    +        val previousWidgetContent: WidgetContent? = event.resolvedPrevContent().toModel()
             return if (widgetContent.isActive()) {
                 val widgetName = widgetContent.getHumanName()
                 if (previousWidgetContent?.isActive().orFalse()) {
    @@ -153,6 +158,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
                 EventType.STATE_ROOM_TOPIC              -> formatRoomTopicEvent(event, senderName)
                 EventType.STATE_ROOM_AVATAR             -> formatRoomAvatarEvent(event, senderName)
                 EventType.STATE_ROOM_MEMBER             -> formatRoomMemberEvent(event, senderName)
    +            EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName)
                 EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName)
                 EventType.CALL_INVITE,
                 EventType.CALL_HANGUP,
    @@ -251,6 +257,31 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
             }
         }
     
    +    private fun formatRoomThirdPartyInvite(event: Event, senderName: String?): CharSequence? {
    +        val content = event.getClearContent().toModel()
    +        val prevContent = event.resolvedPrevContent()?.toModel()
    +
    +        return when {
    +            prevContent != null -> {
    +                // Revoke case
    +                if (event.isSentByCurrentUser()) {
    +                    sp.getString(R.string.notice_room_third_party_revoked_invite_by_you, prevContent.displayName)
    +                } else {
    +                    sp.getString(R.string.notice_room_third_party_revoked_invite, senderName, prevContent.displayName)
    +                }
    +            }
    +            content != null     -> {
    +                // Invitation case
    +                if (event.isSentByCurrentUser()) {
    +                    sp.getString(R.string.notice_room_third_party_invite_by_you, content.displayName)
    +                } else {
    +                    sp.getString(R.string.notice_room_third_party_invite, senderName, content.displayName)
    +                }
    +            }
    +            else                -> null
    +        }
    +    }
    +
         private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? {
             return when (type) {
                 EventType.CALL_INVITE     -> {
    @@ -294,7 +325,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
     
         private fun formatRoomMemberEvent(event: Event, senderName: String?): String? {
             val eventContent: RoomMemberContent? = event.getClearContent().toModel()
    -        val prevEventContent: RoomMemberContent? = event.prevContent.toModel()
    +        val prevEventContent: RoomMemberContent? = event.resolvedPrevContent().toModel()
             val isMembershipEvent = prevEventContent?.membership != eventContent?.membership
             return if (isMembershipEvent) {
                 buildMembershipNotice(event, senderName, eventContent, prevEventContent)
    @@ -305,7 +336,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
     
         private fun formatRoomAliasesEvent(event: Event, senderName: String?): String? {
             val eventContent: RoomAliasesContent? = event.getClearContent().toModel()
    -        val prevEventContent: RoomAliasesContent? = event.unsignedData?.prevContent?.toModel()
    +        val prevEventContent: RoomAliasesContent? = event.resolvedPrevContent()?.toModel()
     
             val addedAliases = eventContent?.aliases.orEmpty() - prevEventContent?.aliases.orEmpty()
             val removedAliases = prevEventContent?.aliases.orEmpty() - eventContent?.aliases.orEmpty()
    @@ -449,7 +480,6 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
             val targetDisplayName = eventContent?.displayName ?: prevEventContent?.displayName ?: event.stateKey ?: ""
             return when (eventContent?.membership) {
                 Membership.INVITE -> {
    -                val selfUserId = sessionHolder.getSafeActiveSession()?.myUserId
                     when {
                         eventContent.thirdPartyInvite != null -> {
                             val userWhoHasAccepted = eventContent.thirdPartyInvite?.signed?.mxid ?: event.stateKey
    @@ -466,7 +496,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
                                 sp.getString(R.string.notice_room_third_party_registered_invite, userWhoHasAccepted, threePidDisplayName)
                             }
                         }
    -                    event.stateKey == selfUserId          ->
    +                    event.stateKey == currentUserId       ->
                             eventContent.safeReason?.let { reason ->
                                 sp.getString(R.string.notice_room_invite_you_with_reason, senderDisplayName, reason)
                             } ?: sp.getString(R.string.notice_room_invite_you, senderDisplayName)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt
    index c52b863658..bee3ca6c5b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt
    @@ -22,6 +22,7 @@ import android.view.View
     import android.widget.ImageView
     import android.widget.LinearLayout
     import android.widget.TextView
    +import androidx.core.content.withStyledAttributes
     import butterknife.BindView
     import butterknife.ButterKnife
     import im.vector.riotx.R
    @@ -73,11 +74,11 @@ class PollResultLineView @JvmOverloads constructor(
             orientation = HORIZONTAL
             ButterKnife.bind(this)
     
    -        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PollResultLineView, 0, 0)
    -        label = typedArray.getString(R.styleable.PollResultLineView_optionName) ?: ""
    -        percent = typedArray.getString(R.styleable.PollResultLineView_optionCount) ?: ""
    -        optionSelected = typedArray.getBoolean(R.styleable.PollResultLineView_optionSelected, false)
    -        isWinner = typedArray.getBoolean(R.styleable.PollResultLineView_optionIsWinner, false)
    -        typedArray.recycle()
    +        context.withStyledAttributes(attrs, R.styleable.PollResultLineView) {
    +            label = getString(R.styleable.PollResultLineView_optionName) ?: ""
    +            percent = getString(R.styleable.PollResultLineView_optionCount) ?: ""
    +            optionSelected = getBoolean(R.styleable.PollResultLineView_optionSelected, false)
    +            isWinner = getBoolean(R.styleable.PollResultLineView_optionIsWinner, false)
    +        }
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    index 4e4e758aa2..7338a46d8a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    @@ -19,9 +19,9 @@ package im.vector.riotx.features.home.room.list
     import android.view.ViewGroup
     import android.widget.ImageView
     import android.widget.TextView
    -import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    @@ -29,6 +29,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel
     import im.vector.riotx.core.extensions.setTextOrHide
     import im.vector.riotx.core.platform.ButtonStateView
     import im.vector.riotx.features.home.AvatarRenderer
    +import im.vector.riotx.features.invite.InviteButtonStateBinder
     
     @EpoxyModelClass(layout = R.layout.item_room_invitation)
     abstract class RoomInvitationItem : VectorEpoxyModel() {
    @@ -37,53 +38,36 @@ abstract class RoomInvitationItem : VectorEpoxyModel(
         @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var secondLine: CharSequence? = null
         @EpoxyAttribute var listener: (() -> Unit)? = null
    -    @EpoxyAttribute var invitationAcceptInProgress: Boolean = false
    -    @EpoxyAttribute var invitationAcceptInError: Boolean = false
    -    @EpoxyAttribute var invitationRejectInProgress: Boolean = false
    -    @EpoxyAttribute var invitationRejectInError: Boolean = false
    +    @EpoxyAttribute lateinit var changeMembershipState: ChangeMembershipState
         @EpoxyAttribute var acceptListener: (() -> Unit)? = null
         @EpoxyAttribute var rejectListener: (() -> Unit)? = null
     
    +    private val acceptCallback = object : ButtonStateView.Callback {
    +        override fun onButtonClicked() {
    +            acceptListener?.invoke()
    +        }
    +
    +        override fun onRetryClicked() {
    +            acceptListener?.invoke()
    +        }
    +    }
    +
    +    private val rejectCallback = object : ButtonStateView.Callback {
    +        override fun onButtonClicked() {
    +            rejectListener?.invoke()
    +        }
    +
    +        override fun onRetryClicked() {
    +            rejectListener?.invoke()
    +        }
    +    }
    +
         override fun bind(holder: Holder) {
             super.bind(holder)
             holder.rootView.setOnClickListener { listener?.invoke() }
    -
    -        // When a request is in progress (accept or reject), we only use the accept State button
    -        val requestInProgress = invitationAcceptInProgress || invitationRejectInProgress
    -
    -        when {
    -            requestInProgress       -> holder.acceptView.render(ButtonStateView.State.Loading)
    -            invitationAcceptInError -> holder.acceptView.render(ButtonStateView.State.Error)
    -            else                    -> holder.acceptView.render(ButtonStateView.State.Button)
    -        }
    -        // ButtonStateView.State.Loaded not used because roomSummary will not be displayed as a room invitation anymore
    -
    -        holder.acceptView.callback = object : ButtonStateView.Callback {
    -            override fun onButtonClicked() {
    -                acceptListener?.invoke()
    -            }
    -
    -            override fun onRetryClicked() {
    -                acceptListener?.invoke()
    -            }
    -        }
    -
    -        holder.rejectView.isVisible = !requestInProgress
    -
    -        when {
    -            invitationRejectInError -> holder.rejectView.render(ButtonStateView.State.Error)
    -            else                    -> holder.rejectView.render(ButtonStateView.State.Button)
    -        }
    -
    -        holder.rejectView.callback = object : ButtonStateView.Callback {
    -            override fun onButtonClicked() {
    -                rejectListener?.invoke()
    -            }
    -
    -            override fun onRetryClicked() {
    -                rejectListener?.invoke()
    -            }
    -        }
    +        holder.acceptView.callback = acceptCallback
    +        holder.rejectView.callback = rejectCallback
    +        InviteButtonStateBinder.bind(holder.acceptView, holder.rejectView, changeMembershipState)
             holder.titleView.text = matrixItem.getBestName()
             holder.subtitleView.setTextOrHide(secondLine)
             avatarRenderer.render(matrixItem, holder.avatarImageView)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListDisplayModeFilter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListDisplayModeFilter.kt
    index 3045987d01..dd75deb8ee 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListDisplayModeFilter.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListDisplayModeFilter.kt
    @@ -28,9 +28,9 @@ class RoomListDisplayModeFilter(private val displayMode: RoomListDisplayMode) :
                 return false
             }
             return when (displayMode) {
    -            RoomListDisplayMode.HOME     ->
    +            RoomListDisplayMode.NOTIFICATIONS ->
                     roomSummary.notificationCount > 0 || roomSummary.membership == Membership.INVITE || roomSummary.userDrafts.isNotEmpty()
    -            RoomListDisplayMode.PEOPLE   -> roomSummary.isDirect && roomSummary.membership.isActive()
    +            RoomListDisplayMode.PEOPLE        -> roomSummary.isDirect && roomSummary.membership.isActive()
                 RoomListDisplayMode.ROOMS    -> !roomSummary.isDirect && roomSummary.membership.isActive()
                 RoomListDisplayMode.FILTERED -> roomSummary.membership == Membership.JOIN
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    index b31117f18f..2858097e24 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    @@ -138,8 +138,8 @@ class RoomListFragment @Inject constructor(
     
         private fun setupCreateRoomButton() {
             when (roomListParams.displayMode) {
    -            RoomListDisplayMode.HOME   -> createChatFabMenu.isVisible = true
    -            RoomListDisplayMode.PEOPLE -> createChatRoomButton.isVisible = true
    +            RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.isVisible = true
    +            RoomListDisplayMode.PEOPLE        -> createChatRoomButton.isVisible = true
                 RoomListDisplayMode.ROOMS  -> createGroupRoomButton.isVisible = true
                 else                       -> Unit // No button in this mode
             }
    @@ -164,8 +164,8 @@ class RoomListFragment @Inject constructor(
                                 RecyclerView.SCROLL_STATE_DRAGGING,
                                 RecyclerView.SCROLL_STATE_SETTLING -> {
                                     when (roomListParams.displayMode) {
    -                                    RoomListDisplayMode.HOME   -> createChatFabMenu.hide()
    -                                    RoomListDisplayMode.PEOPLE -> createChatRoomButton.hide()
    +                                    RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.hide()
    +                                    RoomListDisplayMode.PEOPLE        -> createChatRoomButton.hide()
                                         RoomListDisplayMode.ROOMS  -> createGroupRoomButton.hide()
                                         else                       -> Unit
                                     }
    @@ -207,8 +207,8 @@ class RoomListFragment @Inject constructor(
         private val showFabRunnable = Runnable {
             if (isAdded) {
                 when (roomListParams.displayMode) {
    -                RoomListDisplayMode.HOME   -> createChatFabMenu.show()
    -                RoomListDisplayMode.PEOPLE -> createChatRoomButton.show()
    +                RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.show()
    +                RoomListDisplayMode.PEOPLE        -> createChatRoomButton.show()
                     RoomListDisplayMode.ROOMS  -> createGroupRoomButton.show()
                     else                       -> Unit
                 }
    @@ -258,7 +258,7 @@ class RoomListFragment @Inject constructor(
             roomController.update(state)
             // Mark all as read menu
             when (roomListParams.displayMode) {
    -            RoomListDisplayMode.HOME,
    +            RoomListDisplayMode.NOTIFICATIONS,
                 RoomListDisplayMode.PEOPLE,
                 RoomListDisplayMode.ROOMS -> {
                     val newValue = state.hasUnread
    @@ -288,7 +288,7 @@ class RoomListFragment @Inject constructor(
                     }
                     .isNullOrEmpty()
             val emptyState = when (roomListParams.displayMode) {
    -            RoomListDisplayMode.HOME   -> {
    +            RoomListDisplayMode.NOTIFICATIONS -> {
                     if (hasNoRoom) {
                         StateView.State.Empty(
                                 getString(R.string.room_list_catchup_welcome_title),
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewModel.kt
    index a2de7c79a0..cfc76b61a8 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewModel.kt
    @@ -21,10 +21,12 @@ import com.airbnb.mvrx.MvRxViewModelFactory
     import com.airbnb.mvrx.ViewModelContext
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.NoOpMatrixCallback
    +import im.vector.matrix.android.api.extensions.orFalse
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.session.room.model.tag.RoomTag
    +import im.vector.matrix.rx.rx
     import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.core.utils.DataSource
    @@ -55,6 +57,7 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
     
         init {
             observeRoomSummaries()
    +        observeMembershipChanges()
         }
     
         override fun handle(action: RoomListAction) {
    @@ -102,37 +105,19 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
                     .observeOn(Schedulers.computation())
                     .map { buildRoomSummaries(it) }
                     .execute { async ->
    -                    val invitedRooms = async()?.get(RoomCategory.INVITE)?.map { it.roomId }.orEmpty()
    -                    val remainingJoining = joiningRoomsIds.intersect(invitedRooms)
    -                    val remainingJoinErrors = joiningErrorRoomsIds.intersect(invitedRooms)
    -                    val remainingRejecting = rejectingRoomsIds.intersect(invitedRooms)
    -                    val remainingRejectErrors = rejectingErrorRoomsIds.intersect(invitedRooms)
    -                    copy(
    -                            asyncFilteredRooms = async,
    -                            joiningRoomsIds = remainingJoining,
    -                            joiningErrorRoomsIds = remainingJoinErrors,
    -                            rejectingRoomsIds = remainingRejecting,
    -                            rejectingErrorRoomsIds = remainingRejectErrors
    -                    )
    +                    copy(asyncFilteredRooms = async)
                     }
         }
     
         private fun handleAcceptInvitation(action: RoomListAction.AcceptInvitation) = withState { state ->
             val roomId = action.roomSummary.roomId
    -
    -        if (state.joiningRoomsIds.contains(roomId) || state.rejectingRoomsIds.contains(roomId)) {
    +        val roomMembershipChange = state.roomMembershipChanges[roomId]
    +        if (roomMembershipChange?.isInProgress().orFalse()) {
                 // Request already sent, should not happen
                 Timber.w("Try to join an already joining room. Should not happen")
                 return@withState
             }
     
    -        setState {
    -            copy(
    -                    joiningRoomsIds = joiningRoomsIds + roomId,
    -                    rejectingErrorRoomsIds = rejectingErrorRoomsIds - roomId
    -            )
    -        }
    -
             session.getRoom(roomId)?.join(callback = object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
                     // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
    @@ -142,32 +127,19 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
                 override fun onFailure(failure: Throwable) {
                     // Notify the user
                     _viewEvents.post(RoomListViewEvents.Failure(failure))
    -                setState {
    -                    copy(
    -                            joiningRoomsIds = joiningRoomsIds - roomId,
    -                            joiningErrorRoomsIds = joiningErrorRoomsIds + roomId
    -                    )
    -                }
                 }
             })
         }
     
         private fun handleRejectInvitation(action: RoomListAction.RejectInvitation) = withState { state ->
             val roomId = action.roomSummary.roomId
    -
    -        if (state.joiningRoomsIds.contains(roomId) || state.rejectingRoomsIds.contains(roomId)) {
    +        val roomMembershipChange = state.roomMembershipChanges[roomId]
    +        if (roomMembershipChange?.isInProgress().orFalse()) {
                 // Request already sent, should not happen
    -            Timber.w("Try to reject an already rejecting room. Should not happen")
    +            Timber.w("Try to left an already leaving or joining room. Should not happen")
                 return@withState
             }
     
    -        setState {
    -            copy(
    -                    rejectingRoomsIds = rejectingRoomsIds + roomId,
    -                    joiningErrorRoomsIds = joiningErrorRoomsIds - roomId
    -            )
    -        }
    -
             session.getRoom(roomId)?.leave(null, object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
                     // We do not update the rejectingRoomsIds here, because, the room is not rejected yet regarding the sync data.
    @@ -179,12 +151,6 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
                 override fun onFailure(failure: Throwable) {
                     // Notify the user
                     _viewEvents.post(RoomListViewEvents.Failure(failure))
    -                setState {
    -                    copy(
    -                            rejectingRoomsIds = rejectingRoomsIds - roomId,
    -                            rejectingErrorRoomsIds = rejectingErrorRoomsIds + roomId
    -                    )
    -                }
                 }
             })
         }
    @@ -235,6 +201,16 @@ class RoomListViewModel @Inject constructor(initialState: RoomListViewState,
             })
         }
     
    +    private fun observeMembershipChanges() {
    +        session.rx()
    +                .liveRoomChangeMembershipState()
    +                .subscribe {
    +                    Timber.v("ChangeMembership states: $it")
    +                    setState { copy(roomMembershipChanges = it) }
    +                }
    +                .disposeOnClear()
    +    }
    +
         private fun buildRoomSummaries(rooms: List): RoomSummaries {
             // Set up init size on directChats and groupRooms as they are the biggest ones
             val invites = ArrayList()
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewState.kt
    index b41b4b9eeb..63f0cf2a1a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListViewState.kt
    @@ -20,6 +20,7 @@ import androidx.annotation.StringRes
     import com.airbnb.mvrx.Async
     import com.airbnb.mvrx.MvRxState
     import com.airbnb.mvrx.Uninitialized
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.riotx.R
    @@ -30,14 +31,7 @@ data class RoomListViewState(
             val asyncRooms: Async> = Uninitialized,
             val roomFilter: String = "",
             val asyncFilteredRooms: Async = Uninitialized,
    -        // List of roomIds that the user wants to join
    -        val joiningRoomsIds: Set = emptySet(),
    -        // List of roomIds that the user wants to join, but an error occurred
    -        val joiningErrorRoomsIds: Set = emptySet(),
    -        // List of roomIds that the user wants to join
    -        val rejectingRoomsIds: Set = emptySet(),
    -        // List of roomIds that the user wants to reject, but an error occurred
    -        val rejectingErrorRoomsIds: Set = emptySet(),
    +        val roomMembershipChanges: Map = emptyMap(),
             val isInviteExpanded: Boolean = true,
             val isFavouriteRoomsExpanded: Boolean = true,
             val isDirectRoomsExpanded: Boolean = true,
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt
    index b06cb8a4bb..efa19d012b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt
    @@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.list
     
     import androidx.annotation.StringRes
     import com.airbnb.epoxy.EpoxyController
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.riotx.R
    @@ -72,10 +73,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
                     .filter { it.membership == Membership.JOIN && roomListNameFilter.test(it) }
     
             buildRoomModels(filteredSummaries,
    -                viewState.joiningRoomsIds,
    -                viewState.joiningErrorRoomsIds,
    -                viewState.rejectingRoomsIds,
    -                viewState.rejectingErrorRoomsIds,
    +                viewState.roomMembershipChanges,
                     emptySet())
     
             addFilterFooter(viewState)
    @@ -94,10 +92,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
                     }
                     if (isExpanded) {
                         buildRoomModels(summaries,
    -                            viewState.joiningRoomsIds,
    -                            viewState.joiningErrorRoomsIds,
    -                            viewState.rejectingRoomsIds,
    -                            viewState.rejectingErrorRoomsIds,
    +                            viewState.roomMembershipChanges,
                                 emptySet())
                         // Never set showHelp to true for invitation
                         if (category != RoomCategory.INVITE) {
    @@ -153,18 +148,12 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
         }
     
         private fun buildRoomModels(summaries: List,
    -                                joiningRoomsIds: Set,
    -                                joiningErrorRoomsIds: Set,
    -                                rejectingRoomsIds: Set,
    -                                rejectingErrorRoomsIds: Set,
    +                                roomChangedMembershipStates: Map,
                                     selectedRoomIds: Set) {
             summaries.forEach { roomSummary ->
                 roomSummaryItemFactory
                         .create(roomSummary,
    -                            joiningRoomsIds,
    -                            joiningErrorRoomsIds,
    -                            rejectingRoomsIds,
    -                            rejectingErrorRoomsIds,
    +                            roomChangedMembershipStates,
                                 selectedRoomIds,
                                 listener)
                         .addTo(this)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    index 1830899d80..f33166504d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    @@ -17,6 +17,7 @@
     package im.vector.riotx.features.home.room.list
     
     import android.view.View
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.util.toMatrixItem
    @@ -39,23 +40,20 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
                                                      private val avatarRenderer: AvatarRenderer) {
     
         fun create(roomSummary: RoomSummary,
    -               joiningRoomsIds: Set,
    -               joiningErrorRoomsIds: Set,
    -               rejectingRoomsIds: Set,
    -               rejectingErrorRoomsIds: Set,
    +               roomChangeMembershipStates: Map,
                    selectedRoomIds: Set,
                    listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
             return when (roomSummary.membership) {
    -            Membership.INVITE -> createInvitationItem(roomSummary, joiningRoomsIds, joiningErrorRoomsIds, rejectingRoomsIds, rejectingErrorRoomsIds, listener)
    +            Membership.INVITE -> {
    +                val changeMembershipState = roomChangeMembershipStates[roomSummary.roomId] ?: ChangeMembershipState.Unknown
    +                createInvitationItem(roomSummary, changeMembershipState, listener)
    +            }
                 else              -> createRoomItem(roomSummary, selectedRoomIds, listener?.let { it::onRoomClicked }, listener?.let { it::onRoomLongClicked })
             }
         }
     
    -    fun createInvitationItem(roomSummary: RoomSummary,
    -                             joiningRoomsIds: Set,
    -                             joiningErrorRoomsIds: Set,
    -                             rejectingRoomsIds: Set,
    -                             rejectingErrorRoomsIds: Set,
    +    private fun createInvitationItem(roomSummary: RoomSummary,
    +                             changeMembershipState: ChangeMembershipState,
                                  listener: RoomSummaryController.Listener?): VectorEpoxyModel<*> {
             val secondLine = if (roomSummary.isDirect) {
                 roomSummary.inviterId
    @@ -70,10 +68,7 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
                     .avatarRenderer(avatarRenderer)
                     .matrixItem(roomSummary.toMatrixItem())
                     .secondLine(secondLine)
    -                .invitationAcceptInProgress(joiningRoomsIds.contains(roomSummary.roomId))
    -                .invitationAcceptInError(joiningErrorRoomsIds.contains(roomSummary.roomId))
    -                .invitationRejectInProgress(rejectingRoomsIds.contains(roomSummary.roomId))
    -                .invitationRejectInError(rejectingErrorRoomsIds.contains(roomSummary.roomId))
    +                .changeMembershipState(changeMembershipState)
                     .acceptListener { listener?.onAcceptRoomInvitation(roomSummary) }
                     .rejectListener { listener?.onRejectRoomInvitation(roomSummary) }
                     .listener { listener?.onRoomClicked(roomSummary) }
    diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteButtonStateBinder.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteButtonStateBinder.kt
    new file mode 100644
    index 0000000000..88abf28888
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteButtonStateBinder.kt
    @@ -0,0 +1,48 @@
    +/*
    + * 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.invite
    +
    +import androidx.core.view.isInvisible
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
    +import im.vector.riotx.core.platform.ButtonStateView
    +
    +object InviteButtonStateBinder {
    +
    +    fun bind(
    +            acceptView: ButtonStateView,
    +            rejectView: ButtonStateView,
    +            changeMembershipState: ChangeMembershipState
    +    ) {
    +        // When a request is in progress (accept or reject), we only use the accept State button
    +        // We check for isSuccessful, otherwise we get a glitch the time room summaries get rebuilt
    +
    +        val requestInProgress = changeMembershipState.isInProgress() || changeMembershipState.isSuccessful()
    +        when {
    +            requestInProgress                                            -> acceptView.render(ButtonStateView.State.Loading)
    +            changeMembershipState is ChangeMembershipState.FailedJoining -> acceptView.render(ButtonStateView.State.Error)
    +            else                                                         -> acceptView.render(ButtonStateView.State.Button)
    +        }
    +        // ButtonStateView.State.Loaded not used because roomSummary will not be displayed as a room invitation anymore
    +
    +        rejectView.isInvisible = requestInProgress
    +
    +        when {
    +            changeMembershipState is ChangeMembershipState.FailedLeaving -> rejectView.render(ButtonStateView.State.Error)
    +            else                                                         -> rejectView.render(ButtonStateView.State.Button)
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt
    index 8a62935bdd..6c059c917f 100644
    --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt
    @@ -16,9 +16,9 @@
     
     package im.vector.riotx.features.invite
     
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.riotx.core.platform.VectorViewModelAction
    +import im.vector.riotx.features.userdirectory.PendingInvitee
     
     sealed class InviteUsersToRoomAction : VectorViewModelAction {
    -    data class InviteSelectedUsers(val selectedUsers: Set) : InviteUsersToRoomAction()
    +    data class InviteSelectedUsers(val invitees: Set) : InviteUsersToRoomAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt
    index 839a0767d8..af78457d96 100644
    --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt
    @@ -30,9 +30,16 @@ import im.vector.riotx.core.di.ScreenComponent
     import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.extensions.addFragment
     import im.vector.riotx.core.extensions.addFragmentToBackstack
    +import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.SimpleFragmentActivity
     import im.vector.riotx.core.platform.WaitingViewData
    +import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
    +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
    +import im.vector.riotx.core.utils.allGranted
    +import im.vector.riotx.core.utils.checkPermissions
     import im.vector.riotx.core.utils.toast
    +import im.vector.riotx.features.contactsbook.ContactsBookFragment
    +import im.vector.riotx.features.contactsbook.ContactsBookViewModel
     import im.vector.riotx.features.userdirectory.KnownUsersFragment
     import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
     import im.vector.riotx.features.userdirectory.UserDirectoryFragment
    @@ -53,6 +60,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
         private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
         @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
         @Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory
    +    @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
         @Inject lateinit var errorFormatter: ErrorFormatter
     
         override fun injectWith(injector: ScreenComponent) {
    @@ -74,7 +82,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
                             UserDirectorySharedAction.Close                 -> finish()
                             UserDirectorySharedAction.GoBack                -> onBackPressed()
                             is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
    -                    }
    +                        UserDirectorySharedAction.OpenPhoneBook         -> openPhoneBook()
    +                    }.exhaustive
                     }
                     .disposeOnDestroy()
             if (isFirstCreation()) {
    @@ -92,9 +101,27 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
             viewModel.observeViewEvents { renderInviteEvents(it) }
         }
     
    +    private fun openPhoneBook() {
    +        // Check permission first
    +        if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
    +                        this,
    +                        PERMISSION_REQUEST_CODE_READ_CONTACTS,
    +                        0)) {
    +            addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
    +        }
    +    }
    +
    +    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    +        if (allGranted(grantResults)) {
    +            if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) {
    +                addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
    +            }
    +        }
    +    }
    +
         private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
             if (action.itemId == R.id.action_invite_users_to_room_invite) {
    -            viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selectedUsers))
    +            viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees))
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt
    index fc2f34b7a0..2769dc56bb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt
    @@ -22,11 +22,11 @@ import com.airbnb.mvrx.ViewModelContext
     import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.session.Session
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.matrix.rx.rx
     import im.vector.riotx.R
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.core.resources.StringProvider
    +import im.vector.riotx.features.userdirectory.PendingInvitee
     import io.reactivex.Observable
     
     class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
    @@ -53,27 +53,30 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
     
         override fun handle(action: InviteUsersToRoomAction) {
             when (action) {
    -            is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers)
    +            is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees)
             }
         }
     
    -    private fun inviteUsersToRoom(selectedUsers: Set) {
    +    private fun inviteUsersToRoom(invitees: Set) {
             _viewEvents.post(InviteUsersToRoomViewEvents.Loading)
     
    -        Observable.fromIterable(selectedUsers).flatMapCompletable { user ->
    -            room.rx().invite(user.userId, null)
    +        Observable.fromIterable(invitees).flatMapCompletable { user ->
    +            when (user) {
    +                is PendingInvitee.UserPendingInvitee     -> room.rx().invite(user.user.userId, null)
    +                is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid)
    +            }
             }.subscribe(
                     {
    -                    val successMessage = when (selectedUsers.size) {
    +                    val successMessage = when (invitees.size) {
                             1    -> stringProvider.getString(R.string.invitation_sent_to_one_user,
    -                                selectedUsers.first().getBestName())
    +                                invitees.first().getBestName())
                             2    -> stringProvider.getString(R.string.invitations_sent_to_two_users,
    -                                selectedUsers.first().getBestName(),
    -                                selectedUsers.last().getBestName())
    +                                invitees.first().getBestName(),
    +                                invitees.last().getBestName())
                             else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
    -                                selectedUsers.size - 1,
    -                                selectedUsers.first().getBestName(),
    -                                selectedUsers.size - 1)
    +                                invitees.size - 1,
    +                                invitees.first().getBestName(),
    +                                invitees.size - 1)
                         }
                         _viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
                     },
    diff --git a/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt b/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt
    index b9bd9b0e1e..42f440fc30 100644
    --- a/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt
    @@ -21,10 +21,12 @@ import android.util.AttributeSet
     import android.view.View
     import androidx.constraintlayout.widget.ConstraintLayout
     import androidx.core.view.updateLayoutParams
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.user.model.User
     import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.di.HasScreenInjector
    +import im.vector.riotx.core.platform.ButtonStateView
     import im.vector.riotx.features.home.AvatarRenderer
     import kotlinx.android.synthetic.main.vector_invite_view.view.*
     import javax.inject.Inject
    @@ -50,11 +52,28 @@ class VectorInviteView @JvmOverloads constructor(context: Context, attrs: Attrib
                 context.injector().inject(this)
             }
             View.inflate(context, R.layout.vector_invite_view, this)
    -        inviteRejectView.setOnClickListener { callback?.onRejectInvite() }
    -        inviteAcceptView.setOnClickListener { callback?.onAcceptInvite() }
    +        inviteAcceptView.callback = object : ButtonStateView.Callback {
    +            override fun onButtonClicked() {
    +                callback?.onAcceptInvite()
    +            }
    +
    +            override fun onRetryClicked() {
    +                callback?.onAcceptInvite()
    +            }
    +        }
    +
    +        inviteRejectView.callback = object : ButtonStateView.Callback {
    +            override fun onButtonClicked() {
    +                callback?.onRejectInvite()
    +            }
    +
    +            override fun onRetryClicked() {
    +                callback?.onRejectInvite()
    +            }
    +        }
         }
     
    -    fun render(sender: User, mode: Mode = Mode.LARGE) {
    +    fun render(sender: User, mode: Mode = Mode.LARGE, changeMembershipState: ChangeMembershipState) {
             if (mode == Mode.LARGE) {
                 updateLayoutParams { height = LayoutParams.MATCH_CONSTRAINT }
                 avatarRenderer.render(sender.toMatrixItem(), inviteAvatarView)
    @@ -68,5 +87,6 @@ class VectorInviteView @JvmOverloads constructor(context: Context, attrs: Attrib
                 inviteNameView.visibility = View.GONE
                 inviteLabelView.text = context.getString(R.string.invited_by, sender.userId)
             }
    +        InviteButtonStateBinder.bind(inviteAcceptView, inviteRejectView, changeMembershipState)
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt
    index 7edc674b11..071e23c252 100644
    --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt
    @@ -49,9 +49,6 @@ import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.core.resources.StringProvider
     import im.vector.riotx.core.utils.ensureTrailingSlash
    -import im.vector.riotx.features.call.WebRtcPeerConnectionManager
    -import im.vector.riotx.features.notifications.PushRuleTriggerListener
    -import im.vector.riotx.features.session.SessionListener
     import im.vector.riotx.features.signout.soft.SoftLogoutActivity
     import timber.log.Timber
     import java.util.concurrent.CancellationException
    @@ -64,13 +61,10 @@ class LoginViewModel @AssistedInject constructor(
             private val applicationContext: Context,
             private val authenticationService: AuthenticationService,
             private val activeSessionHolder: ActiveSessionHolder,
    -        private val pushRuleTriggerListener: PushRuleTriggerListener,
             private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory,
    -        private val sessionListener: SessionListener,
             private val reAuthHelper: ReAuthHelper,
    -        private val stringProvider: StringProvider,
    -        private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager)
    -    : VectorViewModel(initialState) {
    +        private val stringProvider: StringProvider
    +) : VectorViewModel(initialState) {
     
         @AssistedInject.Factory
         interface Factory {
    @@ -667,8 +661,7 @@ class LoginViewModel @AssistedInject constructor(
     
         private fun onSessionCreated(session: Session) {
             activeSessionHolder.setActiveSession(session)
    -        session.configureAndStart(applicationContext, pushRuleTriggerListener, sessionListener)
    -        session.callSignalingService().addCallListener(webRtcPeerConnectionManager)
    +        session.configureAndStart(applicationContext)
             setState {
                 copy(
                         asyncLoginAction = Success(Unit)
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt b/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt
    new file mode 100644
    index 0000000000..2812b011f9
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/AttachmentOverlayView.kt
    @@ -0,0 +1,107 @@
    +/*
    + * 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.media
    +
    +import android.content.Context
    +import android.graphics.Color
    +import android.util.AttributeSet
    +import android.view.View
    +import android.widget.ImageView
    +import android.widget.SeekBar
    +import android.widget.TextView
    +import androidx.constraintlayout.widget.ConstraintLayout
    +import androidx.constraintlayout.widget.Group
    +import im.vector.riotx.R
    +import im.vector.riotx.attachmentviewer.AttachmentEventListener
    +import im.vector.riotx.attachmentviewer.AttachmentEvents
    +
    +class AttachmentOverlayView @JvmOverloads constructor(
    +        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    +) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener {
    +
    +    var onShareCallback: (() -> Unit)? = null
    +    var onBack: (() -> Unit)? = null
    +    var onPlayPause: ((play: Boolean) -> Unit)? = null
    +    var videoSeekTo: ((progress: Int) -> Unit)? = null
    +
    +    private val counterTextView: TextView
    +    private val infoTextView: TextView
    +    private val shareImage: ImageView
    +    private val overlayPlayPauseButton: ImageView
    +    private val overlaySeekBar: SeekBar
    +
    +    var isPlaying = false
    +
    +    val videoControlsGroup: Group
    +
    +    var suspendSeekBarUpdate = false
    +
    +    init {
    +        View.inflate(context, R.layout.merge_image_attachment_overlay, this)
    +        setBackgroundColor(Color.TRANSPARENT)
    +        counterTextView = findViewById(R.id.overlayCounterText)
    +        infoTextView = findViewById(R.id.overlayInfoText)
    +        shareImage = findViewById(R.id.overlayShareButton)
    +        videoControlsGroup = findViewById(R.id.overlayVideoControlsGroup)
    +        overlayPlayPauseButton = findViewById(R.id.overlayPlayPauseButton)
    +        overlaySeekBar = findViewById(R.id.overlaySeekBar)
    +        findViewById(R.id.overlayBackButton).setOnClickListener {
    +            onBack?.invoke()
    +        }
    +        findViewById(R.id.overlayShareButton).setOnClickListener {
    +            onShareCallback?.invoke()
    +        }
    +        findViewById(R.id.overlayPlayPauseButton).setOnClickListener {
    +            onPlayPause?.invoke(!isPlaying)
    +        }
    +
    +        overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
    +            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
    +                if (fromUser) {
    +                    videoSeekTo?.invoke(progress)
    +                }
    +            }
    +
    +            override fun onStartTrackingTouch(seekBar: SeekBar?) {
    +                suspendSeekBarUpdate = true
    +            }
    +
    +            override fun onStopTrackingTouch(seekBar: SeekBar?) {
    +                suspendSeekBarUpdate = false
    +            }
    +        })
    +    }
    +
    +    fun updateWith(counter: String, senderInfo: String) {
    +        counterTextView.text = counter
    +        infoTextView.text = senderInfo
    +    }
    +
    +    override fun onEvent(event: AttachmentEvents) {
    +        when (event) {
    +            is AttachmentEvents.VideoEvent -> {
    +                overlayPlayPauseButton.setImageResource(if (!event.isPlaying) R.drawable.ic_play_arrow else R.drawable.ic_pause)
    +                if (!suspendSeekBarUpdate) {
    +                    val safeDuration = (if (event.duration == 0) 100 else event.duration).toFloat()
    +                    val percent = ((event.progress / safeDuration) * 100f).toInt().coerceAtMost(100)
    +                    isPlaying = event.isPlaying
    +                    overlaySeekBar.progress = percent
    +                }
    +            }
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt
    new file mode 100644
    index 0000000000..d4c41c7cb3
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/BaseAttachmentProvider.kt
    @@ -0,0 +1,148 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.media
    +
    +import android.content.Context
    +import android.graphics.drawable.Drawable
    +import android.view.View
    +import android.widget.ImageView
    +import com.bumptech.glide.request.target.CustomViewTarget
    +import com.bumptech.glide.request.transition.Transition
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.attachmentviewer.AttachmentSourceProvider
    +import im.vector.riotx.attachmentviewer.ImageLoaderTarget
    +import im.vector.riotx.attachmentviewer.VideoLoaderTarget
    +import java.io.File
    +
    +abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRenderer, val fileService: FileService) : AttachmentSourceProvider {
    +
    +    interface InteractionListener {
    +        fun onDismissTapped()
    +        fun onShareTapped()
    +        fun onPlayPause(play: Boolean)
    +        fun videoSeekTo(percent: Int)
    +    }
    +
    +    var interactionListener: InteractionListener? = null
    +
    +    protected var overlayView: AttachmentOverlayView? = null
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        if (position == -1) return null
    +        if (overlayView == null) {
    +            overlayView = AttachmentOverlayView(context)
    +            overlayView?.onBack = {
    +                interactionListener?.onDismissTapped()
    +            }
    +            overlayView?.onShareCallback = {
    +                interactionListener?.onShareTapped()
    +            }
    +            overlayView?.onPlayPause = { play ->
    +                interactionListener?.onPlayPause(play)
    +            }
    +            overlayView?.videoSeekTo = { percent ->
    +                interactionListener?.videoSeekTo(percent)
    +            }
    +        }
    +        return overlayView
    +    }
    +
    +    override fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.Image) {
    +        (info.data as? ImageContentRenderer.Data)?.let {
    +            imageContentRenderer.render(it, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +                override fun onLoadFailed(errorDrawable: Drawable?) {
    +                    target.onLoadFailed(info.uid, errorDrawable)
    +                }
    +
    +                override fun onResourceCleared(placeholder: Drawable?) {
    +                    target.onResourceCleared(info.uid, placeholder)
    +                }
    +
    +                override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                    target.onResourceReady(info.uid, resource)
    +                }
    +            })
    +        }
    +    }
    +
    +    override fun loadImage(target: ImageLoaderTarget, info: AttachmentInfo.AnimatedImage) {
    +        (info.data as? ImageContentRenderer.Data)?.let {
    +            imageContentRenderer.render(it, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +                override fun onLoadFailed(errorDrawable: Drawable?) {
    +                    target.onLoadFailed(info.uid, errorDrawable)
    +                }
    +
    +                override fun onResourceCleared(placeholder: Drawable?) {
    +                    target.onResourceCleared(info.uid, placeholder)
    +                }
    +
    +                override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                    target.onResourceReady(info.uid, resource)
    +                }
    +            })
    +        }
    +    }
    +
    +    override fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) {
    +        val data = info.data as? VideoContentRenderer.Data ?: return
    +//        videoContentRenderer.render(data,
    +//                holder.thumbnailImage,
    +//                holder.loaderProgressBar,
    +//                holder.videoView,
    +//                holder.errorTextView)
    +        imageContentRenderer.render(data.thumbnailMediaData, target.contextView(), object : CustomViewTarget(target.contextView()) {
    +            override fun onLoadFailed(errorDrawable: Drawable?) {
    +                target.onThumbnailLoadFailed(info.uid, errorDrawable)
    +            }
    +
    +            override fun onResourceCleared(placeholder: Drawable?) {
    +                target.onThumbnailResourceCleared(info.uid, placeholder)
    +            }
    +
    +            override fun onResourceReady(resource: Drawable, transition: Transition?) {
    +                target.onThumbnailResourceReady(info.uid, resource)
    +            }
    +        })
    +
    +        target.onVideoFileLoading(info.uid)
    +        fileService.downloadFile(
    +                downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
    +                id = data.eventId,
    +                mimeType = data.mimeType,
    +                elementToDecrypt = data.elementToDecrypt,
    +                fileName = data.filename,
    +                url = data.url,
    +                callback = object : MatrixCallback {
    +                    override fun onSuccess(data: File) {
    +                        target.onVideoFileReady(info.uid, data)
    +                    }
    +
    +                    override fun onFailure(failure: Throwable) {
    +                        target.onVideoFileLoadFailed(info.uid)
    +                    }
    +                }
    +        )
    +    }
    +
    +    override fun clear(id: String) {
    +        // TODO("Not yet implemented")
    +    }
    +
    +    abstract fun getFileForSharing(position: Int, callback: ((File?) -> Unit))
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt
    new file mode 100644
    index 0000000000..cb0039fc7e
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/DataAttachmentRoomProvider.kt
    @@ -0,0 +1,112 @@
    +/*
    + * 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.media
    +
    +import android.content.Context
    +import android.view.View
    +import androidx.core.view.isVisible
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.events.model.isVideoMessage
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.matrix.android.api.session.room.Room
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.core.date.VectorDateFormatter
    +import im.vector.riotx.core.extensions.localDateTime
    +import java.io.File
    +
    +class DataAttachmentRoomProvider(
    +        private val attachments: List,
    +        private val room: Room?,
    +        private val initialIndex: Int,
    +        imageContentRenderer: ImageContentRenderer,
    +        private val dateFormatter: VectorDateFormatter,
    +        fileService: FileService) : BaseAttachmentProvider(imageContentRenderer, fileService) {
    +
    +    override fun getItemCount(): Int = attachments.size
    +
    +    override fun getAttachmentInfoAt(position: Int): AttachmentInfo {
    +        return attachments[position].let {
    +            when (it) {
    +                is ImageContentRenderer.Data -> {
    +                    if (it.mimeType == "image/gif") {
    +                        AttachmentInfo.AnimatedImage(
    +                                uid = it.eventId,
    +                                url = it.url ?: "",
    +                                data = it
    +                        )
    +                    } else {
    +                        AttachmentInfo.Image(
    +                                uid = it.eventId,
    +                                url = it.url ?: "",
    +                                data = it
    +                        )
    +                    }
    +                }
    +                is VideoContentRenderer.Data -> {
    +                    AttachmentInfo.Video(
    +                            uid = it.eventId,
    +                            url = it.url ?: "",
    +                            data = it,
    +                            thumbnail = AttachmentInfo.Image(
    +                                    uid = it.eventId,
    +                                    url = it.thumbnailMediaData.url ?: "",
    +                                    data = it.thumbnailMediaData
    +                            )
    +                    )
    +                }
    +                else                         -> throw IllegalArgumentException()
    +            }
    +        }
    +    }
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        super.overlayViewAtPosition(context, position)
    +        val item = attachments[position]
    +        val timeLineEvent = room?.getTimeLineEvent(item.eventId)
    +        if (timeLineEvent != null) {
    +            val dateString = timeLineEvent.root.localDateTime().let {
    +                "${dateFormatter.formatMessageDay(it)} at ${dateFormatter.formatMessageHour(it)} "
    +            }
    +            overlayView?.updateWith("${position + 1} of ${attachments.size}", "${timeLineEvent.senderInfo.displayName} $dateString")
    +            overlayView?.videoControlsGroup?.isVisible = timeLineEvent.root.isVideoMessage()
    +        } else {
    +            overlayView?.updateWith("", "")
    +        }
    +        return overlayView
    +    }
    +
    +    override fun getFileForSharing(position: Int, callback: (File?) -> Unit) {
    +        val item = attachments[position]
    +        fileService.downloadFile(
    +                downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                id = item.eventId,
    +                fileName = item.filename,
    +                mimeType = item.mimeType,
    +                url = item.url ?: "",
    +                elementToDecrypt = item.elementToDecrypt,
    +                callback = object : MatrixCallback {
    +                    override fun onSuccess(data: File) {
    +                        callback(data)
    +                    }
    +
    +                    override fun onFailure(failure: Throwable) {
    +                        callback(null)
    +                    }
    +                }
    +        )
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    index eeeb55ed15..f7613855c5 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt
    @@ -19,11 +19,13 @@ package im.vector.riotx.features.media
     import android.graphics.drawable.Drawable
     import android.net.Uri
     import android.os.Parcelable
    +import android.view.View
     import android.widget.ImageView
     import com.bumptech.glide.load.DataSource
     import com.bumptech.glide.load.engine.GlideException
     import com.bumptech.glide.load.resource.bitmap.RoundedCorners
     import com.bumptech.glide.request.RequestListener
    +import com.bumptech.glide.request.target.CustomViewTarget
     import com.bumptech.glide.request.target.Target
     import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.ORIENTATION_USE_EXIF
     import com.github.piasy.biv.view.BigImageView
    @@ -42,21 +44,29 @@ import java.io.File
     import javax.inject.Inject
     import kotlin.math.min
     
    +interface AttachmentData : Parcelable {
    +    val eventId: String
    +    val filename: String
    +    val mimeType: String?
    +    val url: String?
    +    val elementToDecrypt: ElementToDecrypt?
    +}
    +
     class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
                                                    private val dimensionConverter: DimensionConverter) {
     
         @Parcelize
         data class Data(
    -            val eventId: String,
    -            val filename: String,
    -            val mimeType: String?,
    -            val url: String?,
    -            val elementToDecrypt: ElementToDecrypt?,
    +            override val eventId: String,
    +            override val filename: String,
    +            override val mimeType: String?,
    +            override val url: String?,
    +            override val elementToDecrypt: ElementToDecrypt?,
                 val height: Int?,
                 val maxHeight: Int,
                 val width: Int?,
                 val maxWidth: Int
    -    ) : Parcelable {
    +    ) : AttachmentData {
     
             fun isLocalFile() = url.isLocalFile()
         }
    @@ -93,6 +103,25 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
                     .into(imageView)
         }
     
    +    fun render(data: Data, contextView: View, target: CustomViewTarget<*, Drawable>) {
    +        val req = if (data.elementToDecrypt != null) {
    +            // Encrypted image
    +            GlideApp
    +                    .with(contextView)
    +                    .load(data)
    +        } else {
    +            // Clear image
    +            val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url)
    +            GlideApp
    +                    .with(contextView)
    +                    .load(resolvedUrl)
    +        }
    +
    +        req.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
    +                .fitCenter()
    +                .into(target)
    +    }
    +
         fun renderFitTarget(data: Data, mode: Mode, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
             val size = processSize(data, mode)
     
    @@ -122,6 +151,45 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
                     .into(imageView)
         }
     
    +    fun renderThumbnailDontTransform(data: Data, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) {
    +        // a11y
    +        imageView.contentDescription = data.filename
    +
    +        val req = if (data.elementToDecrypt != null) {
    +            // Encrypted image
    +            GlideApp
    +                    .with(imageView)
    +                    .load(data)
    +        } else {
    +            // Clear image
    +            val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url)
    +            GlideApp
    +                    .with(imageView)
    +                    .load(resolvedUrl)
    +        }
    +
    +        req.listener(object : RequestListener {
    +            override fun onLoadFailed(e: GlideException?,
    +                                      model: Any?,
    +                                      target: Target?,
    +                                      isFirstResource: Boolean): Boolean {
    +                callback?.invoke(false)
    +                return false
    +            }
    +
    +            override fun onResourceReady(resource: Drawable?,
    +                                         model: Any?,
    +                                         target: Target?,
    +                                         dataSource: DataSource?,
    +                                         isFirstResource: Boolean): Boolean {
    +                callback?.invoke(true)
    +                return false
    +            }
    +        })
    +                .dontTransform()
    +                .into(imageView)
    +    }
    +
         private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest {
             return if (data.elementToDecrypt != null) {
                 // Encrypted image
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    index 2be940d0c1..8a6c2f7545 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
    @@ -91,6 +91,8 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
                 encryptedImageView.isVisible = false
                 // Postpone transaction a bit until thumbnail is loaded
                 supportPostponeEnterTransition()
    +
    +            // We are not passing the exact same image that in the
                 imageContentRenderer.renderFitTarget(mediaData, ImageContentRenderer.Mode.THUMBNAIL, imageTransitionView) {
                     // Proceed with transaction
                     scheduleStartPostponedTransition(imageTransitionView)
    @@ -134,13 +136,13 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
     
         private fun onShareActionClicked() {
             session.fileService().downloadFile(
    -                FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    -                mediaData.eventId,
    -                mediaData.filename,
    -                mediaData.mimeType,
    -                mediaData.url,
    -                mediaData.elementToDecrypt,
    -                object : MatrixCallback {
    +                downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                id = mediaData.eventId,
    +                fileName = mediaData.filename,
    +                mimeType = mediaData.mimeType,
    +                url = mediaData.url,
    +                elementToDecrypt = mediaData.elementToDecrypt,
    +                callback = object : MatrixCallback {
                         override fun onSuccess(data: File) {
                             shareMedia(this@ImageMediaViewerActivity, data, getMimeTypeFromUri(this@ImageMediaViewerActivity, data.toUri()))
                         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt b/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt
    new file mode 100644
    index 0000000000..7a7fea6dc4
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/RoomEventsAttachmentProvider.kt
    @@ -0,0 +1,175 @@
    +/*
    + * 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.media
    +
    +import android.content.Context
    +import android.view.View
    +import androidx.core.view.isVisible
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.events.model.isVideoMessage
    +import im.vector.matrix.android.api.session.events.model.toModel
    +import im.vector.matrix.android.api.session.file.FileService
    +import im.vector.matrix.android.api.session.room.Room
    +import im.vector.matrix.android.api.session.room.model.message.MessageContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
    +import im.vector.matrix.android.api.session.room.model.message.getFileUrl
    +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
    +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
    +import im.vector.riotx.attachmentviewer.AttachmentInfo
    +import im.vector.riotx.core.date.VectorDateFormatter
    +import im.vector.riotx.core.extensions.localDateTime
    +import java.io.File
    +import javax.inject.Inject
    +
    +class RoomEventsAttachmentProvider(
    +        private val attachments: List,
    +        private val initialIndex: Int,
    +        imageContentRenderer: ImageContentRenderer,
    +        private val dateFormatter: VectorDateFormatter,
    +        fileService: FileService
    +) : BaseAttachmentProvider(imageContentRenderer, fileService) {
    +
    +    override fun getItemCount(): Int {
    +        return attachments.size
    +    }
    +
    +    override fun getAttachmentInfoAt(position: Int): AttachmentInfo {
    +        return attachments[position].let {
    +            val content = it.root.getClearContent().toModel() as? MessageWithAttachmentContent
    +            if (content is MessageImageContent) {
    +                val data = ImageContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.getFileUrl(),
    +                        elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                        maxHeight = -1,
    +                        maxWidth = -1,
    +                        width = null,
    +                        height = null
    +                )
    +                if (content.mimeType == "image/gif") {
    +                    AttachmentInfo.AnimatedImage(
    +                            uid = it.eventId,
    +                            url = content.url ?: "",
    +                            data = data
    +                    )
    +                } else {
    +                    AttachmentInfo.Image(
    +                            uid = it.eventId,
    +                            url = content.url ?: "",
    +                            data = data
    +                    )
    +                }
    +            } else if (content is MessageVideoContent) {
    +                val thumbnailData = ImageContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.videoInfo?.thumbnailFile?.url
    +                                ?: content.videoInfo?.thumbnailUrl,
    +                        elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
    +                        height = content.videoInfo?.height,
    +                        maxHeight = -1,
    +                        width = content.videoInfo?.width,
    +                        maxWidth = -1
    +                )
    +                val data = VideoContentRenderer.Data(
    +                        eventId = it.eventId,
    +                        filename = content.body,
    +                        mimeType = content.mimeType,
    +                        url = content.getFileUrl(),
    +                        elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                        thumbnailMediaData = thumbnailData
    +                )
    +                AttachmentInfo.Video(
    +                        uid = it.eventId,
    +                        url = content.getFileUrl() ?: "",
    +                        data = data,
    +                        thumbnail = AttachmentInfo.Image(
    +                                uid = it.eventId,
    +                                url = content.videoInfo?.thumbnailFile?.url
    +                                        ?: content.videoInfo?.thumbnailUrl ?: "",
    +                                data = thumbnailData
    +
    +                        )
    +                )
    +            } else {
    +                AttachmentInfo.Image(
    +                        uid = it.eventId,
    +                        url = "",
    +                        data = null
    +                )
    +            }
    +        }
    +    }
    +
    +    override fun overlayViewAtPosition(context: Context, position: Int): View? {
    +        super.overlayViewAtPosition(context, position)
    +        val item = attachments[position]
    +        val dateString = item.root.localDateTime().let {
    +            "${dateFormatter.formatMessageDay(it)} at ${dateFormatter.formatMessageHour(it)} "
    +        }
    +        overlayView?.updateWith("${position + 1} of ${attachments.size}", "${item.senderInfo.displayName} $dateString")
    +        overlayView?.videoControlsGroup?.isVisible = item.root.isVideoMessage()
    +        return overlayView
    +    }
    +
    +    override fun getFileForSharing(position: Int, callback: (File?) -> Unit) {
    +        attachments[position].let { timelineEvent ->
    +
    +            val messageContent = timelineEvent.root.getClearContent().toModel()
    +                    as? MessageWithAttachmentContent
    +                    ?: return@let
    +            fileService.downloadFile(
    +                    downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                    id = timelineEvent.eventId,
    +                    fileName = messageContent.body,
    +                    mimeType = messageContent.mimeType,
    +                    url = messageContent.getFileUrl(),
    +                    elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
    +                    callback = object : MatrixCallback {
    +                        override fun onSuccess(data: File) {
    +                           callback(data)
    +                        }
    +
    +                        override fun onFailure(failure: Throwable) {
    +                            callback(null)
    +                        }
    +                    }
    +            )
    +        }
    +    }
    +}
    +
    +class AttachmentProviderFactory @Inject constructor(
    +        private val imageContentRenderer: ImageContentRenderer,
    +        private val vectorDateFormatter: VectorDateFormatter,
    +        private val session: Session
    +) {
    +
    +    fun createProvider(attachments: List, initialIndex: Int): RoomEventsAttachmentProvider {
    +        return RoomEventsAttachmentProvider(attachments, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
    +    }
    +
    +    fun createProvider(attachments: List, room: Room?, initialIndex: Int): DataAttachmentRoomProvider {
    +        return DataAttachmentRoomProvider(attachments, room, initialIndex, imageContentRenderer, vectorDateFormatter, session.fileService())
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt
    new file mode 100644
    index 0000000000..38e3ccc69c
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/media/VectorAttachmentViewerActivity.kt
    @@ -0,0 +1,277 @@
    +/*
    + * 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.media
    +
    +import android.content.Context
    +import android.content.Intent
    +import android.os.Bundle
    +import android.os.Parcelable
    +import android.view.View
    +import android.view.ViewTreeObserver
    +import androidx.core.app.ActivityCompat
    +import androidx.core.content.ContextCompat
    +import androidx.core.net.toUri
    +import androidx.core.transition.addListener
    +import androidx.core.view.ViewCompat
    +import androidx.core.view.isInvisible
    +import androidx.core.view.isVisible
    +import androidx.lifecycle.Lifecycle
    +import androidx.transition.Transition
    +import im.vector.riotx.R
    +import im.vector.riotx.attachmentviewer.AttachmentCommands
    +import im.vector.riotx.attachmentviewer.AttachmentViewerActivity
    +import im.vector.riotx.core.di.ActiveSessionHolder
    +import im.vector.riotx.core.di.DaggerScreenComponent
    +import im.vector.riotx.core.di.HasVectorInjector
    +import im.vector.riotx.core.di.ScreenComponent
    +import im.vector.riotx.core.di.VectorComponent
    +import im.vector.riotx.core.intent.getMimeTypeFromUri
    +import im.vector.riotx.core.utils.shareMedia
    +import im.vector.riotx.features.themes.ActivityOtherThemes
    +import im.vector.riotx.features.themes.ThemeUtils
    +import kotlinx.android.parcel.Parcelize
    +import timber.log.Timber
    +import javax.inject.Inject
    +import kotlin.system.measureTimeMillis
    +
    +class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener {
    +
    +    @Parcelize
    +    data class Args(
    +            val roomId: String?,
    +            val eventId: String,
    +            val sharedTransitionName: String?
    +    ) : Parcelable
    +
    +    @Inject
    +    lateinit var sessionHolder: ActiveSessionHolder
    +
    +    @Inject
    +    lateinit var dataSourceFactory: AttachmentProviderFactory
    +
    +    @Inject
    +    lateinit var imageContentRenderer: ImageContentRenderer
    +
    +    private lateinit var screenComponent: ScreenComponent
    +
    +    private var initialIndex = 0
    +    private var isAnimatingOut = false
    +
    +    var currentSourceProvider: BaseAttachmentProvider? = null
    +
    +    override fun onCreate(savedInstanceState: Bundle?) {
    +        super.onCreate(savedInstanceState)
    +        Timber.i("onCreate Activity ${this.javaClass.simpleName}")
    +        val vectorComponent = getVectorComponent()
    +        screenComponent = DaggerScreenComponent.factory().create(vectorComponent, this)
    +        val timeForInjection = measureTimeMillis {
    +            screenComponent.inject(this)
    +        }
    +        Timber.v("Injecting dependencies into ${javaClass.simpleName} took $timeForInjection ms")
    +        ThemeUtils.setActivityTheme(this, getOtherThemes())
    +
    +        val args = args() ?: throw IllegalArgumentException("Missing arguments")
    +
    +        if (savedInstanceState == null && addTransitionListener()) {
    +            args.sharedTransitionName?.let {
    +                ViewCompat.setTransitionName(imageTransitionView, it)
    +                transitionImageContainer.isVisible = true
    +
    +                // Postpone transaction a bit until thumbnail is loaded
    +                val mediaData: Parcelable? = intent.getParcelableExtra(EXTRA_IMAGE_DATA)
    +                if (mediaData is ImageContentRenderer.Data) {
    +                    // will be shown at end of transition
    +                    pager2.isInvisible = true
    +                    supportPostponeEnterTransition()
    +                    imageContentRenderer.renderThumbnailDontTransform(mediaData, imageTransitionView) {
    +                        // Proceed with transaction
    +                        scheduleStartPostponedTransition(imageTransitionView)
    +                    }
    +                } else if (mediaData is VideoContentRenderer.Data) {
    +                    // will be shown at end of transition
    +                    pager2.isInvisible = true
    +                    supportPostponeEnterTransition()
    +                    imageContentRenderer.renderThumbnailDontTransform(mediaData.thumbnailMediaData, imageTransitionView) {
    +                        // Proceed with transaction
    +                        scheduleStartPostponedTransition(imageTransitionView)
    +                    }
    +                }
    +            }
    +        }
    +
    +        val session = sessionHolder.getSafeActiveSession() ?: return Unit.also { finish() }
    +
    +        val room = args.roomId?.let { session.getRoom(it) }
    +
    +        val inMemoryData = intent.getParcelableArrayListExtra(EXTRA_IN_MEMORY_DATA)
    +        if (inMemoryData != null) {
    +            val sourceProvider = dataSourceFactory.createProvider(inMemoryData, room, initialIndex)
    +            val index = inMemoryData.indexOfFirst { it.eventId == args.eventId }
    +            initialIndex = index
    +            sourceProvider.interactionListener = this
    +            setSourceProvider(sourceProvider)
    +            this.currentSourceProvider = sourceProvider
    +            if (savedInstanceState == null) {
    +                pager2.setCurrentItem(index, false)
    +                // The page change listener is not notified of the change...
    +                pager2.post {
    +                    onSelectedPositionChanged(index)
    +                }
    +            }
    +        } else {
    +            val events = room?.getAttachmentMessages()
    +                    ?: emptyList()
    +            val index = events.indexOfFirst { it.eventId == args.eventId }
    +            initialIndex = index
    +
    +            val sourceProvider = dataSourceFactory.createProvider(events, index)
    +            sourceProvider.interactionListener = this
    +            setSourceProvider(sourceProvider)
    +            this.currentSourceProvider = sourceProvider
    +            if (savedInstanceState == null) {
    +                pager2.setCurrentItem(index, false)
    +                // The page change listener is not notified of the change...
    +                pager2.post {
    +                    onSelectedPositionChanged(index)
    +                }
    +            }
    +        }
    +
    +        window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha)
    +        window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha)
    +    }
    +
    +    private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
    +
    +    override fun shouldAnimateDismiss(): Boolean {
    +        return currentPosition != initialIndex
    +    }
    +
    +    override fun onBackPressed() {
    +        if (currentPosition == initialIndex) {
    +            // show back the transition view
    +            // TODO, we should track and update the mapping
    +            transitionImageContainer.isVisible = true
    +        }
    +        isAnimatingOut = true
    +        super.onBackPressed()
    +    }
    +
    +    override fun animateClose() {
    +        if (currentPosition == initialIndex) {
    +            // show back the transition view
    +            // TODO, we should track and update the mapping
    +            transitionImageContainer.isVisible = true
    +        }
    +        isAnimatingOut = true
    +        ActivityCompat.finishAfterTransition(this)
    +    }
    +
    +    // ==========================================================================================
    +    // PRIVATE METHODS
    +    // ==========================================================================================
    +
    +    /**
    +     * Try and add a [Transition.TransitionListener] to the entering shared element
    +     * [Transition]. We do this so that we can load the full-size image after the transition
    +     * has completed.
    +     *
    +     * @return true if we were successful in adding a listener to the enter transition
    +     */
    +    private fun addTransitionListener(): Boolean {
    +        val transition = window.sharedElementEnterTransition
    +
    +        if (transition != null) {
    +            // There is an entering shared element transition so add a listener to it
    +            transition.addListener(
    +                    onEnd = {
    +                        // The listener is also called when we are exiting
    +                        // so we use a boolean to avoid reshowing pager at end of dismiss transition
    +                        if (!isAnimatingOut) {
    +                            transitionImageContainer.isVisible = false
    +                            pager2.isInvisible = false
    +                        }
    +                    },
    +                    onCancel = {
    +                        if (!isAnimatingOut) {
    +                            transitionImageContainer.isVisible = false
    +                            pager2.isInvisible = false
    +                        }
    +                    }
    +            )
    +            return true
    +        }
    +
    +        // If we reach here then we have not added a listener
    +        return false
    +    }
    +
    +    private fun args() = intent.getParcelableExtra(EXTRA_ARGS)
    +
    +    private fun getVectorComponent(): VectorComponent {
    +        return (application as HasVectorInjector).injector()
    +    }
    +
    +    private fun scheduleStartPostponedTransition(sharedElement: View) {
    +        sharedElement.viewTreeObserver.addOnPreDrawListener(
    +                object : ViewTreeObserver.OnPreDrawListener {
    +                    override fun onPreDraw(): Boolean {
    +                        sharedElement.viewTreeObserver.removeOnPreDrawListener(this)
    +                        supportStartPostponedEnterTransition()
    +                        return true
    +                    }
    +                })
    +    }
    +
    +    companion object {
    +        const val EXTRA_ARGS = "EXTRA_ARGS"
    +        const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA"
    +        const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA"
    +
    +        fun newIntent(context: Context,
    +                      mediaData: AttachmentData,
    +                      roomId: String?,
    +                      eventId: String,
    +                      inMemoryData: List,
    +                      sharedTransitionName: String?) = Intent(context, VectorAttachmentViewerActivity::class.java).also {
    +            it.putExtra(EXTRA_ARGS, Args(roomId, eventId, sharedTransitionName))
    +            it.putExtra(EXTRA_IMAGE_DATA, mediaData)
    +            if (inMemoryData.isNotEmpty()) {
    +                it.putParcelableArrayListExtra(EXTRA_IN_MEMORY_DATA, ArrayList(inMemoryData))
    +            }
    +        }
    +    }
    +
    +    override fun onDismissTapped() {
    +        animateClose()
    +    }
    +
    +    override fun onPlayPause(play: Boolean) {
    +        handle(if (play) AttachmentCommands.StartVideo else AttachmentCommands.PauseVideo)
    +    }
    +
    +    override fun videoSeekTo(percent: Int) {
    +        handle(AttachmentCommands.SeekTo(percent))
    +    }
    +
    +    override fun onShareTapped() {
    +        this.currentSourceProvider?.getFileForSharing(currentPosition) { data ->
    +            if (data != null && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
    +                shareMedia(this@VectorAttachmentViewerActivity, data, getMimeTypeFromUri(this@VectorAttachmentViewerActivity, data.toUri()))
    +            }
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    index eb9105f792..e6dec88349 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt
    @@ -16,7 +16,6 @@
     
     package im.vector.riotx.features.media
     
    -import android.os.Parcelable
     import android.widget.ImageView
     import android.widget.ProgressBar
     import android.widget.TextView
    @@ -38,13 +37,13 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
     
         @Parcelize
         data class Data(
    -            val eventId: String,
    -            val filename: String,
    -            val mimeType: String?,
    -            val url: String?,
    -            val elementToDecrypt: ElementToDecrypt?,
    +            override val eventId: String,
    +            override val filename: String,
    +            override val mimeType: String?,
    +            override val url: String?,
    +            override val elementToDecrypt: ElementToDecrypt?,
                 val thumbnailMediaData: ImageContentRenderer.Data
    -    ) : Parcelable
    +    ) : AttachmentData
     
         fun render(data: Data,
                    thumbnailView: ImageView,
    @@ -70,7 +69,7 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
                                     downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
                                     id = data.eventId,
                                     fileName = data.filename,
    -                                mimeType = null,
    +                                mimeType = data.mimeType,
                                     url = data.url,
                                     elementToDecrypt = data.elementToDecrypt,
                                     callback = object : MatrixCallback {
    diff --git a/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt
    index 6ef8927f00..d9df861a25 100644
    --- a/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt
    @@ -79,13 +79,13 @@ class VideoMediaViewerActivity : VectorBaseActivity() {
     
         private fun onShareActionClicked() {
             session.fileService().downloadFile(
    -                FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    -                mediaData.eventId,
    -                mediaData.filename,
    -                mediaData.mimeType,
    -                mediaData.url,
    -                mediaData.elementToDecrypt,
    -                object : MatrixCallback {
    +                downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                id = mediaData.eventId,
    +                fileName = mediaData.filename,
    +                mimeType = mediaData.mimeType,
    +                url = mediaData.url,
    +                elementToDecrypt = mediaData.elementToDecrypt,
    +                callback = object : MatrixCallback {
                         override fun onSuccess(data: File) {
                             shareMedia(this@VideoMediaViewerActivity, data, getMimeTypeFromUri(this@VideoMediaViewerActivity, data.toUri()))
                         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    index 0b89ab8ec4..8267ba4c99 100644
    --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt
    @@ -19,7 +19,6 @@ package im.vector.riotx.features.navigation
     import android.app.Activity
     import android.content.Context
     import android.content.Intent
    -import android.os.Build
     import android.view.View
     import android.view.Window
     import androidx.core.app.ActivityOptionsCompat
    @@ -29,6 +28,7 @@ import androidx.core.view.ViewCompat
     import androidx.fragment.app.Fragment
     import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerificationTransaction
     import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
    +import im.vector.matrix.android.api.session.room.model.thirdparty.RoomDirectoryData
     import im.vector.matrix.android.api.session.terms.TermsService
     import im.vector.matrix.android.api.session.widgets.model.Widget
     import im.vector.matrix.android.api.util.MatrixItem
    @@ -49,11 +49,9 @@ import im.vector.riotx.features.home.room.detail.RoomDetailArgs
     import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes
     import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
     import im.vector.riotx.features.invite.InviteUsersToRoomActivity
    +import im.vector.riotx.features.media.AttachmentData
     import im.vector.riotx.features.media.BigImageViewerActivity
    -import im.vector.riotx.features.media.ImageContentRenderer
    -import im.vector.riotx.features.media.ImageMediaViewerActivity
    -import im.vector.riotx.features.media.VideoContentRenderer
    -import im.vector.riotx.features.media.VideoMediaViewerActivity
    +import im.vector.riotx.features.media.VectorAttachmentViewerActivity
     import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
     import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity
     import im.vector.riotx.features.roomdirectory.roompreview.RoomPreviewActivity
    @@ -89,7 +87,8 @@ class DefaultNavigator @Inject constructor(
     
         override fun performDeviceVerification(context: Context, otherUserId: String, sasTransactionId: String) {
             val session = sessionHolder.getSafeActiveSession() ?: return
    -        val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId) ?: return
    +        val tx = session.cryptoService().verificationService().getExistingTransaction(otherUserId, sasTransactionId)
    +                ?: return
             (tx as? IncomingSasVerificationTransaction)?.performAccept()
             if (context is VectorBaseActivity) {
                 VerificationBottomSheet.withArgs(
    @@ -116,6 +115,27 @@ class DefaultNavigator @Inject constructor(
             }
         }
     
    +    override fun requestSelfSessionVerification(context: Context) {
    +        val session = sessionHolder.getSafeActiveSession() ?: return
    +        val otherSessions = session.cryptoService()
    +                .getCryptoDeviceInfo(session.myUserId)
    +                .filter { it.deviceId != session.sessionParams.deviceId }
    +                .map { it.deviceId }
    +        if (context is VectorBaseActivity) {
    +            if (otherSessions.isNotEmpty()) {
    +                val pr = session.cryptoService().verificationService().requestKeyVerification(
    +                        supportedVerificationMethodsProvider.provide(),
    +                        session.myUserId,
    +                        otherSessions)
    +                VerificationBottomSheet.forSelfVerification(session, pr.transactionId ?: pr.localId)
    +                        .show(context.supportFragmentManager, VerificationBottomSheet.WAITING_SELF_VERIF_TAG)
    +            } else {
    +                VerificationBottomSheet.forSelfVerification(session)
    +                        .show(context.supportFragmentManager, VerificationBottomSheet.WAITING_SELF_VERIF_TAG)
    +            }
    +        }
    +    }
    +
         override fun waitSessionVerification(context: Context) {
             val session = sessionHolder.getSafeActiveSession() ?: return
             if (context is VectorBaseActivity) {
    @@ -126,7 +146,7 @@ class DefaultNavigator @Inject constructor(
     
         override fun upgradeSessionSecurity(context: Context, initCrossSigningOnly: Boolean) {
             if (context is VectorBaseActivity) {
    -            BootstrapBottomSheet.show(context.supportFragmentManager, initCrossSigningOnly)
    +            BootstrapBottomSheet.show(context.supportFragmentManager, initCrossSigningOnly, false)
             }
         }
     
    @@ -159,8 +179,8 @@ class DefaultNavigator @Inject constructor(
             activity.finish()
         }
     
    -    override fun openRoomPreview(publicRoom: PublicRoom, context: Context) {
    -        val intent = RoomPreviewActivity.getIntent(context, publicRoom)
    +    override fun openRoomPreview(context: Context, publicRoom: PublicRoom, roomDirectoryData: RoomDirectoryData) {
    +        val intent = RoomPreviewActivity.getIntent(context, publicRoom, roomDirectoryData)
             context.startActivity(intent)
         }
     
    @@ -199,7 +219,14 @@ class DefaultNavigator @Inject constructor(
         }
     
         override fun openKeysBackupSetup(context: Context, showManualExport: Boolean) {
    -        context.startActivity(KeysBackupSetupActivity.intent(context, showManualExport))
    +        // if cross signing is enabled we should propose full 4S
    +        sessionHolder.getSafeActiveSession()?.let { session ->
    +            if (session.cryptoService().crossSigningService().canCrossSign() && context is VectorBaseActivity) {
    +                BootstrapBottomSheet.show(context.supportFragmentManager, initCrossSigningOnly = false, forceReset4S = false)
    +            } else {
    +                context.startActivity(KeysBackupSetupActivity.intent(context, showManualExport))
    +            }
    +        }
         }
     
         override fun openKeysBackupManager(context: Context) {
    @@ -216,7 +243,8 @@ class DefaultNavigator @Inject constructor(
                     ?.let { avatarUrl ->
                         val intent = BigImageViewerActivity.newIntent(activity, matrixItem.getBestName(), avatarUrl)
                         val options = sharedElement?.let {
    -                        ActivityOptionsCompat.makeSceneTransitionAnimation(activity, it, ViewCompat.getTransitionName(it) ?: "")
    +                        ActivityOptionsCompat.makeSceneTransitionAnimation(activity, it, ViewCompat.getTransitionName(it)
    +                                ?: "")
                         }
                         activity.startActivity(intent, options?.toBundle())
                     }
    @@ -244,27 +272,32 @@ class DefaultNavigator @Inject constructor(
             context.startActivity(WidgetActivity.newIntent(context, widgetArgs))
         }
     
    -    override fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?) {
    -        val intent = ImageMediaViewerActivity.newIntent(activity, mediaData, ViewCompat.getTransitionName(view))
    -        val pairs = ArrayList>()
    -        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    +    override fun openMediaViewer(activity: Activity,
    +                                 roomId: String,
    +                                 mediaData: AttachmentData,
    +                                 view: View,
    +                                 inMemory: List,
    +                                 options: ((MutableList>) -> Unit)?) {
    +        VectorAttachmentViewerActivity.newIntent(activity,
    +                mediaData,
    +                roomId,
    +                mediaData.eventId,
    +                inMemory,
    +                ViewCompat.getTransitionName(view)).let { intent ->
    +            val pairs = ArrayList>()
                 activity.window.decorView.findViewById(android.R.id.statusBarBackground)?.let {
                     pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME))
                 }
                 activity.window.decorView.findViewById(android.R.id.navigationBarBackground)?.let {
                     pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME))
                 }
    +
    +            pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: ""))
    +            options?.invoke(pairs)
    +
    +            val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, *pairs.toTypedArray()).toBundle()
    +            activity.startActivity(intent, bundle)
             }
    -        pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: ""))
    -        options?.invoke(pairs)
    -
    -        val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, *pairs.toTypedArray()).toBundle()
    -        activity.startActivity(intent, bundle)
    -    }
    -
    -    override fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data) {
    -        val intent = VideoMediaViewerActivity.newIntent(activity, mediaData)
    -        activity.startActivity(intent)
         }
     
         private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    index ce4d5ef3ea..2d817183be 100644
    --- a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt
    @@ -22,12 +22,12 @@ import android.view.View
     import androidx.core.util.Pair
     import androidx.fragment.app.Fragment
     import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
    +import im.vector.matrix.android.api.session.room.model.thirdparty.RoomDirectoryData
     import im.vector.matrix.android.api.session.terms.TermsService
    -import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.matrix.android.api.session.widgets.model.Widget
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes
    -import im.vector.riotx.features.media.ImageContentRenderer
    -import im.vector.riotx.features.media.VideoContentRenderer
    +import im.vector.riotx.features.media.AttachmentData
     import im.vector.riotx.features.settings.VectorSettingsActivity
     import im.vector.riotx.features.share.SharedData
     import im.vector.riotx.features.terms.ReviewTermsActivity
    @@ -40,6 +40,8 @@ interface Navigator {
     
         fun requestSessionVerification(context: Context, otherSessionId: String)
     
    +    fun requestSelfSessionVerification(context: Context)
    +
         fun waitSessionVerification(context: Context)
     
         fun upgradeSessionSecurity(context: Context, initCrossSigningOnly: Boolean)
    @@ -48,7 +50,7 @@ interface Navigator {
     
         fun openNotJoinedRoom(context: Context, roomIdOrAlias: String?, eventId: String? = null, buildTask: Boolean = false)
     
    -    fun openRoomPreview(publicRoom: PublicRoom, context: Context)
    +    fun openRoomPreview(context: Context, publicRoom: PublicRoom, roomDirectoryData: RoomDirectoryData)
     
         fun openCreateRoom(context: Context, initialName: String = "")
     
    @@ -91,7 +93,10 @@ interface Navigator {
     
         fun openRoomWidget(context: Context, roomId: String, widget: Widget)
     
    -    fun openImageViewer(activity: Activity, mediaData: ImageContentRenderer.Data, view: View, options: ((MutableList>) -> Unit)?)
    -
    -    fun openVideoViewer(activity: Activity, mediaData: VideoContentRenderer.Data)
    +    fun openMediaViewer(activity: Activity,
    +                        roomId: String,
    +                        mediaData: AttachmentData,
    +                        view: View,
    +                        inMemory: List = emptyList(),
    +                        options: ((MutableList>) -> Unit)?)
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationDrawerManager.kt
    index 6fc396b264..d0839795dd 100644
    --- a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationDrawerManager.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationDrawerManager.kt
    @@ -22,10 +22,11 @@ import android.os.HandlerThread
     import androidx.annotation.WorkerThread
     import androidx.core.app.NotificationCompat
     import androidx.core.app.Person
    +import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.content.ContentUrlResolver
    +import im.vector.riotx.ActiveSessionDataSource
     import im.vector.riotx.BuildConfig
     import im.vector.riotx.R
    -import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.resources.StringProvider
     import im.vector.riotx.features.settings.VectorPreferences
     import me.gujun.android.span.span
    @@ -46,7 +47,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
                                                         private val notificationUtils: NotificationUtils,
                                                         private val vectorPreferences: VectorPreferences,
                                                         private val stringProvider: StringProvider,
    -                                                    private val activeSessionHolder: ActiveSessionHolder,
    +                                                    private val activeSessionDataSource: ActiveSessionDataSource,
                                                         private val iconLoader: IconLoader,
                                                         private val bitmapLoader: BitmapLoader,
                                                         private val outdatedDetector: OutdatedEventDetector?) {
    @@ -68,6 +69,10 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
     
         private var currentRoomId: String? = null
     
    +    // TODO Multi-session: this will have to be improved
    +    private val currentSession: Session?
    +        get() = activeSessionDataSource.currentValue?.orNull()
    +
         /**
         Should be called as soon as a new event is ready to be displayed.
         The notification corresponding to this event will not be displayed until
    @@ -204,7 +209,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
         private fun refreshNotificationDrawerBg() {
             Timber.v("refreshNotificationDrawerBg()")
     
    -        val session = activeSessionHolder.getSafeActiveSession() ?: return
    +        val session = currentSession ?: return
     
             val user = session.getUser(session.myUserId)
             // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
    @@ -474,7 +479,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
                     val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
                     if (!file.exists()) file.createNewFile()
                     FileOutputStream(file).use {
    -                    activeSessionHolder.getSafeActiveSession()?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it)
    +                    currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it)
                     }
                 } catch (e: Throwable) {
                     Timber.e(e, "## Failed to save cached notification info")
    @@ -487,7 +492,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
                 val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
                 if (file.exists()) {
                     FileInputStream(file).use {
    -                    val events: ArrayList? = activeSessionHolder.getSafeActiveSession()?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
    +                    val events: ArrayList? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
                         if (events != null) {
                             return events.toMutableList()
                         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/OutdatedEventDetector.kt b/vector/src/main/java/im/vector/riotx/features/notifications/OutdatedEventDetector.kt
    index 6b8d3dae49..d2b939bc99 100644
    --- a/vector/src/main/java/im/vector/riotx/features/notifications/OutdatedEventDetector.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/notifications/OutdatedEventDetector.kt
    @@ -15,10 +15,12 @@
      */
     package im.vector.riotx.features.notifications
     
    -import im.vector.riotx.core.di.ActiveSessionHolder
    +import im.vector.riotx.ActiveSessionDataSource
     import javax.inject.Inject
     
    -class OutdatedEventDetector @Inject constructor(private val activeSessionHolder: ActiveSessionHolder) {
    +class OutdatedEventDetector @Inject constructor(
    +        private val activeSessionDataSource: ActiveSessionDataSource
    +) {
     
         /**
          * Returns true if the given event is outdated.
    @@ -26,10 +28,12 @@ class OutdatedEventDetector @Inject constructor(private val activeSessionHolder:
          * other device.
          */
         fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean {
    +        val session = activeSessionDataSource.currentValue?.orNull() ?: return false
    +
             if (notifiableEvent is NotifiableMessageEvent) {
                 val eventID = notifiableEvent.eventId
                 val roomID = notifiableEvent.roomId
    -            val room = activeSessionHolder.getSafeActiveSession()?.getRoom(roomID) ?: return false
    +            val room = session.getRoom(roomID) ?: return false
                 return room.isEventRead(eventID)
             }
             return false
    diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/PushRuleTriggerListener.kt b/vector/src/main/java/im/vector/riotx/features/notifications/PushRuleTriggerListener.kt
    index 4ba89c02e2..adef246151 100644
    --- a/vector/src/main/java/im/vector/riotx/features/notifications/PushRuleTriggerListener.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/notifications/PushRuleTriggerListener.kt
    @@ -30,17 +30,17 @@ class PushRuleTriggerListener @Inject constructor(
             private val notificationDrawerManager: NotificationDrawerManager
     ) : PushRuleService.PushRuleListener {
     
    -    var session: Session? = null
    +    private var session: Session? = null
     
         override fun onMatchRule(event: Event, actions: List) {
             Timber.v("Push rule match for event ${event.eventId}")
    -        if (session == null) {
    +        val safeSession = session ?: return Unit.also {
                 Timber.e("Called without active session")
    -            return
             }
    +
             val notificationAction = actions.toNotificationAction()
             if (notificationAction.shouldNotify) {
    -            val notifiableEvent = resolver.resolveEvent(event, session!!)
    +            val notifiableEvent = resolver.resolveEvent(event, safeSession)
                 if (notifiableEvent == null) {
                     Timber.v("## Failed to resolve event")
                     // TODO
    diff --git a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    index 78a0cece41..e5b2f34f61 100644
    --- a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt
    @@ -26,6 +26,7 @@ import com.tapadoo.alerter.Alerter
     import com.tapadoo.alerter.OnHideAlertListener
     import dagger.Lazy
     import im.vector.riotx.R
    +import im.vector.riotx.core.platform.VectorBaseActivity
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.themes.ThemeUtils
     import timber.log.Timber
    @@ -83,7 +84,7 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy JoinState.JOINED
    -                viewState.joiningRoomsIds.contains(publicRoom.roomId)      -> JoinState.JOINING
    -                viewState.joiningErrorRoomsIds.contains(publicRoom.roomId) -> JoinState.JOINING_ERROR
    -                else                                                       -> JoinState.NOT_JOINED
    +                isJoined                                                    -> JoinState.JOINED
    +                roomChangeMembership is ChangeMembershipState.Joining       -> JoinState.JOINING
    +                roomChangeMembership is ChangeMembershipState.FailedJoining -> JoinState.JOINING_ERROR
    +                else                                                        -> JoinState.NOT_JOINED
                 }
    -
                 joinState(joinState)
     
                 joinListener {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt
    index 869ee85337..dcccd33cf6 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt
    @@ -114,26 +114,22 @@ class PublicRoomsFragment @Inject constructor(
     
         override fun onPublicRoomClicked(publicRoom: PublicRoom, joinState: JoinState) {
             Timber.v("PublicRoomClicked: $publicRoom")
    -
    -        when (joinState) {
    -            JoinState.JOINED        -> {
    -                navigator.openRoom(requireActivity(), publicRoom.roomId)
    -            }
    -            JoinState.NOT_JOINED,
    -            JoinState.JOINING_ERROR -> {
    -                // ROOM PREVIEW
    -                navigator.openRoomPreview(publicRoom, requireActivity())
    -            }
    -            else                    -> {
    -                Snackbar.make(publicRoomsCoordinator, getString(R.string.please_wait), Snackbar.LENGTH_SHORT)
    -                        .show()
    +        withState(viewModel) { state ->
    +            when (joinState) {
    +                JoinState.JOINED -> {
    +                    navigator.openRoom(requireActivity(), publicRoom.roomId)
    +                }
    +                else             -> {
    +                    // ROOM PREVIEW
    +                    navigator.openRoomPreview(requireActivity(), publicRoom, state.roomDirectoryData)
    +                }
                 }
             }
         }
     
         override fun onPublicRoomJoin(publicRoom: PublicRoom) {
             Timber.v("PublicRoomJoinClicked: $publicRoom")
    -        viewModel.handle(RoomDirectoryAction.JoinRoom(publicRoom.getPrimaryAlias(), publicRoom.roomId))
    +        viewModel.handle(RoomDirectoryAction.JoinRoom(publicRoom.roomId))
         }
     
         override fun loadMore() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsViewState.kt
    index 665e37dcbd..67b17ea34e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsViewState.kt
    @@ -19,7 +19,9 @@ package im.vector.riotx.features.roomdirectory
     import com.airbnb.mvrx.Async
     import com.airbnb.mvrx.MvRxState
     import com.airbnb.mvrx.Uninitialized
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
    +import im.vector.matrix.android.api.session.room.model.thirdparty.RoomDirectoryData
     
     data class PublicRoomsViewState(
             // The current filter
    @@ -30,11 +32,9 @@ data class PublicRoomsViewState(
             val asyncPublicRoomsRequest: Async> = Uninitialized,
             // True if more result are available server side
             val hasMore: Boolean = false,
    -        // Set of roomIds that the user wants to join
    -        val joiningRoomsIds: Set = emptySet(),
    -        // Set of roomIds that the user wants to join, but an error occurred
    -        val joiningErrorRoomsIds: Set = emptySet(),
             // Set of joined roomId,
             val joinedRoomsIds: Set = emptySet(),
    -        val roomDirectoryDisplayName: String? = null
    +        // keys are room alias or roomId
    +        val changeMembershipStates: Map = emptyMap(),
    +        val roomDirectoryData: RoomDirectoryData = RoomDirectoryData()
     ) : MvRxState
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryAction.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryAction.kt
    index 598f26fc3b..8b32726370 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryAction.kt
    @@ -23,5 +23,5 @@ sealed class RoomDirectoryAction : VectorViewModelAction {
         data class SetRoomDirectoryData(val roomDirectoryData: RoomDirectoryData) : RoomDirectoryAction()
         data class FilterWith(val filter: String) : RoomDirectoryAction()
         object LoadMore : RoomDirectoryAction()
    -    data class JoinRoom(val roomAlias: String?, val roomId: String) : RoomDirectoryAction()
    +    data class JoinRoom(val roomId: String) : RoomDirectoryAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt
    index 53661b075a..1b51ab1822 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt
    @@ -26,6 +26,7 @@ import com.airbnb.mvrx.appendAt
     import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.extensions.orFalse
     import im.vector.matrix.android.api.failure.Failure
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.room.model.Membership
    @@ -63,18 +64,10 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
     
         private var currentTask: Cancelable? = null
     
    -    // Default RoomDirectoryData
    -    private var roomDirectoryData = RoomDirectoryData()
    -
         init {
    -        setState {
    -            copy(
    -                    roomDirectoryDisplayName = roomDirectoryData.displayName
    -            )
    -        }
    -
             // Observe joined room (from the sync)
             observeJoinedRooms()
    +        observeMembershipChanges()
         }
     
         private fun observeJoinedRooms() {
    @@ -91,18 +84,21 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
                                 ?: emptySet()
     
                         setState {
    -                        copy(
    -                                joinedRoomsIds = joinedRoomIds,
    -                                // Remove (newly) joined room id from the joining room list
    -                                joiningRoomsIds = joiningRoomsIds.toMutableSet().apply { removeAll(joinedRoomIds) },
    -                                // Remove (newly) joined room id from the joining room list in error
    -                                joiningErrorRoomsIds = joiningErrorRoomsIds.toMutableSet().apply { removeAll(joinedRoomIds) }
    -                        )
    +                        copy(joinedRoomsIds = joinedRoomIds)
                         }
                     }
                     .disposeOnClear()
         }
     
    +    private fun observeMembershipChanges() {
    +        session.rx()
    +                .liveRoomChangeMembershipState()
    +                .subscribe {
    +                    setState { copy(changeMembershipStates = it) }
    +                }
    +                .disposeOnClear()
    +    }
    +
         override fun handle(action: RoomDirectoryAction) {
             when (action) {
                 is RoomDirectoryAction.SetRoomDirectoryData -> setRoomDirectoryData(action)
    @@ -112,15 +108,15 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
             }
         }
     
    -    private fun setRoomDirectoryData(action: RoomDirectoryAction.SetRoomDirectoryData) {
    -        if (this.roomDirectoryData == action.roomDirectoryData) {
    -            return
    +    private fun setRoomDirectoryData(action: RoomDirectoryAction.SetRoomDirectoryData) = withState {
    +        if (it.roomDirectoryData == action.roomDirectoryData) {
    +            return@withState
    +        }
    +        setState {
    +            copy(roomDirectoryData = action.roomDirectoryData)
             }
    -
    -        this.roomDirectoryData = action.roomDirectoryData
    -
             reset("")
    -        load("")
    +        load("", action.roomDirectoryData)
         }
     
         private fun filterWith(action: RoomDirectoryAction.FilterWith) = withState { state ->
    @@ -128,7 +124,7 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
                 currentTask?.cancel()
     
                 reset(action.filter)
    -            load(action.filter)
    +            load(action.filter, state.roomDirectoryData)
             }
         }
     
    @@ -141,7 +137,6 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
                         publicRooms = emptyList(),
                         asyncPublicRoomsRequest = Loading(),
                         hasMore = false,
    -                    roomDirectoryDisplayName = roomDirectoryData.displayName,
                         currentFilter = newFilter
                 )
             }
    @@ -154,12 +149,11 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
                             asyncPublicRoomsRequest = Loading()
                     )
                 }
    -
    -            load(state.currentFilter)
    +            load(state.currentFilter, state.roomDirectoryData)
             }
         }
     
    -    private fun load(filter: String) {
    +    private fun load(filter: String, roomDirectoryData: RoomDirectoryData) {
             currentTask = session.getPublicRooms(roomDirectoryData.homeServer,
                     PublicRoomsParams(
                             limit = PUBLIC_ROOMS_LIMIT,
    @@ -204,19 +198,16 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
         }
     
         private fun joinRoom(action: RoomDirectoryAction.JoinRoom) = withState { state ->
    -        if (state.joiningRoomsIds.contains(action.roomId)) {
    +        val roomMembershipChange = state.changeMembershipStates[action.roomId]
    +        if (roomMembershipChange?.isInProgress().orFalse()) {
                 // Request already sent, should not happen
                 Timber.w("Try to join an already joining room. Should not happen")
                 return@withState
             }
    -
    -        setState {
    -            copy(
    -                    joiningRoomsIds = joiningRoomsIds.toMutableSet().apply { add(action.roomId) }
    -            )
    -        }
    -
    -        session.joinRoom(action.roomAlias ?: action.roomId, callback = object : MatrixCallback {
    +        val viaServers = state.roomDirectoryData.homeServer?.let {
    +            listOf(it)
    +        } ?: emptyList()
    +        session.joinRoom(action.roomId, viaServers = viaServers, callback = object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
                     // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
                     // Instead, we wait for the room to be joined
    @@ -225,20 +216,12 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
                 override fun onFailure(failure: Throwable) {
                     // Notify the user
                     _viewEvents.post(RoomDirectoryViewEvents.Failure(failure))
    -
    -                setState {
    -                    copy(
    -                            joiningRoomsIds = joiningRoomsIds.toMutableSet().apply { remove(action.roomId) },
    -                            joiningErrorRoomsIds = joiningErrorRoomsIds.toMutableSet().apply { add(action.roomId) }
    -                    )
    -                }
                 }
             })
         }
     
         override fun onCleared() {
             super.onCleared()
    -
             currentTask?.cancel()
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt
    index cfe50bb2f7..b75e9444fe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt
    @@ -84,15 +84,19 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr
                 copy(asyncCreateRoomRequest = Loading())
             }
     
    -        val createRoomParams = CreateRoomParams(
    -                name = state.roomName.takeIf { it.isNotBlank() },
    -                // Directory visibility
    -                visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE,
    -                // Public room
    -                preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
    -        )
    -                // Encryption
    -                .enableEncryptionWithAlgorithm(state.isEncrypted)
    +        val createRoomParams = CreateRoomParams()
    +                .apply {
    +                    name = state.roomName.takeIf { it.isNotBlank() }
    +                    // Directory visibility
    +                    visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE
    +                    // Public room
    +                    preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
    +
    +                    // Encryption
    +                    if (state.isEncrypted) {
    +                        enableEncryption()
    +                    }
    +                }
     
             session.createRoom(createRoomParams, object : MatrixCallback {
                 override fun onSuccess(data: String) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewAction.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewAction.kt
    index 6b83ada90e..426078fa3d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewAction.kt
    @@ -19,5 +19,5 @@ package im.vector.riotx.features.roomdirectory.roompreview
     import im.vector.riotx.core.platform.VectorViewModelAction
     
     sealed class RoomPreviewAction : VectorViewModelAction {
    -    data class Join(val roomAlias: String?) : RoomPreviewAction()
    +    object Join : RoomPreviewAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt
    index 3cb442127f..063cf3b8ff 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt
    @@ -21,6 +21,7 @@ import android.content.Intent
     import android.os.Parcelable
     import androidx.appcompat.widget.Toolbar
     import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
    +import im.vector.matrix.android.api.session.room.model.thirdparty.RoomDirectoryData
     import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.addFragment
    @@ -35,7 +36,8 @@ data class RoomPreviewData(
             val roomAlias: String?,
             val topic: String?,
             val worldReadable: Boolean,
    -        val avatarUrl: String?
    +        val avatarUrl: String?,
    +        val homeServer: String?
     ) : Parcelable {
         val matrixItem: MatrixItem
             get() = MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl)
    @@ -46,7 +48,7 @@ class RoomPreviewActivity : VectorBaseActivity(), ToolbarConfigurable {
         companion object {
             private const val ARG = "ARG"
     
    -        fun getIntent(context: Context, publicRoom: PublicRoom): Intent {
    +        fun getIntent(context: Context, publicRoom: PublicRoom, roomDirectoryData: RoomDirectoryData): Intent {
                 return Intent(context, RoomPreviewActivity::class.java).apply {
                     putExtra(ARG, RoomPreviewData(
                             roomId = publicRoom.roomId,
    @@ -54,7 +56,8 @@ class RoomPreviewActivity : VectorBaseActivity(), ToolbarConfigurable {
                             roomAlias = publicRoom.getPrimaryAlias(),
                             topic = publicRoom.topic,
                             worldReadable = publicRoom.worldReadable,
    -                        avatarUrl = publicRoom.avatarUrl
    +                        avatarUrl = publicRoom.avatarUrl,
    +                        homeServer = roomDirectoryData.homeServer
                     ))
                 }
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt
    index 04ecdb2305..ee01e8f7fe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt
    @@ -65,7 +65,7 @@ class RoomPreviewNoPreviewFragment @Inject constructor(
     
             roomPreviewNoPreviewJoin.callback = object : ButtonStateView.Callback {
                 override fun onButtonClicked() {
    -                roomPreviewViewModel.handle(RoomPreviewAction.Join(roomPreviewData.roomAlias))
    +                roomPreviewViewModel.handle(RoomPreviewAction.Join)
                 }
     
                 override fun onRetryClicked() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewModel.kt
    index 3f8ae03029..c5e79832fc 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewModel.kt
    @@ -22,7 +22,9 @@ import com.airbnb.mvrx.ViewModelContext
     import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.query.QueryStringValue
     import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.room.members.ChangeMembershipState
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.roomSummaryQueryParams
     import im.vector.matrix.rx.rx
    @@ -32,7 +34,7 @@ import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.features.roomdirectory.JoinState
     import timber.log.Timber
     
    -class RoomPreviewViewModel @AssistedInject constructor(@Assisted initialState: RoomPreviewViewState,
    +class RoomPreviewViewModel @AssistedInject constructor(@Assisted private val initialState: RoomPreviewViewState,
                                                            private val session: Session)
         : VectorViewModel(initialState) {
     
    @@ -52,30 +54,41 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted initialState: R
     
         init {
             // Observe joined room (from the sync)
    -        observeJoinedRooms()
    +        observeRoomSummary()
    +        observeMembershipChanges()
         }
     
    -    private fun observeJoinedRooms() {
    +    private fun observeRoomSummary() {
             val queryParams = roomSummaryQueryParams {
    -            memberships = listOf(Membership.JOIN)
    +            roomId = QueryStringValue.Equals(initialState.roomId)
             }
             session
                     .rx()
                     .liveRoomSummaries(queryParams)
                     .subscribe { list ->
    -                    withState { state ->
    -                        val isRoomJoined = list
    -                                ?.map { it.roomId }
    -                                ?.toList()
    -                                ?.contains(state.roomId) == true
    +                    val isRoomJoined = list.any {
    +                        it.membership == Membership.JOIN
    +                    }
    +                    if (isRoomJoined) {
    +                        setState { copy(roomJoinState = JoinState.JOINED) }
    +                    }
    +                }
    +                .disposeOnClear()
    +    }
     
    -                        if (isRoomJoined) {
    -                            setState {
    -                                copy(
    -                                        roomJoinState = JoinState.JOINED
    -                                )
    -                            }
    -                        }
    +    private fun observeMembershipChanges() {
    +        session.rx()
    +                .liveRoomChangeMembershipState()
    +                .subscribe {
    +                    val changeMembership = it[initialState.roomId] ?: ChangeMembershipState.Unknown
    +                    val joinState = when (changeMembership) {
    +                        is ChangeMembershipState.Joining       -> JoinState.JOINING
    +                        is ChangeMembershipState.FailedJoining -> JoinState.JOINING_ERROR
    +                        // Other cases are handled by room summary
    +                        else                                   -> null
    +                    }
    +                    if (joinState != null) {
    +                        setState { copy(roomJoinState = joinState) }
                         }
                     }
                     .disposeOnClear()
    @@ -83,37 +96,27 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted initialState: R
     
         override fun handle(action: RoomPreviewAction) {
             when (action) {
    -            is RoomPreviewAction.Join -> handleJoinRoom(action)
    +            is RoomPreviewAction.Join -> handleJoinRoom()
             }.exhaustive
         }
     
    -    private fun handleJoinRoom(action: RoomPreviewAction.Join) = withState { state ->
    +    private fun handleJoinRoom() = withState { state ->
             if (state.roomJoinState == JoinState.JOINING) {
                 // Request already sent, should not happen
                 Timber.w("Try to join an already joining room. Should not happen")
                 return@withState
             }
    -
    -        setState {
    -            copy(
    -                    roomJoinState = JoinState.JOINING,
    -                    lastError = null
    -            )
    -        }
    -
    -        session.joinRoom(action.roomAlias ?: state.roomId, callback = object : MatrixCallback {
    +        val viaServers = state.homeServer?.let {
    +            listOf(it)
    +        } ?: emptyList()
    +        session.joinRoom(state.roomId, viaServers = viaServers, callback = object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
                     // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data.
                     // Instead, we wait for the room to be joined
                 }
     
                 override fun onFailure(failure: Throwable) {
    -                setState {
    -                    copy(
    -                            roomJoinState = JoinState.JOINING_ERROR,
    -                            lastError = failure
    -                    )
    -                }
    +                setState { copy(lastError = failure) }
                 }
             })
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewState.kt
    index d3c75f95e0..04806ccf27 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewViewState.kt
    @@ -22,11 +22,21 @@ import im.vector.riotx.features.roomdirectory.JoinState
     data class RoomPreviewViewState(
             // The room id
             val roomId: String = "",
    +        val roomAlias: String? = null,
    +        /**
    +         * The server name (might be null)
    +         * Set null when the server is the current user's home server.
    +         */
    +        val homeServer: String? = null,
             // Current state of the room in preview
             val roomJoinState: JoinState = JoinState.NOT_JOINED,
             // Last error of join room request
             val lastError: Throwable? = null
     ) : MvRxState {
     
    -    constructor(args: RoomPreviewData) : this(roomId = args.roomId)
    +    constructor(args: RoomPreviewData) : this(
    +            roomId = args.roomId,
    +            roomAlias = args.roomAlias,
    +            homeServer = args.homeServer
    +    )
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt
    index f0cb29ea6b..1ff5094517 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt
    @@ -112,7 +112,6 @@ class RoomProfileFragment @Inject constructor(
                 when (it) {
                     is RoomProfileViewEvents.Loading            -> showLoading(it.message)
                     is RoomProfileViewEvents.Failure            -> showFailure(it.throwable)
    -                is RoomProfileViewEvents.OnLeaveRoomSuccess -> onLeaveRoom()
                     is RoomProfileViewEvents.ShareRoomProfile   -> onShareRoomProfile(it.permalink)
                     RoomProfileViewEvents.OnChangeAvatarSuccess -> dismissLoadingDialog()
                 }.exhaustive
    @@ -243,7 +242,7 @@ class RoomProfileFragment @Inject constructor(
         private fun onAvatarClicked(view: View, matrixItem: MatrixItem.RoomItem) = withState(roomProfileViewModel) {
             if (matrixItem.avatarUrl?.isNotEmpty() == true) {
                 val intent = BigImageViewerActivity.newIntent(requireContext(), matrixItem.getBestName(), matrixItem.avatarUrl!!, it.canChangeAvatar)
    -            val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity!!, view, ViewCompat.getTransitionName(view) ?: "")
    +            val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, ViewCompat.getTransitionName(view) ?: "")
                 startActivityForResult(intent, BigImageViewerActivity.REQUEST_CODE, options.toBundle())
             } else if (it.canChangeAvatar) {
                 showAvatarSelector()
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt
    index 78df127f72..c0c1f2eb24 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt
    @@ -25,7 +25,6 @@ sealed class RoomProfileViewEvents : VectorViewEvents {
         data class Loading(val message: CharSequence? = null) : RoomProfileViewEvents()
         data class Failure(val throwable: Throwable) : RoomProfileViewEvents()
     
    -    object OnLeaveRoomSuccess : RoomProfileViewEvents()
         object OnChangeAvatarSuccess : RoomProfileViewEvents()
         data class ShareRoomProfile(val permalink: String) : RoomProfileViewEvents()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt
    index 373dd6b56c..bab0331ccb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt
    @@ -25,6 +25,7 @@ import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.permalinks.PermalinkFactory
     import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.events.model.EventType
     import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper
     import im.vector.matrix.rx.rx
     import im.vector.matrix.rx.unwrap
    @@ -71,7 +72,9 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini
             powerLevelsContentLive
                     .subscribe {
                         val powerLevelsHelper = PowerLevelsHelper(it)
    -                    setState { copy(canChangeAvatar = powerLevelsHelper.isUserAbleToChangeRoomAvatar(session.myUserId)) }
    +                    setState {
    +                        copy(canChangeAvatar = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true,  EventType.STATE_ROOM_AVATAR))
    +                    }
                     }
                     .disposeOnClear()
         }
    @@ -95,7 +98,7 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini
             _viewEvents.post(RoomProfileViewEvents.Loading(stringProvider.getString(R.string.room_profile_leaving_room)))
             room.leave(null, object : MatrixCallback {
                 override fun onSuccess(data: Unit) {
    -                _viewEvents.post(RoomProfileViewEvents.OnLeaveRoomSuccess)
    +                // Do nothing, we will be closing the room automatically when it will get back from sync
                 }
     
                 override fun onFailure(failure: Throwable) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt
    index 01a35b84d3..d6a63197bd 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt
    @@ -18,4 +18,6 @@ package im.vector.riotx.features.roomprofile.members
     
     import im.vector.riotx.core.platform.VectorViewModelAction
     
    -sealed class RoomMemberListAction : VectorViewModelAction
    +sealed class RoomMemberListAction : VectorViewModelAction {
    +    data class RevokeThreePidInvite(val stateKey: String) : RoomMemberListAction()
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt
    index d0939e939e..8cf93e8589 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt
    @@ -17,7 +17,11 @@
     package im.vector.riotx.features.roomprofile.members
     
     import com.airbnb.epoxy.TypedEpoxyController
    +import im.vector.matrix.android.api.session.events.model.Event
    +import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
    +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.dividerItem
    @@ -37,6 +41,7 @@ class RoomMemberListController @Inject constructor(
     
         interface Callback {
             fun onRoomMemberClicked(roomMember: RoomMemberSummary)
    +        fun onThreePidInvites(event: Event)
         }
     
         private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color)
    @@ -49,15 +54,29 @@ class RoomMemberListController @Inject constructor(
     
         override fun buildModels(data: RoomMemberListViewState?) {
             val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return
    +        val threePidInvites = data.threePidInvites().orEmpty()
    +        var threePidInvitesDone = threePidInvites.isEmpty()
    +
             for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) {
                 if (roomMemberList.isEmpty()) {
                     continue
                 }
    +
    +            if (powerLevelCategory == RoomMemberListCategories.USER && !threePidInvitesDone) {
    +                // If there is not regular invite, display threepid invite before the regular user
    +                buildProfileSection(
    +                        stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
    +                )
    +
    +                buildThreePidInvites(data)
    +                threePidInvitesDone = true
    +            }
    +
                 buildProfileSection(
                         stringProvider.getString(powerLevelCategory.titleRes)
                 )
                 roomMemberList.join(
    -                    each = { roomMember ->
    +                    each = { _, roomMember ->
                             profileMatrixItem {
                                 id(roomMember.userId)
                                 matrixItem(roomMember.toMatrixItem())
    @@ -68,13 +87,62 @@ class RoomMemberListController @Inject constructor(
                                 }
                             }
                         },
    -                    between = { roomMemberBefore ->
    +                    between = { _, roomMemberBefore ->
                             dividerItem {
                                 id("divider_${roomMemberBefore.userId}")
                                 color(dividerColor)
                             }
                         }
                 )
    +            if (powerLevelCategory == RoomMemberListCategories.INVITE) {
    +                // Display the threepid invite after the regular invite
    +                dividerItem {
    +                    id("divider_threepidinvites")
    +                    color(dividerColor)
    +                }
    +                buildThreePidInvites(data)
    +                threePidInvitesDone = true
    +            }
    +        }
    +
    +        if (!threePidInvitesDone) {
    +            // If there is not regular invite and no regular user, finally display threepid invite here
    +            buildProfileSection(
    +                    stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
    +            )
    +
    +            buildThreePidInvites(data)
             }
         }
    +
    +    private fun buildThreePidInvites(data: RoomMemberListViewState) {
    +        data.threePidInvites()
    +                ?.filter { it.content.toModel() != null }
    +                ?.join(
    +                        each = { idx, event ->
    +                            event.content.toModel()
    +                                    ?.let { content ->
    +                                        profileMatrixItem {
    +                                            id("3pid_$idx")
    +                                            matrixItem(content.toMatrixItem())
    +                                            avatarRenderer(avatarRenderer)
    +                                            editable(data.actionsPermissions.canRevokeThreePidInvite)
    +                                            clickListener { _ ->
    +                                                callback?.onThreePidInvites(event)
    +                                            }
    +                                        }
    +                                    }
    +                        },
    +                        between = { idx, _ ->
    +                            dividerItem {
    +                                id("divider3_$idx")
    +                                color(dividerColor)
    +                            }
    +                        }
    +                )
    +    }
    +
    +    private fun RoomThirdPartyInviteContent.toMatrixItem(): MatrixItem {
    +        return MatrixItem.UserItem("@", displayName = displayName)
    +    }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt
    index 6bd2b5d0e3..6fe1f7ad18 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt
    @@ -20,10 +20,14 @@ import android.os.Bundle
     import android.view.Menu
     import android.view.MenuItem
     import android.view.View
    +import androidx.appcompat.app.AlertDialog
     import com.airbnb.mvrx.args
     import com.airbnb.mvrx.fragmentViewModel
     import com.airbnb.mvrx.withState
    +import im.vector.matrix.android.api.session.events.model.Event
    +import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
    +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
     import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.cleanup
    @@ -88,6 +92,22 @@ class RoomMemberListFragment @Inject constructor(
             navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity())
         }
     
    +    override fun onThreePidInvites(event: Event) {
    +        // Display a dialog to revoke invite if power level is high enough
    +        val content = event.content.toModel() ?: return
    +        val stateKey = event.stateKey ?: return
    +        if (withState(viewModel) { it.actionsPermissions.canRevokeThreePidInvite }) {
    +            AlertDialog.Builder(requireActivity())
    +                    .setTitle(R.string.three_pid_revoke_invite_dialog_title)
    +                    .setMessage(getString(R.string.three_pid_revoke_invite_dialog_content, content.displayName))
    +                    .setNegativeButton(R.string.cancel, null)
    +                    .setPositiveButton(R.string.revoke) { _, _ ->
    +                        viewModel.handle(RoomMemberListAction.RevokeThreePidInvite(stateKey))
    +                    }
    +                    .show()
    +        }
    +    }
    +
         private fun renderRoomSummary(state: RoomMemberListViewState) {
             state.roomSummary()?.let {
                 roomSettingsToolbarTitleView.text = it.displayName
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt
    index f177d26725..23d5e61399 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt
    @@ -16,11 +16,13 @@
     
     package im.vector.riotx.features.roomprofile.members
     
    +import androidx.lifecycle.viewModelScope
     import com.airbnb.mvrx.FragmentViewModelContext
     import com.airbnb.mvrx.MvRxViewModelFactory
     import com.airbnb.mvrx.ViewModelContext
     import com.squareup.inject.assisted.Assisted
     import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.NoOpMatrixCallback
     import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
     import im.vector.matrix.android.api.extensions.orFalse
     import im.vector.matrix.android.api.query.QueryStringValue
    @@ -37,12 +39,14 @@ import im.vector.matrix.rx.asObservable
     import im.vector.matrix.rx.mapOptional
     import im.vector.matrix.rx.rx
     import im.vector.matrix.rx.unwrap
    +import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.platform.EmptyViewEvents
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
     import io.reactivex.Observable
     import io.reactivex.android.schedulers.AndroidSchedulers
     import io.reactivex.functions.BiFunction
    +import kotlinx.coroutines.launch
     import timber.log.Timber
     
     class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState,
    @@ -68,6 +72,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
     
         init {
             observeRoomMemberSummaries()
    +        observeThirdPartyInvites()
             observeRoomSummary()
             observePowerLevel()
         }
    @@ -124,7 +129,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
             PowerLevelsObservableFactory(room).createObservable()
                     .subscribe {
                         val permissions = ActionPermissions(
    -                            canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId)
    +                            canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId),
    +                            canRevokeThreePidInvite = PowerLevelsHelper(it).isUserAllowedToSend(
    +                                    userId = session.myUserId,
    +                                    isState = true,
    +                                    eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE
    +                            )
                         )
                         setState {
                             copy(actionsPermissions = permissions)
    @@ -140,6 +150,13 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
                     }
         }
     
    +    private fun observeThirdPartyInvites() {
    +        room.rx().liveStateEvents(setOf(EventType.STATE_ROOM_THIRD_PARTY_INVITE))
    +                .execute { async ->
    +                    copy(threePidInvites = async)
    +                }
    +    }
    +
         private fun buildRoomMemberSummaries(powerLevelsContent: PowerLevelsContent, roomMembers: List): RoomMemberSummaries {
             val admins = ArrayList()
             val moderators = ArrayList()
    @@ -169,5 +186,19 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
         }
     
         override fun handle(action: RoomMemberListAction) {
    +        when (action) {
    +            is RoomMemberListAction.RevokeThreePidInvite -> handleRevokeThreePidInvite(action)
    +        }.exhaustive
    +    }
    +
    +    private fun handleRevokeThreePidInvite(action: RoomMemberListAction.RevokeThreePidInvite) {
    +        viewModelScope.launch {
    +            room.sendStateEvent(
    +                    eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE,
    +                    stateKey = action.stateKey,
    +                    body = emptyMap(),
    +                    callback = NoOpMatrixCallback()
    +            )
    +        }
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt
    index ece49a178c..55fb950a8e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt
    @@ -21,6 +21,7 @@ import com.airbnb.mvrx.Async
     import com.airbnb.mvrx.MvRxState
     import com.airbnb.mvrx.Uninitialized
     import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
    +import im.vector.matrix.android.api.session.events.model.Event
     import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.riotx.R
    @@ -30,6 +31,7 @@ data class RoomMemberListViewState(
             val roomId: String,
             val roomSummary: Async = Uninitialized,
             val roomMemberSummaries: Async = Uninitialized,
    +        val threePidInvites: Async> = Uninitialized,
             val trustLevelMap: Async> = Uninitialized,
             val actionsPermissions: ActionPermissions = ActionPermissions()
     ) : MvRxState {
    @@ -38,7 +40,8 @@ data class RoomMemberListViewState(
     }
     
     data class ActionPermissions(
    -        val canInvite: Boolean = false
    +        val canInvite: Boolean = false,
    +        val canRevokeThreePidInvite: Boolean = false
     )
     
     typealias RoomMemberSummaries = List>>
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsController.kt
    index 94177159f0..e9d2e5ccb5 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsController.kt
    @@ -20,6 +20,7 @@ import com.airbnb.epoxy.TypedEpoxyController
     import im.vector.matrix.android.api.session.events.model.Event
     import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibilityContent
    +import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.profiles.buildProfileAction
     import im.vector.riotx.core.epoxy.profiles.buildProfileSection
    @@ -104,6 +105,13 @@ class RoomSettingsController @Inject constructor(
                     action = { if (data.actionPermissions.canChangeHistoryReadability) callback?.onHistoryVisibilityClicked() }
             )
     
    +        buildEncryptionAction(data.actionPermissions, roomSummary)
    +    }
    +
    +    private fun buildEncryptionAction(actionPermissions: RoomSettingsViewState.ActionPermissions, roomSummary: RoomSummary) {
    +        if (!actionPermissions.canEnableEncryption) {
    +            return
    +        }
             if (roomSummary.isEncrypted) {
                 buildProfileAction(
                         id = "encryption",
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewModel.kt
    index e198375cfb..652c5cf4c5 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewModel.kt
    @@ -101,10 +101,13 @@ class RoomSettingsViewModel @AssistedInject constructor(@Assisted initialState:
                     .subscribe {
                         val powerLevelsHelper = PowerLevelsHelper(it)
                         val permissions = RoomSettingsViewState.ActionPermissions(
    -                            canChangeName = powerLevelsHelper.isUserAbleToChangeRoomName(session.myUserId),
    -                            canChangeTopic = powerLevelsHelper.isUserAbleToChangeRoomTopic(session.myUserId),
    -                            canChangeCanonicalAlias = powerLevelsHelper.isUserAbleToChangeRoomCanonicalAlias(session.myUserId),
    -                            canChangeHistoryReadability = powerLevelsHelper.isUserAbleToChangeRoomHistoryReadability(session.myUserId)
    +                            canChangeName = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, EventType.STATE_ROOM_NAME),
    +                            canChangeTopic =  powerLevelsHelper.isUserAllowedToSend(session.myUserId,  true, EventType.STATE_ROOM_TOPIC),
    +                            canChangeCanonicalAlias = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true,
    +                                    EventType.STATE_ROOM_CANONICAL_ALIAS),
    +                            canChangeHistoryReadability = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true,
    +                                    EventType.STATE_ROOM_HISTORY_VISIBILITY),
    +                            canEnableEncryption =  powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, EventType.STATE_ROOM_ENCRYPTION)
                         )
                         setState { copy(actionPermissions = permissions) }
                     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewState.kt
    index a86fbf8cfa..c8d81f3ead 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/settings/RoomSettingsViewState.kt
    @@ -43,6 +43,7 @@ data class RoomSettingsViewState(
                 val canChangeName: Boolean = false,
                 val canChangeTopic: Boolean = false,
                 val canChangeCanonicalAlias: Boolean = false,
    -            val canChangeHistoryReadability: Boolean = false
    +            val canChangeHistoryReadability: Boolean = false,
    +            val canEnableEncryption: Boolean = false
         )
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
    index 10f0a5051e..7cc8b9b31d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
    @@ -158,13 +158,13 @@ class RoomUploadsViewModel @AssistedInject constructor(
                 try {
                     val file = awaitCallback {
                         session.fileService().downloadFile(
    -                            FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    -                            action.uploadEvent.eventId,
    -                            action.uploadEvent.contentWithAttachmentContent.body,
    -                            action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
    -                            action.uploadEvent.contentWithAttachmentContent.mimeType,
    -                            action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
    -                            it)
    +                            downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
    +                            id = action.uploadEvent.eventId,
    +                            fileName = action.uploadEvent.contentWithAttachmentContent.body,
    +                            mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
    +                            url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
    +                            elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
    +                            callback = it)
                     }
                     _viewEvents.post(RoomUploadsViewEvents.FileReadyForSaving(file, action.uploadEvent.contentWithAttachmentContent.body))
                 } catch (failure: Throwable) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt
    index a4e6c61238..dda070bf48 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/RoomUploadsMediaFragment.kt
    @@ -20,23 +20,34 @@ import android.os.Bundle
     import android.util.DisplayMetrics
     import android.view.View
     import androidx.core.content.ContextCompat
    +import androidx.core.util.Pair
    +import androidx.core.view.ViewCompat
     import androidx.recyclerview.widget.GridLayoutManager
     import com.airbnb.mvrx.Fail
     import com.airbnb.mvrx.Loading
     import com.airbnb.mvrx.Success
     import com.airbnb.mvrx.parentFragmentViewModel
     import com.airbnb.mvrx.withState
    +import com.google.android.material.appbar.AppBarLayout
    +import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
    +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
    +import im.vector.matrix.android.api.session.room.model.message.getFileUrl
    +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.cleanup
     import im.vector.riotx.core.extensions.trackItemsVisibilityChange
     import im.vector.riotx.core.platform.StateView
     import im.vector.riotx.core.platform.VectorBaseFragment
     import im.vector.riotx.core.utils.DimensionConverter
    +import im.vector.riotx.features.media.AttachmentData
     import im.vector.riotx.features.media.ImageContentRenderer
     import im.vector.riotx.features.media.VideoContentRenderer
     import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction
    +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsFragment
     import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel
    +import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewState
     import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.*
    +import kotlinx.android.synthetic.main.fragment_room_uploads.*
     import javax.inject.Inject
     
     class RoomUploadsMediaFragment @Inject constructor(
    @@ -76,12 +87,86 @@ class RoomUploadsMediaFragment @Inject constructor(
             controller.listener = null
         }
     
    -    override fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) {
    -        navigator.openImageViewer(requireActivity(), mediaData, view, null)
    +    // It's very strange i can't just access
    +    // the app bar using find by id...
    +    private fun trickFindAppBar(): AppBarLayout? {
    +        return activity?.supportFragmentManager?.fragments
    +                ?.filterIsInstance()
    +                ?.firstOrNull()
    +                ?.roomUploadsAppBar
         }
     
    -    override fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) {
    -        navigator.openVideoViewer(requireActivity(), mediaData)
    +    override fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data) = withState(uploadsViewModel) { state ->
    +        val inMemory = getItemsArgs(state)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = state.roomId,
    +                mediaData = mediaData,
    +                view = view,
    +                inMemory = inMemory
    +        ) { pairs ->
    +            trickFindAppBar()?.let {
    +                pairs.add(Pair(it, ViewCompat.getTransitionName(it) ?: ""))
    +            }
    +        }
    +    }
    +
    +    private fun getItemsArgs(state: RoomUploadsViewState): List {
    +        return state.mediaEvents.mapNotNull {
    +            when (val content = it.contentWithAttachmentContent) {
    +                is MessageImageContent -> {
    +                    ImageContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.getFileUrl(),
    +                            elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                            maxHeight = -1,
    +                            maxWidth = -1,
    +                            width = null,
    +                            height = null
    +                    )
    +                }
    +                is MessageVideoContent -> {
    +                    val thumbnailData = ImageContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.videoInfo?.thumbnailFile?.url
    +                                    ?: content.videoInfo?.thumbnailUrl,
    +                            elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
    +                            height = content.videoInfo?.height,
    +                            maxHeight = -1,
    +                            width = content.videoInfo?.width,
    +                            maxWidth = -1
    +                    )
    +                    VideoContentRenderer.Data(
    +                            eventId = it.eventId,
    +                            filename = content.body,
    +                            mimeType = content.mimeType,
    +                            url = content.getFileUrl(),
    +                            elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
    +                            thumbnailMediaData = thumbnailData
    +                    )
    +                }
    +                else                   -> null
    +            }
    +        }
    +    }
    +
    +    override fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data) = withState(uploadsViewModel) { state ->
    +        val inMemory = getItemsArgs(state)
    +        navigator.openMediaViewer(
    +                activity = requireActivity(),
    +                roomId = state.roomId,
    +                mediaData = mediaData,
    +                view = view,
    +                inMemory = inMemory
    +        ) { pairs ->
    +            trickFindAppBar()?.let {
    +                pairs.add(Pair(it, ViewCompat.getTransitionName(it) ?: ""))
    +            }
    +        }
         }
     
         override fun loadMore() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    index 98026901cc..3b83e99656 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsImageItem.kt
    @@ -18,11 +18,13 @@ package im.vector.riotx.features.roomprofile.uploads.media
     
     import android.view.View
     import android.widget.ImageView
    +import androidx.core.view.ViewCompat
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.features.media.ImageContentRenderer
     
     @EpoxyModelClass(layout = R.layout.item_uploads_image)
    @@ -35,8 +37,13 @@ abstract class UploadsImageItem : VectorEpoxyModel() {
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    -        holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) }
    +        holder.view.setOnClickListener(
    +                DebouncedClickListener(View.OnClickListener { _ ->
    +                    listener?.onItemClicked(holder.imageView, data)
    +                })
    +        )
             imageContentRenderer.render(data, holder.imageView, IMAGE_SIZE_DP)
    +        ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    index 82e33b76da..f20f6ed5b1 100644
    --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsVideoItem.kt
    @@ -18,11 +18,13 @@ package im.vector.riotx.features.roomprofile.uploads.media
     
     import android.view.View
     import android.widget.ImageView
    +import androidx.core.view.ViewCompat
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.features.media.ImageContentRenderer
     import im.vector.riotx.features.media.VideoContentRenderer
     
    @@ -36,8 +38,13 @@ abstract class UploadsVideoItem : VectorEpoxyModel() {
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    -        holder.view.setOnClickListener { listener?.onItemClicked(holder.imageView, data) }
    +        holder.view.setOnClickListener(
    +            DebouncedClickListener(View.OnClickListener { _ ->
    +                listener?.onItemClicked(holder.imageView, data)
    +            })
    +        )
             imageContentRenderer.render(data.thumbnailMediaData, holder.imageView, IMAGE_SIZE_DP)
    +        ViewCompat.setTransitionName(holder.imageView, "videoPreview_${id()}")
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
    index e4a0eb3eb6..50f4d516bf 100755
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
    @@ -72,6 +72,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
             const val SETTINGS_ALLOW_INTEGRATIONS_KEY = "SETTINGS_ALLOW_INTEGRATIONS_KEY"
             const val SETTINGS_INTEGRATION_MANAGER_UI_URL_KEY = "SETTINGS_INTEGRATION_MANAGER_UI_URL_KEY"
             const val SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY = "SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY"
    +//        const val SETTINGS_SECURE_BACKUP_RESET_PREFERENCE_KEY = "SETTINGS_SECURE_BACKUP_RESET_PREFERENCE_KEY"
     
             // user
             const val SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY = "SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY"
    @@ -146,6 +147,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
             private const val SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY = "SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY"
             // SETTINGS_LABS_HIDE_TECHNICAL_E2E_ERRORS
             private const val SETTINGS_LABS_MERGE_E2E_ERRORS = "SETTINGS_LABS_MERGE_E2E_ERRORS"
    +        const val SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB = "SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB"
     
             // analytics
             const val SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY"
    @@ -275,6 +277,10 @@ class VectorPreferences @Inject constructor(private val context: Context) {
             return developerMode() && defaultPrefs.getBoolean(SETTINGS_LABS_ALLOW_EXTENDED_LOGS, false)
         }
     
    +    fun labAddNotificationTab(): Boolean {
    +        return defaultPrefs.getBoolean(SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB, false)
    +    }
    +
         fun failFast(): Boolean {
             return BuildConfig.DEBUG || (developerMode() && defaultPrefs.getBoolean(SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY, false))
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt
    index 85d32251b6..c43a6ab40d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt
    @@ -83,8 +83,8 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), HasScree
          * ========================================================================================== */
     
         protected fun notImplemented() {
    -        // Snackbar cannot be display on PreferenceFragment
    -        // Snackbar.make(view!!, R.string.not_implemented, Snackbar.LENGTH_SHORT)
    +        // Snackbar cannot be display on PreferenceFragment. TODO It's maybe because the show() method is not used...
    +        // Snackbar.make(requireView(), R.string.not_implemented, Snackbar.LENGTH_SHORT)
             activity?.toast(R.string.not_implemented)
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
    index 18fa9d95ed..6bfb88a480 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
    @@ -221,7 +221,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
     
                 it.onPreferenceClickListener = Preference.OnPreferenceClickListener {
                     displayLoadingView()
    -                MainActivity.restartApp(activity!!, MainActivityArgs(clearCache = true))
    +                MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCache = true))
                     false
                 }
             }
    @@ -622,7 +622,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
                 var order = addEmailBtn.order
     
                 for ((index, email3PID) in currentEmail3PID.withIndex()) {
    -                val preference = VectorPreference(activity!!)
    +                val preference = VectorPreference(requireActivity())
     
                     preference.title = getString(R.string.settings_email_address)
                     preference.summary = "TODO" // email3PID.address
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsLabsFragment.kt
    index eeda0167a3..1ffd80a591 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsLabsFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsLabsFragment.kt
    @@ -34,6 +34,10 @@ class VectorSettingsLabsFragment @Inject constructor(
                 it.isChecked = vectorPreferences.labAllowedExtendedLogging()
             }
     
    +        findPreference(VectorPreferences.SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB)?.let {
    +            it.isChecked = vectorPreferences.labAddNotificationTab()
    +        }
    +
     //        val useCryptoPref = findPreference(VectorPreferences.SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY) as SwitchPreference
     //        val cryptoIsEnabledPref = findPreference(VectorPreferences.SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY)
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt
    index 04908e166f..3ac097abfe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt
    @@ -72,7 +72,7 @@ class VectorSettingsNotificationsTroubleshootFragment @Inject constructor(
             mRecyclerView.addItemDecoration(dividerItemDecoration)
     
             mSummaryButton.debouncedClicks {
    -            bugReporter.openBugReportScreen(activity!!)
    +            bugReporter.openBugReportScreen(requireActivity())
             }
     
             mRunButton.debouncedClicks {
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsPreferencesFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsPreferencesFragment.kt
    index ed8f15db98..5848caacdb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsPreferencesFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsPreferencesFragment.kt
    @@ -134,7 +134,7 @@ class VectorSettingsPreferencesFragment @Inject constructor(
             selectedLanguagePreference.summary = VectorLocale.localeToLocalisedString(VectorLocale.applicationLocale)
     
             // Text size
    -        textSizePreference.summary = getString(FontScale.getFontScaleValue(activity!!).nameResId)
    +        textSizePreference.summary = getString(FontScale.getFontScaleValue(requireActivity()).nameResId)
     
             textSizePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
                 activity?.let { displayTextSizeSelection(it) }
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt
    index 2b9338ccc8..9d71c1712e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt
    @@ -23,6 +23,7 @@ import android.content.Intent
     import android.widget.Button
     import android.widget.TextView
     import androidx.appcompat.app.AlertDialog
    +import androidx.core.content.ContextCompat
     import androidx.core.view.isVisible
     import androidx.preference.Preference
     import androidx.preference.PreferenceCategory
    @@ -33,30 +34,37 @@ import im.vector.matrix.android.internal.crypto.crosssigning.isVerified
     import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult
     import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
     import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
    +import im.vector.matrix.rx.SecretsSynchronisationInfo
    +import im.vector.matrix.rx.rx
     import im.vector.riotx.R
    +import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.dialogs.ExportKeysDialog
    +import im.vector.riotx.core.extensions.queryExportKeys
     import im.vector.riotx.core.intent.ExternalIntentData
     import im.vector.riotx.core.intent.analyseIntent
     import im.vector.riotx.core.intent.getFilenameFromUri
     import im.vector.riotx.core.platform.SimpleTextWatcher
     import im.vector.riotx.core.preference.VectorPreference
    -import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
    -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_EXPORT_KEYS
    -import im.vector.riotx.core.utils.allGranted
    -import im.vector.riotx.core.utils.checkPermissions
    +import im.vector.riotx.core.preference.VectorPreferenceCategory
     import im.vector.riotx.core.utils.openFileSelection
     import im.vector.riotx.core.utils.toast
     import im.vector.riotx.features.crypto.keys.KeysExporter
     import im.vector.riotx.features.crypto.keys.KeysImporter
     import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity
    +import im.vector.riotx.features.crypto.recover.BootstrapBottomSheet
    +import im.vector.riotx.features.themes.ThemeUtils
    +import io.reactivex.android.schedulers.AndroidSchedulers
    +import io.reactivex.disposables.Disposable
     import javax.inject.Inject
     
     class VectorSettingsSecurityPrivacyFragment @Inject constructor(
    -        private val vectorPreferences: VectorPreferences
    +        private val vectorPreferences: VectorPreferences,
    +        private val activeSessionHolder: ActiveSessionHolder
     ) : VectorSettingsBaseFragment() {
     
         override var titleRes = R.string.settings_security_and_privacy
         override val preferenceXmlRes = R.xml.vector_settings_security_privacy
    +    private var disposables = mutableListOf()
     
         // cryptography
         private val mCryptographyCategory by lazy {
    @@ -93,6 +101,97 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
             // My device name may have been updated
             refreshMyDevice()
             refreshXSigningStatus()
    +        session.rx().liveSecretSynchronisationInfo()
    +                .observeOn(AndroidSchedulers.mainThread())
    +                .subscribe {
    +                    refresh4SSection(it)
    +                    refreshXSigningStatus()
    +                }.also {
    +                    disposables.add(it)
    +                }
    +    }
    +
    +    private val secureBackupCategory by lazy {
    +        findPreference("SETTINGS_CRYPTOGRAPHY_MANAGE_4S_CATEGORY_KEY")!!
    +    }
    +    private val secureBackupPreference by lazy {
    +        findPreference("SETTINGS_SECURE_BACKUP_RECOVERY_PREFERENCE_KEY")!!
    +    }
    +//    private val secureBackupResetPreference by lazy {
    +//        findPreference(VectorPreferences.SETTINGS_SECURE_BACKUP_RESET_PREFERENCE_KEY)
    +//    }
    +
    +    override fun onPause() {
    +        super.onPause()
    +        disposables.forEach {
    +            it.dispose()
    +        }
    +        disposables.clear()
    +    }
    +
    +    private fun refresh4SSection(info: SecretsSynchronisationInfo) {
    +        // it's a lot of if / else if / else
    +        // But it's not yet clear how to manage all cases
    +        if (!info.isCrossSigningEnabled) {
    +            // There is not cross signing, so we can remove the section
    +            secureBackupCategory.isVisible = false
    +        } else {
    +            if (!info.isBackupSetup) {
    +                if (info.isCrossSigningEnabled && info.allPrivateKeysKnown) {
    +                    // You can setup recovery!
    +                    secureBackupCategory.isVisible = true
    +                    secureBackupPreference.title = getString(R.string.settings_secure_backup_setup)
    +                    secureBackupPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
    +                        BootstrapBottomSheet.show(parentFragmentManager, initCrossSigningOnly = false, forceReset4S = false)
    +                        true
    +                    }
    +                } else {
    +                    // just hide all, you can't setup from here
    +                    // you should synchronize to get gossips
    +                    secureBackupCategory.isVisible = false
    +                }
    +            } else {
    +                // so here we know that 4S is setup
    +                if (info.isCrossSigningTrusted && info.allPrivateKeysKnown) {
    +                    // Looks like we have all cross signing secrets and session is trusted
    +                    // Let's see if there is a megolm backup
    +                    if (!info.megolmBackupAvailable || info.megolmSecretKnown) {
    +                        // Only option here is to create a new backup if you want?
    +                        // aka reset
    +                        secureBackupCategory.isVisible = true
    +                        secureBackupPreference.title = getString(R.string.settings_secure_backup_reset)
    +                        secureBackupPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
    +                            BootstrapBottomSheet.show(parentFragmentManager, initCrossSigningOnly = false, forceReset4S = true)
    +                            true
    +                        }
    +                    } else if (!info.megolmSecretKnown) {
    +                        // megolm backup is available but we don't have key
    +                        // you could try to synchronize to get missing megolm key ?
    +                        secureBackupCategory.isVisible = true
    +                        secureBackupPreference.title = getString(R.string.settings_secure_backup_enter_to_setup)
    +                        secureBackupPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
    +                            vectorActivity.let {
    +                                it.navigator.requestSelfSessionVerification(it)
    +                            }
    +                            true
    +                        }
    +                    } else {
    +                        secureBackupCategory.isVisible = false
    +                    }
    +                } else {
    +                    // there is a backup, but this session is not trusted, or is missing some secrets
    +                    // you should enter passphrase to get them or verify against another session
    +                    secureBackupCategory.isVisible = true
    +                    secureBackupPreference.title = getString(R.string.settings_secure_backup_enter_to_setup)
    +                    secureBackupPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
    +                        vectorActivity.let {
    +                            it.navigator.requestSelfSessionVerification(it)
    +                        }
    +                        true
    +                    }
    +                }
    +            }
    +        }
         }
     
         override fun bindPref() {
    @@ -116,41 +215,75 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
             }
     
             refreshXSigningStatus()
    +
    +        secureBackupPreference.icon = activity?.let {
    +            ThemeUtils.tintDrawable(it,
    +                    ContextCompat.getDrawable(it, R.drawable.ic_secure_backup)!!, R.attr.vctr_settings_icon_tint_color)
    +        }
         }
     
    +    // Todo this should be refactored and use same state as 4S section
         private fun refreshXSigningStatus() {
    -            val xSigningIsEnableInAccount = session.cryptoService().crossSigningService().isCrossSigningInitialized()
    -            val xSigningKeysAreTrusted = session.cryptoService().crossSigningService().checkUserTrust(session.myUserId).isVerified()
    -            val xSigningKeyCanSign = session.cryptoService().crossSigningService().canCrossSign()
    +        val crossSigningKeys = session.cryptoService().crossSigningService().getMyCrossSigningKeys()
    +        val xSigningIsEnableInAccount = crossSigningKeys != null
    +        val xSigningKeysAreTrusted = session.cryptoService().crossSigningService().checkUserTrust(session.myUserId).isVerified()
    +        val xSigningKeyCanSign = session.cryptoService().crossSigningService().canCrossSign()
     
    -            if (xSigningKeyCanSign) {
    +        when {
    +            xSigningKeyCanSign        -> {
                     mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_trusted)
                     mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_complete)
    -            } else if (xSigningKeysAreTrusted) {
    +            }
    +            xSigningKeysAreTrusted    -> {
                     mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_custom)
                     mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_trusted)
    -            } else if (xSigningIsEnableInAccount) {
    +            }
    +            xSigningIsEnableInAccount -> {
                     mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_black)
                     mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_not_trusted)
    -            } else {
    +            }
    +            else                      -> {
                     mCrossSigningStatePreference.setIcon(android.R.color.transparent)
                     mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_disabled)
                 }
    -
    -            mCrossSigningStatePreference.isVisible = true
    -    }
    -
    -    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    -        if (allGranted(grantResults)) {
    -            if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) {
    -                exportKeys()
    -            }
             }
    +
    +        mCrossSigningStatePreference.isVisible = true
         }
     
         override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
             super.onActivityResult(requestCode, resultCode, data)
    +        if (requestCode == REQUEST_CODE_SAVE_MEGOLM_EXPORT) {
    +            val uri = data?.data
    +            if (resultCode == Activity.RESULT_OK && uri != null) {
    +                activity?.let { activity ->
    +                    ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener {
    +                        override fun onPassphrase(passphrase: String) {
    +                            displayLoadingView()
     
    +                            KeysExporter(session)
    +                                    .export(requireContext(),
    +                                            passphrase,
    +                                            uri,
    +                                            object : MatrixCallback {
    +                                                override fun onSuccess(data: Boolean) {
    +                                                    if (data) {
    +                                                        requireActivity().toast(getString(R.string.encryption_exported_successfully))
    +                                                    } else {
    +                                                        requireActivity().toast(getString(R.string.unexpected_error))
    +                                                    }
    +                                                    hideLoadingView()
    +                                                }
    +
    +                                                override fun onFailure(failure: Throwable) {
    +                                                    onCommonDone(failure.localizedMessage)
    +                                                }
    +                                            })
    +                        }
    +                    })
    +                }
    +            }
    +        }
             if (resultCode == Activity.RESULT_OK) {
                 when (requestCode) {
                     REQUEST_E2E_FILE_REQUEST_CODE -> importKeys(data)
    @@ -169,7 +302,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
             }
     
             exportPref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
    -            exportKeys()
    +            queryExportKeys(activeSessionHolder.getSafeActiveSession()?.myUserId ?: "", REQUEST_CODE_SAVE_MEGOLM_EXPORT)
                 true
             }
     
    @@ -179,46 +312,6 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
             }
         }
     
    -    /**
    -     * Manage the e2e keys export.
    -     */
    -    private fun exportKeys() {
    -        // We need WRITE_EXTERNAL permission
    -        if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES,
    -                        this,
    -                        PERMISSION_REQUEST_CODE_EXPORT_KEYS,
    -                        R.string.permissions_rationale_msg_keys_backup_export)) {
    -            activity?.let { activity ->
    -                ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener {
    -                    override fun onPassphrase(passphrase: String) {
    -                        displayLoadingView()
    -
    -                        KeysExporter(session)
    -                                .export(requireContext(),
    -                                        passphrase,
    -                                        object : MatrixCallback {
    -                                            override fun onSuccess(data: String) {
    -                                                if (isAdded) {
    -                                                    hideLoadingView()
    -
    -                                                    AlertDialog.Builder(activity)
    -                                                            .setMessage(getString(R.string.encryption_export_saved_as, data))
    -                                                            .setCancelable(false)
    -                                                            .setPositiveButton(R.string.ok, null)
    -                                                            .show()
    -                                                }
    -                                            }
    -
    -                                            override fun onFailure(failure: Throwable) {
    -                                                onCommonDone(failure.localizedMessage)
    -                                            }
    -                                        })
    -                    }
    -                })
    -            }
    -        }
    -    }
    -
         /**
          * Manage the e2e keys import.
          */
    @@ -515,6 +608,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
     
         companion object {
             private const val REQUEST_E2E_FILE_REQUEST_CODE = 123
    +        private const val REQUEST_CODE_SAVE_MEGOLM_EXPORT = 124
     
             private const val PUSHER_PREFERENCE_KEY_BASE = "PUSHER_PREFERENCE_KEY_BASE"
             private const val DEVICES_PREFERENCE_KEY_BASE = "DEVICES_PREFERENCE_KEY_BASE"
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/account/deactivation/DeactivateAccountFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/account/deactivation/DeactivateAccountFragment.kt
    index 8a8a5fa4e4..447f1086be 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/account/deactivation/DeactivateAccountFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/account/deactivation/DeactivateAccountFragment.kt
    @@ -107,7 +107,7 @@ class DeactivateAccountFragment @Inject constructor(
                         displayErrorDialog(it.throwable)
                     }
                     DeactivateAccountViewEvents.Done            ->
    -                    MainActivity.restartApp(activity!!, MainActivityArgs(clearCredentials = true, isAccountDeactivated = true))
    +                    MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCredentials = true, isAccountDeactivated = true))
                 }.exhaustive
             }
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/crosssigning/CrossSigningSettingsFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/crosssigning/CrossSigningSettingsFragment.kt
    index 37d9677f7f..5778d05d1c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/settings/crosssigning/CrossSigningSettingsFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/crosssigning/CrossSigningSettingsFragment.kt
    @@ -51,7 +51,7 @@ class CrossSigningSettingsFragment @Inject constructor(
                         Unit
                     }
                     CrossSigningSettingsViewEvents.VerifySession -> {
    -                    navigator.waitSessionVerification(requireActivity())
    +                    navigator.requestSelfSessionVerification(requireActivity())
                     }
                     CrossSigningSettingsViewEvents.SetUpRecovery -> {
                         navigator.upgradeSessionSecurity(requireActivity(), false)
    diff --git a/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt b/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    index b37c1a4818..b29e60784e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/themes/ActivityOtherThemes.kt
    @@ -38,4 +38,10 @@ sealed class ActivityOtherThemes(@StyleRes val dark: Int,
                 R.style.AppTheme_AttachmentsPreview,
                 R.style.AppTheme_AttachmentsPreview
         )
    +
    +    object VectorAttachmentsPreview : ActivityOtherThemes(
    +            R.style.AppTheme_Transparent,
    +            R.style.AppTheme_Transparent,
    +            R.style.AppTheme_Transparent
    +    )
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/ui/SharedPreferencesUiStateRepository.kt b/vector/src/main/java/im/vector/riotx/features/ui/SharedPreferencesUiStateRepository.kt
    index d1a4315cc9..ec1f8e5131 100644
    --- a/vector/src/main/java/im/vector/riotx/features/ui/SharedPreferencesUiStateRepository.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/ui/SharedPreferencesUiStateRepository.kt
    @@ -19,12 +19,16 @@ package im.vector.riotx.features.ui
     import android.content.SharedPreferences
     import androidx.core.content.edit
     import im.vector.riotx.features.home.RoomListDisplayMode
    +import im.vector.riotx.features.settings.VectorPreferences
     import javax.inject.Inject
     
     /**
      * This class is used to persist UI state across application restart
      */
    -class SharedPreferencesUiStateRepository @Inject constructor(private val sharedPreferences: SharedPreferences) : UiStateRepository {
    +class SharedPreferencesUiStateRepository @Inject constructor(
    +        private val sharedPreferences: SharedPreferences,
    +        private val vectorPreferences: VectorPreferences
    +) : UiStateRepository {
     
         override fun reset() {
             sharedPreferences.edit {
    @@ -36,7 +40,11 @@ class SharedPreferencesUiStateRepository @Inject constructor(private val sharedP
             return when (sharedPreferences.getInt(KEY_DISPLAY_MODE, VALUE_DISPLAY_MODE_CATCHUP)) {
                 VALUE_DISPLAY_MODE_PEOPLE -> RoomListDisplayMode.PEOPLE
                 VALUE_DISPLAY_MODE_ROOMS  -> RoomListDisplayMode.ROOMS
    -            else                      -> RoomListDisplayMode.PEOPLE // RoomListDisplayMode.HOME
    +            else                      -> if (vectorPreferences.labAddNotificationTab()) {
    +                RoomListDisplayMode.NOTIFICATIONS
    +            } else {
    +                RoomListDisplayMode.PEOPLE
    +            }
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt
    index 9d11387fe8..d5fc34728a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt
    @@ -60,7 +60,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
                 is Loading       -> renderLoading()
                 is Success       -> renderSuccess(
                         computeUsersList(asyncUsers(), currentState.directorySearchTerm),
    -                    currentState.selectedUsers.map { it.userId },
    +                    currentState.getSelectedMatrixId(),
                         hasSearch
                 )
                 is Fail          -> renderFailure(asyncUsers.error)
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt
    index 7a1ad49b8c..c78368f01b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt
    @@ -51,7 +51,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
     
         fun setData(state: UserDirectoryViewState) {
             this.isFiltering = !state.filterKnownUsersValue.isEmpty()
    -        val newSelection = state.selectedUsers.map { it.userId }
    +        val newSelection = state.getSelectedMatrixId()
             this.users = state.knownUsers
             if (newSelection != selectedUsers) {
                 this.selectedUsers = newSelection
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt
    index 42dd46bd01..671c0b0ee1 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt
    @@ -63,8 +63,9 @@ class KnownUsersFragment @Inject constructor(
             setupRecyclerView()
             setupFilterView()
             setupAddByMatrixIdView()
    +        setupAddFromPhoneBookView()
             setupCloseView()
    -        viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) {
    +        viewModel.selectSubscribe(this, UserDirectoryViewState::pendingInvitees) {
                 renderSelectedUsers(it)
             }
         }
    @@ -77,7 +78,7 @@ class KnownUsersFragment @Inject constructor(
     
         override fun onPrepareOptionsMenu(menu: Menu) {
             withState(viewModel) {
    -            val showMenuItem = it.selectedUsers.isNotEmpty()
    +            val showMenuItem = it.pendingInvitees.isNotEmpty()
                 menu.forEach { menuItem ->
                     menuItem.isVisible = showMenuItem
                 }
    @@ -86,7 +87,7 @@ class KnownUsersFragment @Inject constructor(
         }
     
         override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) {
    -        sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.selectedUsers))
    +        sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.pendingInvitees))
             return@withState true
         }
     
    @@ -96,6 +97,13 @@ class KnownUsersFragment @Inject constructor(
             }
         }
     
    +    private fun setupAddFromPhoneBookView() {
    +        addFromPhoneBook.debouncedClicks {
    +            // TODO handle Permission first
    +            sharedActionViewModel.post(UserDirectorySharedAction.OpenPhoneBook)
    +        }
    +    }
    +
         private fun setupRecyclerView() {
             knownUsersController.callback = this
             // Don't activate animation as we might have way to much item animation when filtering
    @@ -131,14 +139,14 @@ class KnownUsersFragment @Inject constructor(
             knownUsersController.setData(it)
         }
     
    -    private fun renderSelectedUsers(selectedUsers: Set) {
    +    private fun renderSelectedUsers(invitees: Set) {
             invalidateOptionsMenu()
     
             val currentNumberOfChips = chipGroup.childCount
    -        val newNumberOfChips = selectedUsers.size
    +        val newNumberOfChips = invitees.size
     
             chipGroup.removeAllViews()
    -        selectedUsers.forEach { addChipToGroup(it) }
    +        invitees.forEach { addChipToGroup(it) }
     
             // Scroll to the bottom when adding chips. When removing chips, do not scroll
             if (newNumberOfChips >= currentNumberOfChips) {
    @@ -148,22 +156,22 @@ class KnownUsersFragment @Inject constructor(
             }
         }
     
    -    private fun addChipToGroup(user: User) {
    +    private fun addChipToGroup(pendingInvitee: PendingInvitee) {
             val chip = Chip(requireContext())
             chip.setChipBackgroundColorResource(android.R.color.transparent)
             chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat()
    -        chip.text = user.getBestName()
    +        chip.text = pendingInvitee.getBestName()
             chip.isClickable = true
             chip.isCheckable = false
             chip.isCloseIconVisible = true
             chipGroup.addView(chip)
             chip.setOnCloseIconClickListener {
    -            viewModel.handle(UserDirectoryAction.RemoveSelectedUser(user))
    +            viewModel.handle(UserDirectoryAction.RemovePendingInvitee(pendingInvitee))
             }
         }
     
         override fun onItemClick(user: User) {
             view?.hideKeyboard()
    -        viewModel.handle(UserDirectoryAction.SelectUser(user))
    +        viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt
    new file mode 100644
    index 0000000000..c9aad1cf65
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt
    @@ -0,0 +1,32 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.userdirectory
    +
    +import im.vector.matrix.android.api.session.identity.ThreePid
    +import im.vector.matrix.android.api.session.user.model.User
    +
    +sealed class PendingInvitee {
    +    data class UserPendingInvitee(val user: User) : PendingInvitee()
    +    data class ThreePidPendingInvitee(val threePid: ThreePid) : PendingInvitee()
    +
    +    fun getBestName(): String {
    +        return when (this) {
    +            is UserPendingInvitee     -> user.getBestName()
    +            is ThreePidPendingInvitee -> threePid.value
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt
    index 1df3c02736..fde71cff5c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt
    @@ -16,13 +16,12 @@
     
     package im.vector.riotx.features.userdirectory
     
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.riotx.core.platform.VectorViewModelAction
     
     sealed class UserDirectoryAction : VectorViewModelAction {
         data class FilterKnownUsers(val value: String) : UserDirectoryAction()
         data class SearchDirectoryUsers(val value: String) : UserDirectoryAction()
         object ClearFilterKnownUsers : UserDirectoryAction()
    -    data class SelectUser(val user: User) : UserDirectoryAction()
    -    data class RemoveSelectedUser(val user: User) : UserDirectoryAction()
    +    data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
    +    data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt
    index 12de191b54..a6d22dfbe3 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt
    @@ -84,7 +84,7 @@ class UserDirectoryFragment @Inject constructor(
     
         override fun onItemClick(user: User) {
             view?.hideKeyboard()
    -        viewModel.handle(UserDirectoryAction.SelectUser(user))
    +        viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
             sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt
    index 7d1987aa4b..14270f31a7 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt
    @@ -16,12 +16,12 @@
     
     package im.vector.riotx.features.userdirectory
     
    -import im.vector.matrix.android.api.session.user.model.User
     import im.vector.riotx.core.platform.VectorSharedAction
     
     sealed class UserDirectorySharedAction : VectorSharedAction {
         object OpenUsersDirectory : UserDirectorySharedAction()
    +    object OpenPhoneBook : UserDirectorySharedAction()
         object Close : UserDirectorySharedAction()
         object GoBack : UserDirectorySharedAction()
    -    data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set) : UserDirectorySharedAction()
    +    data class OnMenuItemSelected(val itemId: Int, val invitees: Set) : UserDirectorySharedAction()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt
    index 3111a86bf7..57ebe408c7 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt
    @@ -28,6 +28,7 @@ import com.squareup.inject.assisted.AssistedInject
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.matrix.rx.rx
    +import im.vector.riotx.core.extensions.exhaustive
     import im.vector.riotx.core.extensions.toggle
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
    @@ -59,9 +60,9 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted
                     is FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state)
                     is ActivityViewModelContext -> {
                         when (viewModelContext.activity()) {
    -                        is CreateDirectRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state)
    +                        is CreateDirectRoomActivity  -> viewModelContext.activity().userDirectoryViewModelFactory.create(state)
                             is InviteUsersToRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state)
    -                        else                        -> error("Wrong activity or fragment")
    +                        else                         -> error("Wrong activity or fragment")
                         }
                     }
                     else                        -> error("Wrong activity or fragment")
    @@ -79,21 +80,21 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted
                 is UserDirectoryAction.FilterKnownUsers      -> knownUsersFilter.accept(Option.just(action.value))
                 is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty())
                 is UserDirectoryAction.SearchDirectoryUsers  -> directoryUsersSearch.accept(action.value)
    -            is UserDirectoryAction.SelectUser            -> handleSelectUser(action)
    -            is UserDirectoryAction.RemoveSelectedUser    -> handleRemoveSelectedUser(action)
    -        }
    +            is UserDirectoryAction.SelectPendingInvitee  -> handleSelectUser(action)
    +            is UserDirectoryAction.RemovePendingInvitee  -> handleRemoveSelectedUser(action)
    +        }.exhaustive
         }
     
    -    private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state ->
    -        val selectedUsers = state.selectedUsers.minus(action.user)
    -        setState { copy(selectedUsers = selectedUsers) }
    +    private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state ->
    +        val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee)
    +        setState { copy(pendingInvitees = selectedUsers) }
         }
     
    -    private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state ->
    +    private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state ->
             // Reset the filter asap
             directoryUsersSearch.accept("")
    -        val selectedUsers = state.selectedUsers.toggle(action.user)
    -        setState { copy(selectedUsers = selectedUsers) }
    +        val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee)
    +        setState { copy(pendingInvitees = selectedUsers) }
         }
     
         private fun observeDirectoryUsers() = withState { state ->
    diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt
    index 52f92a9994..4d99a75fde 100644
    --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt
    @@ -27,11 +27,21 @@ data class UserDirectoryViewState(
             val excludedUserIds: Set? = null,
             val knownUsers: Async> = Uninitialized,
             val directoryUsers: Async> = Uninitialized,
    -        val selectedUsers: Set = emptySet(),
    +        val pendingInvitees: Set = emptySet(),
             val createAndInviteState: Async = Uninitialized,
             val directorySearchTerm: String = "",
             val filterKnownUsersValue: Option = Option.empty()
     ) : MvRxState {
     
         constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds)
    +
    +    fun getSelectedMatrixId(): List {
    +        return pendingInvitees
    +                .mapNotNull {
    +                    when (it) {
    +                        is PendingInvitee.UserPendingInvitee     -> it.user.userId
    +                        is PendingInvitee.ThreePidPendingInvitee -> null
    +                    }
    +                }
    +    }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt
    index d516137bc5..89d597c4dc 100644
    --- a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt
    @@ -16,6 +16,7 @@
     
     package im.vector.riotx.features.widgets
     
    +import android.net.Uri
     import androidx.lifecycle.viewModelScope
     import com.airbnb.mvrx.ActivityViewModelContext
     import com.airbnb.mvrx.Fail
    @@ -236,7 +237,9 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi
                     _viewEvents.post(WidgetViewEvents.OnURLFormatted(formattedUrl))
                 } catch (failure: Throwable) {
                     if (failure is WidgetManagementFailure.TermsNotSignedException) {
    -                    _viewEvents.post(WidgetViewEvents.DisplayTerms(initialState.baseUrl, failure.token))
    +                    // Terms for IM shouldn't have path appended
    +                    val displayTermsBaseUrl = Uri.parse(initialState.baseUrl).buildUpon().path("").toString()
    +                    _viewEvents.post(WidgetViewEvents.DisplayTerms(displayTermsBaseUrl, failure.token))
                     }
                     setState { copy(formattedURL = Fail(failure)) }
                 }
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt
    new file mode 100644
    index 0000000000..bfeb959534
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt
    @@ -0,0 +1,176 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.workers.signout
    +
    +import androidx.lifecycle.MutableLiveData
    +import com.airbnb.mvrx.ActivityViewModelContext
    +import com.airbnb.mvrx.Async
    +import com.airbnb.mvrx.FragmentViewModelContext
    +import com.airbnb.mvrx.MvRxState
    +import com.airbnb.mvrx.MvRxViewModelFactory
    +import com.airbnb.mvrx.Uninitialized
    +import com.airbnb.mvrx.ViewModelContext
    +import com.squareup.inject.assisted.Assisted
    +import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.extensions.orFalse
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.crosssigning.MXCrossSigningInfo
    +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
    +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener
    +import im.vector.matrix.android.api.util.Optional
    +import im.vector.matrix.android.internal.crypto.store.PrivateKeysInfo
    +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData
    +import im.vector.matrix.rx.rx
    +import im.vector.riotx.core.platform.EmptyAction
    +import im.vector.riotx.core.platform.EmptyViewEvents
    +import im.vector.riotx.core.platform.VectorViewModel
    +import io.reactivex.Observable
    +import io.reactivex.functions.Function4
    +import io.reactivex.subjects.PublishSubject
    +import java.util.concurrent.TimeUnit
    +
    +data class ServerBackupStatusViewState(
    +        val bannerState: Async = Uninitialized
    +) : MvRxState
    +
    +/**
    + * The state representing the view
    + * It can take one state at a time
    + */
    +sealed class BannerState {
    +
    +    object Hidden : BannerState()
    +
    +    // Keys backup is not setup, numberOfKeys is the number of locally stored keys
    +    data class Setup(val numberOfKeys: Int) : BannerState()
    +
    +    // Keys are backing up
    +    object BackingUp : BannerState()
    +}
    +
    +class ServerBackupStatusViewModel @AssistedInject constructor(@Assisted initialState: ServerBackupStatusViewState,
    +                                                              private val session: Session)
    +    : VectorViewModel(initialState), KeysBackupStateListener {
    +
    +    @AssistedInject.Factory
    +    interface Factory {
    +        fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel
    +    }
    +
    +    companion object : MvRxViewModelFactory {
    +
    +        @JvmStatic
    +        override fun create(viewModelContext: ViewModelContext, state: ServerBackupStatusViewState): ServerBackupStatusViewModel? {
    +            val factory = when (viewModelContext) {
    +                is FragmentViewModelContext -> viewModelContext.fragment as? Factory
    +                is ActivityViewModelContext -> viewModelContext.activity as? Factory
    +            }
    +            return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
    +        }
    +    }
    +
    +    // Keys exported manually
    +    val keysExportedToFile = MutableLiveData()
    +    val keysBackupState = MutableLiveData()
    +
    +    private val keyBackupPublishSubject: PublishSubject = PublishSubject.create()
    +
    +    init {
    +        session.cryptoService().keysBackupService().addListener(this)
    +
    +        keysBackupState.value = session.cryptoService().keysBackupService().state
    +
    +        Observable.combineLatest, Optional, KeysBackupState, Optional, BannerState>(
    +                session.rx().liveAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME)),
    +                session.rx().liveCrossSigningInfo(session.myUserId),
    +                keyBackupPublishSubject,
    +                session.rx().liveCrossSigningPrivateKeys(),
    +                Function4 { _, crossSigningInfo, keyBackupState, pInfo ->
    +                    // first check if 4S is already setup
    +                    if (session.sharedSecretStorageService.isRecoverySetup()) {
    +                        // 4S is already setup sp we should not display anything
    +                        return@Function4 when (keyBackupState) {
    +                            KeysBackupState.BackingUp -> BannerState.BackingUp
    +                            else                      -> BannerState.Hidden
    +                        }
    +                    }
    +
    +                    // So recovery is not setup
    +                    // Check if cross signing is enabled and local secrets known
    +                    if (crossSigningInfo.getOrNull()?.isTrusted() == true
    +                            && pInfo.getOrNull()?.allKnown().orFalse()
    +                    ) {
    +                        // So 4S is not setup and we have local secrets,
    +                        return@Function4 BannerState.Setup(numberOfKeys = getNumberOfKeysToBackup())
    +                    }
    +
    +                    BannerState.Hidden
    +                }
    +        )
    +                .throttleLast(1000, TimeUnit.MILLISECONDS) // we don't want to flicker or catch transient states
    +                .distinctUntilChanged()
    +                .execute { async ->
    +                    copy(
    +                            bannerState = async
    +                    )
    +                }
    +
    +        keyBackupPublishSubject.onNext(session.cryptoService().keysBackupService().state)
    +    }
    +
    +    /**
    +     * Safe way to get the current KeysBackup version
    +     */
    +    fun getCurrentBackupVersion(): String {
    +        return session.cryptoService().keysBackupService().currentBackupVersion ?: ""
    +    }
    +
    +    /**
    +     * Safe way to get the number of keys to backup
    +     */
    +    fun getNumberOfKeysToBackup(): Int {
    +        return session.cryptoService().inboundGroupSessionsCount(false)
    +    }
    +
    +    /**
    +     * Safe way to tell if there are more keys on the server
    +     */
    +    fun canRestoreKeys(): Boolean {
    +        return session.cryptoService().keysBackupService().canRestoreKeys()
    +    }
    +
    +    override fun onCleared() {
    +        super.onCleared()
    +        session.cryptoService().keysBackupService().removeListener(this)
    +    }
    +
    +    override fun onStateChange(newState: KeysBackupState) {
    +        keyBackupPublishSubject.onNext(session.cryptoService().keysBackupService().state)
    +        keysBackupState.value = newState
    +    }
    +
    +    fun refreshRemoteStateIfNeeded() {
    +        if (keysBackupState.value == KeysBackupState.Disabled) {
    +            session.cryptoService().keysBackupService().checkAndStartKeysBackup()
    +        }
    +    }
    +
    +    override fun handle(action: EmptyAction) {}
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt
    index e1ef7bc07b..2ebf086796 100644
    --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt
    @@ -28,19 +28,27 @@ import android.widget.ProgressBar
     import android.widget.TextView
     import androidx.appcompat.app.AlertDialog
     import androidx.core.view.isVisible
    -import androidx.lifecycle.Observer
    -import androidx.transition.TransitionManager
     import butterknife.BindView
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.Success
    +import com.airbnb.mvrx.fragmentViewModel
    +import com.airbnb.mvrx.withState
     import com.google.android.material.bottomsheet.BottomSheetBehavior
     import com.google.android.material.bottomsheet.BottomSheetDialog
    +import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
     import im.vector.riotx.R
    +import im.vector.riotx.core.di.ScreenComponent
    +import im.vector.riotx.core.dialogs.ExportKeysDialog
    +import im.vector.riotx.core.extensions.queryExportKeys
     import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment
    -import im.vector.riotx.core.utils.toast
    -import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity
     import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupActivity
    +import im.vector.riotx.features.crypto.recover.BootstrapBottomSheet
    +import timber.log.Timber
    +import javax.inject.Inject
     
    -class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
    +// TODO this needs to be refactored to current standard and remove legacy
    +class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment(), SignoutCheckViewModel.Factory {
     
         @BindView(R.id.bottom_sheet_signout_warning_text)
         lateinit var sheetTitle: TextView
    @@ -48,14 +56,20 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
         @BindView(R.id.bottom_sheet_signout_backingup_status_group)
         lateinit var backingUpStatusGroup: ViewGroup
     
    -    @BindView(R.id.keys_backup_setup)
    -    lateinit var setupClickableView: View
    +    @BindView(R.id.setupRecoveryButton)
    +    lateinit var setupRecoveryButton: SignoutBottomSheetActionButton
     
    -    @BindView(R.id.keys_backup_activate)
    -    lateinit var activateClickableView: View
    +    @BindView(R.id.setupMegolmBackupButton)
    +    lateinit var setupMegolmBackupButton: SignoutBottomSheetActionButton
     
    -    @BindView(R.id.keys_backup_dont_want)
    -    lateinit var dontWantClickableView: View
    +    @BindView(R.id.exportManuallyButton)
    +    lateinit var exportManuallyButton: SignoutBottomSheetActionButton
    +
    +    @BindView(R.id.exitAnywayButton)
    +    lateinit var exitAnywayButton: SignoutBottomSheetActionButton
    +
    +    @BindView(R.id.signOutButton)
    +    lateinit var signOutButton: SignoutBottomSheetActionButton
     
         @BindView(R.id.bottom_sheet_signout_icon_progress_bar)
         lateinit var backupProgress: ProgressBar
    @@ -66,8 +80,8 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
         @BindView(R.id.bottom_sheet_backup_status_text)
         lateinit var backupStatusTex: TextView
     
    -    @BindView(R.id.bottom_sheet_signout_button)
    -    lateinit var signoutClickableView: View
    +    @BindView(R.id.signoutExportingLoading)
    +    lateinit var signoutExportingLoading: View
     
         @BindView(R.id.root_layout)
         lateinit var rootLayout: ViewGroup
    @@ -78,62 +92,44 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
             fun newInstance() = SignOutBottomSheetDialogFragment()
     
             private const val EXPORT_REQ = 0
    +        private const val QUERY_EXPORT_KEYS = 1
         }
     
         init {
             isCancelable = true
         }
     
    -    private lateinit var viewModel: SignOutViewModel
    +    @Inject
    +    lateinit var viewModelFactory: SignoutCheckViewModel.Factory
    +
    +    override fun create(initialState: SignoutCheckViewState): SignoutCheckViewModel {
    +        return viewModelFactory.create(initialState)
    +    }
    +
    +    private val viewModel: SignoutCheckViewModel by fragmentViewModel(SignoutCheckViewModel::class)
    +
    +    override fun injectWith(injector: ScreenComponent) {
    +        injector.inject(this)
    +    }
    +
    +    override fun onResume() {
    +        super.onResume()
    +        viewModel.refreshRemoteStateIfNeeded()
    +    }
     
         override fun onActivityCreated(savedInstanceState: Bundle?) {
             super.onActivityCreated(savedInstanceState)
     
    -        viewModel = fragmentViewModelProvider.get(SignOutViewModel::class.java)
    -
    -        setupClickableView.setOnClickListener {
    -            context?.let { context ->
    -                startActivityForResult(KeysBackupSetupActivity.intent(context, true), EXPORT_REQ)
    -            }
    +        setupRecoveryButton.action = {
    +            BootstrapBottomSheet.show(parentFragmentManager, initCrossSigningOnly = false, forceReset4S = false)
             }
     
    -        activateClickableView.setOnClickListener {
    -            context?.let { context ->
    -                startActivity(KeysBackupManageActivity.intent(context))
    -            }
    -        }
    -
    -        signoutClickableView.setOnClickListener {
    -            this.onSignOut?.run()
    -        }
    -
    -        dontWantClickableView.setOnClickListener { _ ->
    +        exitAnywayButton.action = {
                 context?.let {
                     AlertDialog.Builder(it)
                             .setTitle(R.string.are_you_sure)
                             .setMessage(R.string.sign_out_bottom_sheet_will_lose_secure_messages)
    -                        .setPositiveButton(R.string.backup) { _, _ ->
    -                            when (viewModel.keysBackupState.value) {
    -                                KeysBackupState.NotTrusted -> {
    -                                    context?.let { context ->
    -                                        startActivity(KeysBackupManageActivity.intent(context))
    -                                    }
    -                                }
    -                                KeysBackupState.Disabled   -> {
    -                                    context?.let { context ->
    -                                        startActivityForResult(KeysBackupSetupActivity.intent(context, true), EXPORT_REQ)
    -                                    }
    -                                }
    -                                KeysBackupState.BackingUp,
    -                                KeysBackupState.WillBackUp -> {
    -                                    // keys are already backing up please wait
    -                                    context?.toast(R.string.keys_backup_is_not_finished_please_wait)
    -                                }
    -                                else                       -> {
    -                                    // nop
    -                                }
    -                            }
    -                        }
    +                        .setPositiveButton(R.string.backup, null)
                             .setNegativeButton(R.string.action_sign_out) { _, _ ->
                                 onSignOut?.run()
                             }
    @@ -141,71 +137,143 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
                 }
             }
     
    -        viewModel.keysExportedToFile.observe(viewLifecycleOwner, Observer {
    -            val hasExportedToFile = it ?: false
    -            if (hasExportedToFile) {
    -                // We can allow to sign out
    -
    -                sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple)
    -
    -                signoutClickableView.isVisible = true
    -                dontWantClickableView.isVisible = false
    -                setupClickableView.isVisible = false
    -                activateClickableView.isVisible = false
    -                backingUpStatusGroup.isVisible = false
    +        exportManuallyButton.action = {
    +            withState(viewModel) { state ->
    +                queryExportKeys(state.userId, QUERY_EXPORT_KEYS)
                 }
    -        })
    +        }
     
    -        viewModel.keysBackupState.observe(viewLifecycleOwner, Observer {
    -            if (viewModel.keysExportedToFile.value == true) {
    -                // ignore this
    -                return@Observer
    -            }
    -            TransitionManager.beginDelayedTransition(rootLayout)
    +        setupMegolmBackupButton.action = {
    +            startActivityForResult(KeysBackupSetupActivity.intent(requireContext(), true), EXPORT_REQ)
    +        }
    +
    +        viewModel.observeViewEvents {
                 when (it) {
    -                KeysBackupState.ReadyToBackUp -> {
    -                    signoutClickableView.isVisible = true
    -                    dontWantClickableView.isVisible = false
    -                    setupClickableView.isVisible = false
    -                    activateClickableView.isVisible = false
    -                    backingUpStatusGroup.isVisible = true
    +                is SignoutCheckViewModel.ViewEvents.ExportKeys -> {
    +                    it.exporter
    +                            .export(requireContext(),
    +                                    it.passphrase,
    +                                    it.uri,
    +                                    object : MatrixCallback {
    +                                        override fun onSuccess(data: Boolean) {
    +                                            if (data) {
    +                                                viewModel.handle(SignoutCheckViewModel.Actions.KeySuccessfullyManuallyExported)
    +                                            } else {
    +                                                viewModel.handle(SignoutCheckViewModel.Actions.KeyExportFailed)
    +                                            }
    +                                        }
     
    +                                        override fun onFailure(failure: Throwable) {
    +                                            Timber.e("## Failed to export manually keys ${failure.localizedMessage}")
    +                                            viewModel.handle(SignoutCheckViewModel.Actions.KeyExportFailed)
    +                                        }
    +                                    })
    +                }
    +            }
    +        }
    +    }
    +
    +    override fun invalidate() = withState(viewModel) { state ->
    +        signoutExportingLoading.isVisible = false
    +        if (state.crossSigningSetupAllKeysKnown && !state.backupIsSetup) {
    +            sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup)
    +            backingUpStatusGroup.isVisible = false
    +            // we should show option to setup 4S
    +            setupRecoveryButton.isVisible = true
    +            setupMegolmBackupButton.isVisible = false
    +            signOutButton.isVisible = false
    +            // We let the option to ignore and quit
    +            exportManuallyButton.isVisible = true
    +            exitAnywayButton.isVisible = true
    +        } else if (state.keysBackupState == KeysBackupState.Unknown || state.keysBackupState == KeysBackupState.Disabled) {
    +            sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup)
    +            backingUpStatusGroup.isVisible = false
    +            // no key backup and cannot setup full 4S
    +            // we propose to setup
    +            // we should show option to setup 4S
    +            setupRecoveryButton.isVisible = false
    +            setupMegolmBackupButton.isVisible = true
    +            signOutButton.isVisible = false
    +            // We let the option to ignore and quit
    +            exportManuallyButton.isVisible = true
    +            exitAnywayButton.isVisible = true
    +        } else {
    +            // so keybackup is setup
    +            // You should wait until all are uploaded
    +            setupRecoveryButton.isVisible = false
    +
    +            when (state.keysBackupState) {
    +                KeysBackupState.ReadyToBackUp -> {
    +                    sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple)
    +
    +                    // Ok all keys are backedUp
    +                    backingUpStatusGroup.isVisible = true
                         backupProgress.isVisible = false
                         backupCompleteImage.isVisible = true
                         backupStatusTex.text = getString(R.string.keys_backup_info_keys_all_backup_up)
     
    -                    sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple)
    +                    hideViews(setupMegolmBackupButton, exportManuallyButton, exitAnywayButton)
    +                    // You can signout
    +                    signOutButton.isVisible = true
                     }
    -                KeysBackupState.BackingUp,
    -                KeysBackupState.WillBackUp    -> {
    -                    backingUpStatusGroup.isVisible = true
    -                    sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backing_up)
    -                    dontWantClickableView.isVisible = true
    -                    setupClickableView.isVisible = false
    -                    activateClickableView.isVisible = false
     
    +                KeysBackupState.WillBackUp,
    +                KeysBackupState.BackingUp     -> {
    +                    sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backing_up)
    +
    +                    // save in progress
    +                    backingUpStatusGroup.isVisible = true
                         backupProgress.isVisible = true
                         backupCompleteImage.isVisible = false
                         backupStatusTex.text = getString(R.string.sign_out_bottom_sheet_backing_up_keys)
    +
    +                    hideViews(setupMegolmBackupButton, setupMegolmBackupButton, signOutButton, exportManuallyButton)
    +                    exitAnywayButton.isVisible = true
                     }
                     KeysBackupState.NotTrusted    -> {
    -                    backingUpStatusGroup.isVisible = false
    -                    dontWantClickableView.isVisible = true
    -                    setupClickableView.isVisible = false
    -                    activateClickableView.isVisible = true
                         sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backup_not_active)
    +                    // It's not trusted and we know there are unsaved keys..
    +                    backingUpStatusGroup.isVisible = false
    +
    +                    exportManuallyButton.isVisible = true
    +                    // option to enter pass/key
    +                    setupMegolmBackupButton.isVisible = true
    +                    exitAnywayButton.isVisible = true
                     }
                     else                          -> {
    -                    backingUpStatusGroup.isVisible = false
    -                    dontWantClickableView.isVisible = true
    -                    setupClickableView.isVisible = true
    -                    activateClickableView.isVisible = false
    -                    sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup)
    +                    // mmm.. strange state
    +
    +                    exitAnywayButton.isVisible = true
                     }
                 }
    +        }
     
    -            // updateSignOutSection()
    -        })
    +        // final call if keys have been exported
    +        when (state.hasBeenExportedToFile) {
    +            is Loading -> {
    +                signoutExportingLoading.isVisible = true
    +                hideViews(setupRecoveryButton,
    +                        setupMegolmBackupButton,
    +                        exportManuallyButton,
    +                        backingUpStatusGroup,
    +                        signOutButton)
    +                exitAnywayButton.isVisible = true
    +            }
    +            is Success -> {
    +                if (state.hasBeenExportedToFile.invoke()) {
    +                    sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple)
    +                    hideViews(setupRecoveryButton,
    +                            setupMegolmBackupButton,
    +                            exportManuallyButton,
    +                            backingUpStatusGroup,
    +                            exitAnywayButton)
    +                    signOutButton.isVisible = true
    +                }
    +            }
    +            else       -> {
    +            }
    +        }
    +        super.invalidate()
         }
     
         override fun getLayoutResId() = R.layout.bottom_sheet_logout_and_backup
    @@ -228,10 +296,26 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() {
             super.onActivityResult(requestCode, resultCode, data)
     
             if (resultCode == Activity.RESULT_OK) {
    -            if (requestCode == EXPORT_REQ) {
    -                val manualExportDone = data?.getBooleanExtra(KeysBackupSetupActivity.MANUAL_EXPORT, false)
    -                viewModel.keysExportedToFile.value = manualExportDone
    +            if (requestCode == QUERY_EXPORT_KEYS) {
    +                val uri = data?.data
    +                if (resultCode == Activity.RESULT_OK && uri != null) {
    +                    activity?.let { activity ->
    +                        ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener {
    +                            override fun onPassphrase(passphrase: String) {
    +                                viewModel.handle(SignoutCheckViewModel.Actions.ExportKeys(passphrase, uri))
    +                            }
    +                        })
    +                    }
    +                }
    +            } else if (requestCode == EXPORT_REQ) {
    +                if (data?.getBooleanExtra(KeysBackupSetupActivity.MANUAL_EXPORT, false) == true) {
    +                    viewModel.handle(SignoutCheckViewModel.Actions.KeySuccessfullyManuallyExported)
    +                }
                 }
             }
         }
    +
    +    private fun hideViews(vararg views: View) {
    +        views.forEach { it.isVisible = false }
    +    }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt
    index e51fda2be5..e06a47d3d4 100644
    --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt
    @@ -21,7 +21,7 @@ import androidx.appcompat.app.AlertDialog
     import androidx.fragment.app.FragmentActivity
     import im.vector.riotx.R
     import im.vector.riotx.core.di.ActiveSessionHolder
    -import im.vector.riotx.core.extensions.hasUnsavedKeys
    +import im.vector.riotx.core.extensions.cannotLogoutSafely
     import im.vector.riotx.core.extensions.vectorComponent
     import im.vector.riotx.features.MainActivity
     import im.vector.riotx.features.MainActivityArgs
    @@ -33,7 +33,7 @@ class SignOutUiWorker(private val activity: FragmentActivity) {
         fun perform(context: Context) {
             activeSessionHolder = context.vectorComponent().activeSessionHolder()
             val session = activeSessionHolder.getActiveSession()
    -        if (session.hasUnsavedKeys()) {
    +        if (session.cannotLogoutSafely()) {
                 // The backup check on logout flow has to be displayed if there are keys in the store, and the keys backup state is not Ready
                 val signOutDialog = SignOutBottomSheetDialogFragment.newInstance()
                 signOutDialog.onSignOut = Runnable {
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt
    deleted file mode 100644
    index 2f26fdf377..0000000000
    --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt
    +++ /dev/null
    @@ -1,74 +0,0 @@
    -/*
    - * Copyright 2019 New Vector Ltd
    - *
    - * Licensed under the Apache License, Version 2.0 (the "License");
    - * you may not use this file except in compliance with the License.
    - * You may obtain a copy of the License at
    - *
    - *     http://www.apache.org/licenses/LICENSE-2.0
    - *
    - * Unless required by applicable law or agreed to in writing, software
    - * distributed under the License is distributed on an "AS IS" BASIS,
    - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    - * See the License for the specific language governing permissions and
    - * limitations under the License.
    - */
    -
    -package im.vector.riotx.features.workers.signout
    -
    -import androidx.lifecycle.MutableLiveData
    -import androidx.lifecycle.ViewModel
    -import im.vector.matrix.android.api.session.Session
    -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
    -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener
    -import javax.inject.Inject
    -
    -class SignOutViewModel @Inject constructor(private val session: Session) : ViewModel(), KeysBackupStateListener {
    -    // Keys exported manually
    -    var keysExportedToFile = MutableLiveData()
    -
    -    var keysBackupState = MutableLiveData()
    -
    -    init {
    -        session.cryptoService().keysBackupService().addListener(this)
    -
    -        keysBackupState.value = session.cryptoService().keysBackupService().state
    -    }
    -
    -    /**
    -     * Safe way to get the current KeysBackup version
    -     */
    -    fun getCurrentBackupVersion(): String {
    -        return session.cryptoService().keysBackupService().currentBackupVersion ?: ""
    -    }
    -
    -    /**
    -     * Safe way to get the number of keys to backup
    -     */
    -    fun getNumberOfKeysToBackup(): Int {
    -        return session.cryptoService().inboundGroupSessionsCount(false)
    -    }
    -
    -    /**
    -     * Safe way to tell if there are more keys on the server
    -     */
    -    fun canRestoreKeys(): Boolean {
    -        return session.cryptoService().keysBackupService().canRestoreKeys()
    -    }
    -
    -    override fun onCleared() {
    -        super.onCleared()
    -
    -        session.cryptoService().keysBackupService().removeListener(this)
    -    }
    -
    -    override fun onStateChange(newState: KeysBackupState) {
    -        keysBackupState.value = newState
    -    }
    -
    -    fun refreshRemoteStateIfNeeded() {
    -        if (keysBackupState.value == KeysBackupState.Disabled) {
    -            session.cryptoService().keysBackupService().checkAndStartKeysBackup()
    -        }
    -    }
    -}
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt
    new file mode 100644
    index 0000000000..cd5e4ed9da
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt
    @@ -0,0 +1,95 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.workers.signout
    +
    +import android.content.Context
    +import android.content.res.ColorStateList
    +import android.graphics.drawable.Drawable
    +import android.util.AttributeSet
    +import android.view.View
    +import android.widget.ImageView
    +import android.widget.LinearLayout
    +import android.widget.TextView
    +import androidx.core.view.isVisible
    +import butterknife.BindView
    +import butterknife.ButterKnife
    +import im.vector.riotx.R
    +import im.vector.riotx.core.extensions.setTextOrHide
    +import im.vector.riotx.features.themes.ThemeUtils
    +
    +class SignoutBottomSheetActionButton @JvmOverloads constructor(
    +        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    +) : LinearLayout(context, attrs, defStyleAttr) {
    +
    +    @BindView(R.id.actionTitleText)
    +    lateinit var actionTextView: TextView
    +
    +    @BindView(R.id.actionIconImageView)
    +    lateinit var iconImageView: ImageView
    +
    +    @BindView(R.id.signedOutActionClickable)
    +    lateinit var clickableZone: View
    +
    +    var action: (() -> Unit)? = null
    +
    +    var title: String? = null
    +        set(value) {
    +            field = value
    +            actionTextView.setTextOrHide(value)
    +        }
    +
    +    var leftIcon: Drawable? = null
    +        set(value) {
    +            field = value
    +            if (value == null) {
    +                iconImageView.isVisible = false
    +                iconImageView.setImageDrawable(null)
    +            } else {
    +                iconImageView.isVisible = true
    +                iconImageView.setImageDrawable(value)
    +            }
    +        }
    +
    +    var tint: Int? = null
    +        set(value) {
    +            field = value
    +            iconImageView.imageTintList = value?.let { ColorStateList.valueOf(value) }
    +        }
    +
    +    var textColor: Int? = null
    +        set(value) {
    +            field = value
    +            textColor?.let { actionTextView.setTextColor(it) }
    +        }
    +
    +    init {
    +        inflate(context, R.layout.item_signout_action, this)
    +        ButterKnife.bind(this)
    +
    +        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.SignoutBottomSheetActionButton, 0, 0)
    +        title = typedArray.getString(R.styleable.SignoutBottomSheetActionButton_actionTitle) ?: ""
    +        leftIcon = typedArray.getDrawable(R.styleable.SignoutBottomSheetActionButton_leftIcon)
    +        tint = typedArray.getColor(R.styleable.SignoutBottomSheetActionButton_iconTint, ThemeUtils.getColor(context, android.R.attr.textColor))
    +        textColor = typedArray.getColor(R.styleable.SignoutBottomSheetActionButton_textColor, ThemeUtils.getColor(context, android.R.attr.textColor))
    +
    +        typedArray.recycle()
    +
    +        clickableZone.setOnClickListener {
    +            action?.invoke()
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt
    new file mode 100644
    index 0000000000..47da7d4edc
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt
    @@ -0,0 +1,148 @@
    +/*
    + * Copyright (c) 2020 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.workers.signout
    +
    +import android.net.Uri
    +import com.airbnb.mvrx.ActivityViewModelContext
    +import com.airbnb.mvrx.Async
    +import com.airbnb.mvrx.FragmentViewModelContext
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.MvRxState
    +import com.airbnb.mvrx.MvRxViewModelFactory
    +import com.airbnb.mvrx.Success
    +import com.airbnb.mvrx.Uninitialized
    +import com.airbnb.mvrx.ViewModelContext
    +import com.squareup.inject.assisted.Assisted
    +import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
    +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
    +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener
    +import im.vector.matrix.rx.rx
    +import im.vector.riotx.core.extensions.exhaustive
    +import im.vector.riotx.core.platform.VectorViewEvents
    +import im.vector.riotx.core.platform.VectorViewModel
    +import im.vector.riotx.core.platform.VectorViewModelAction
    +import im.vector.riotx.features.crypto.keys.KeysExporter
    +
    +data class SignoutCheckViewState(
    +        val userId: String = "",
    +        val backupIsSetup: Boolean = false,
    +        val crossSigningSetupAllKeysKnown: Boolean = false,
    +        val keysBackupState: KeysBackupState = KeysBackupState.Unknown,
    +        val hasBeenExportedToFile: Async = Uninitialized
    +) : MvRxState
    +
    +class SignoutCheckViewModel @AssistedInject constructor(@Assisted initialState: SignoutCheckViewState,
    +                                                        private val session: Session)
    +    : VectorViewModel(initialState), KeysBackupStateListener {
    +
    +    sealed class Actions : VectorViewModelAction {
    +        data class ExportKeys(val passphrase: String, val uri: Uri) : Actions()
    +        object KeySuccessfullyManuallyExported : Actions()
    +        object KeyExportFailed : Actions()
    +    }
    +
    +    sealed class ViewEvents : VectorViewEvents {
    +        data class ExportKeys(val exporter: KeysExporter, val passphrase: String, val uri: Uri) : ViewEvents()
    +    }
    +
    +    @AssistedInject.Factory
    +    interface Factory {
    +        fun create(initialState: SignoutCheckViewState): SignoutCheckViewModel
    +    }
    +
    +    companion object : MvRxViewModelFactory {
    +
    +        @JvmStatic
    +        override fun create(viewModelContext: ViewModelContext, state: SignoutCheckViewState): SignoutCheckViewModel? {
    +            val factory = when (viewModelContext) {
    +                is FragmentViewModelContext -> viewModelContext.fragment as? Factory
    +                is ActivityViewModelContext -> viewModelContext.activity as? Factory
    +            }
    +            return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
    +        }
    +    }
    +
    +    init {
    +        session.cryptoService().keysBackupService().addListener(this)
    +        session.cryptoService().keysBackupService().checkAndStartKeysBackup()
    +
    +        val quad4SIsSetup = session.sharedSecretStorageService.isRecoverySetup()
    +        val allKeysKnown = session.cryptoService().crossSigningService().allPrivateKeysKnown()
    +        val backupState = session.cryptoService().keysBackupService().state
    +        setState {
    +            copy(
    +                    userId = session.myUserId,
    +                    crossSigningSetupAllKeysKnown = allKeysKnown,
    +                    backupIsSetup = quad4SIsSetup,
    +                    keysBackupState = backupState
    +            )
    +        }
    +
    +        session.rx().liveAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME))
    +                .map {
    +                    session.sharedSecretStorageService.isRecoverySetup()
    +                }
    +                .distinctUntilChanged()
    +                .execute {
    +                    copy(backupIsSetup = it.invoke() == true)
    +                }
    +    }
    +
    +    override fun onCleared() {
    +        super.onCleared()
    +        session.cryptoService().keysBackupService().removeListener(this)
    +    }
    +
    +    override fun onStateChange(newState: KeysBackupState) {
    +        setState {
    +            copy(
    +                    keysBackupState = newState
    +            )
    +        }
    +    }
    +
    +    fun refreshRemoteStateIfNeeded() = withState { state ->
    +        if (state.keysBackupState == KeysBackupState.Disabled) {
    +            session.cryptoService().keysBackupService().checkAndStartKeysBackup()
    +        }
    +    }
    +
    +    override fun handle(action: Actions) {
    +        when (action) {
    +            is Actions.ExportKeys                   -> {
    +                setState {
    +                    copy(hasBeenExportedToFile = Loading())
    +                }
    +                _viewEvents.post(ViewEvents.ExportKeys(KeysExporter(session), action.passphrase, action.uri))
    +            }
    +            Actions.KeySuccessfullyManuallyExported -> {
    +                setState {
    +                    copy(hasBeenExportedToFile = Success(true))
    +                }
    +            }
    +            Actions.KeyExportFailed                 -> {
    +                setState {
    +                    copy(hasBeenExportedToFile = Uninitialized)
    +                }
    +            }
    +        }.exhaustive
    +    }
    +}
    diff --git a/vector/src/main/res/drawable/ic_pause.xml b/vector/src/main/res/drawable/ic_pause.xml
    new file mode 100644
    index 0000000000..13d6d2ec00
    --- /dev/null
    +++ b/vector/src/main/res/drawable/ic_pause.xml
    @@ -0,0 +1,10 @@
    +
    +  
    +
    diff --git a/vector/src/main/res/drawable/ic_play_arrow.xml b/vector/src/main/res/drawable/ic_play_arrow.xml
    new file mode 100644
    index 0000000000..13c137a921
    --- /dev/null
    +++ b/vector/src/main/res/drawable/ic_play_arrow.xml
    @@ -0,0 +1,10 @@
    +
    +  
    +
    diff --git a/vector/src/main/res/drawable/ic_secure_backup.xml b/vector/src/main/res/drawable/ic_secure_backup.xml
    new file mode 100644
    index 0000000000..899bb8d2ae
    --- /dev/null
    +++ b/vector/src/main/res/drawable/ic_secure_backup.xml
    @@ -0,0 +1,20 @@
    +
    +  
    +    
    +    
    +    
    +  
    +  
    +
    diff --git a/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml b/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml
    index feaa79e1dc..c6605dfc05 100644
    --- a/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml
    +++ b/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml
    @@ -70,137 +70,60 @@
         
     
         
    +        android:layout_height="44dp"
    +        android:gravity="center">
     
    -        
    -
    -        
    -
    +            android:layout_height="wrap_content" />
         
     
    -    
    -
    -        
    -
    -        
    -
    -    
    +        app:actionTitle="@string/secure_backup_setup"
    +        app:iconTint="?riotx_text_primary"
    +        app:leftIcon="@drawable/ic_secure_backup"
    +        app:textColor="?riotx_text_secondary" />
     
     
    -    
    +        app:actionTitle="@string/keys_backup_setup"
    +        app:iconTint="?riotx_text_primary"
    +        app:leftIcon="@drawable/backup_keys"
    +        app:textColor="?riotx_text_secondary" />
     
    -        
    -
    -        
    -    
    -
    -    
    +        app:actionTitle="@string/keys_backup_setup_step1_manual_export"
    +        app:iconTint="?riotx_text_primary"
    +        app:leftIcon="@drawable/ic_download"
    +        app:textColor="?riotx_text_secondary" />
     
    -        
    -
    -        
    -    
    +    
     
    +    
     
    \ No newline at end of file
    diff --git a/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml
    index 17b350542a..198f4ca83b 100644
    --- a/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml
    +++ b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml
    @@ -60,7 +60,7 @@
             app:layout_constraintTop_toTopOf="parent"
             tools:text="@tools:sample/first_names" />
     
    -    
     
    @@ -63,6 +64,7 @@
             app:actionDescription="@string/bottom_sheet_setup_secure_backup_security_phrase_subtitle"
             app:actionTitle="@string/bottom_sheet_setup_secure_backup_security_phrase_title"
             app:leftIcon="@drawable/ic_security_phrase_24dp"
    +        app:tint="?attr/riotx_text_primary"
             app:rightIcon="@drawable/ic_arrow_right"
             tools:visibility="visible" />
     
    @@ -71,4 +73,18 @@
             android:layout_height="1dp"
             android:background="?attr/vctr_list_divider_color" />
     
    +    
    +
     
    diff --git a/vector/src/main/res/layout/fragment_contacts_book.xml b/vector/src/main/res/layout/fragment_contacts_book.xml
    new file mode 100644
    index 0000000000..eb90da1bbe
    --- /dev/null
    +++ b/vector/src/main/res/layout/fragment_contacts_book.xml
    @@ -0,0 +1,122 @@
    +
    +
    +
    +
    +    
    +
    +        
    +
    +            
    +
    +                
    +
    +                
    +
    +            
    +
    +        
    +
    +        
    +
    +            
    +
    +        
    +
    +        
    +
    +        
    +
    +        
    +
    +    
    +
    +
    +
    diff --git a/vector/src/main/res/layout/fragment_create_direct_room.xml b/vector/src/main/res/layout/fragment_create_direct_room.xml
    index a6e477220b..10ba583336 100644
    --- a/vector/src/main/res/layout/fragment_create_direct_room.xml
    +++ b/vector/src/main/res/layout/fragment_create_direct_room.xml
    @@ -36,7 +36,7 @@
                         app:layout_constraintStart_toStartOf="parent"
                         app:layout_constraintTop_toTopOf="parent" />
     
    -                
     
    -                
     
    -                
     
    -            
    diff --git a/vector/src/main/res/layout/fragment_known_users.xml b/vector/src/main/res/layout/fragment_known_users.xml
    index 915d27bdf7..82ddea5323 100644
    --- a/vector/src/main/res/layout/fragment_known_users.xml
    +++ b/vector/src/main/res/layout/fragment_known_users.xml
    @@ -36,7 +36,7 @@
                         app:layout_constraintStart_toStartOf="parent"
                         app:layout_constraintTop_toTopOf="parent" />
     
    -                
     
    +        
    +
             
     
         
    diff --git a/vector/src/main/res/layout/fragment_matrix_profile.xml b/vector/src/main/res/layout/fragment_matrix_profile.xml
    index 6e3eca06bf..c935ab5cee 100644
    --- a/vector/src/main/res/layout/fragment_matrix_profile.xml
    +++ b/vector/src/main/res/layout/fragment_matrix_profile.xml
    @@ -71,7 +71,7 @@
                             tools:ignore="MissingConstraints"
                             tools:src="@drawable/ic_shield_trusted" />
     
    -                    
     
    -            
     
    -            
     
    -                
     
    -            
    @@ -46,7 +48,7 @@
                         tools:ignore="MissingConstraints"
                         tools:src="@drawable/ic_shield_trusted" />
     
    -                
     
    -                
     
    -        
     
    -    
     
    -    
     
    -    
     
    -    
    +
    +
    +    
    +
    +    
    +
    +
    \ No newline at end of file
    diff --git a/vector/src/main/res/layout/item_contact_main.xml b/vector/src/main/res/layout/item_contact_main.xml
    new file mode 100644
    index 0000000000..e9a07274b3
    --- /dev/null
    +++ b/vector/src/main/res/layout/item_contact_main.xml
    @@ -0,0 +1,39 @@
    +
    +
    +
    +    
    +
    +    
    +
    +
    \ No newline at end of file
    diff --git a/vector/src/main/res/layout/item_create_direct_room_user.xml b/vector/src/main/res/layout/item_create_direct_room_user.xml
    index ac6a660e38..fa7e742584 100644
    --- a/vector/src/main/res/layout/item_create_direct_room_user.xml
    +++ b/vector/src/main/res/layout/item_create_direct_room_user.xml
    @@ -36,7 +36,7 @@
                 android:visibility="visible" />
         
     
    -    
     
    -    
     
    -        
     
    -    
     
    -    
         
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
    +
    +
    +    
    +
    +    
    +
    +
    \ No newline at end of file
    diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml
    index 7cc929306e..0f27afd5b7 100644
    --- a/vector/src/main/res/layout/item_timeline_event_base.xml
    +++ b/vector/src/main/res/layout/item_timeline_event_base.xml
    @@ -23,7 +23,7 @@
             android:layout_marginTop="4dp"
             tools:src="@tools:sample/avatars" />
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
     
    -    
    +
    +
    +    
    +
    +    
    +
    +
    +    
    +
    +    
    +
    +    
    +
    +    
    +
    +
    +    
    +
    +    
    +
    +    
    +
    +    
    +
    diff --git a/vector/src/main/res/layout/vector_invite_view.xml b/vector/src/main/res/layout/vector_invite_view.xml
    index 5e557895c2..7356fcf64b 100644
    --- a/vector/src/main/res/layout/vector_invite_view.xml
    +++ b/vector/src/main/res/layout/vector_invite_view.xml
    @@ -57,34 +57,35 @@
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintTop_toBottomOf="@id/inviteIdentifierView" />
     
    -    
    -
    -    
     
    +    
    +
         
    diff --git a/vector/src/main/res/layout/view_keys_backup_banner.xml b/vector/src/main/res/layout/view_keys_backup_banner.xml
    index 87c92cf8b4..6c8fc2b5a1 100644
    --- a/vector/src/main/res/layout/view_keys_backup_banner.xml
    +++ b/vector/src/main/res/layout/view_keys_backup_banner.xml
    @@ -10,11 +10,11 @@
     
         
     
     
    -    
    -
         
     
    +    
    +
     
    diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml
    index 27d53fe90e..80ecf32029 100644
    --- a/vector/src/main/res/values/attrs.xml
    +++ b/vector/src/main/res/values/attrs.xml
    @@ -114,4 +114,10 @@
             
         
     
    +    
    +        
    +        
    +        
    +        
    +    
     
    diff --git a/vector/src/main/res/values/colors_riotx.xml b/vector/src/main/res/values/colors_riotx.xml
    index a9cb32c3fd..c9d1c2a223 100644
    --- a/vector/src/main/res/values/colors_riotx.xml
    +++ b/vector/src/main/res/values/colors_riotx.xml
    @@ -40,6 +40,7 @@
         
         #FF000000
         #FFFFFFFF
    +    #55000000
     
         
         Ongoing conference call.\nJoin as %1$s or %2$s
         Voice
    @@ -122,9 +126,11 @@
         Confirmation
         Warning
         Error
    +    Success
     
         
         Home
    +    Notifications
         Favourites
         People
         Rooms
    @@ -838,6 +844,15 @@
         Send message with enter
         Enter button of the soft keyboard will send message instead of adding a line break
     
    +    Secure Backup
    +    Manage
    +    Set up Secure Backup
    +    Reset Secure Backup
    +    Set up on this device
    +    Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.
    +    Generate a new Security Key or set a new Security Phrase for your existing backup.
    +    This will replace your current Key or Phrase.
    +
         Deactivate account
         Deactivate my account
         Discovery
    @@ -1048,6 +1063,7 @@
         Export
         Please create a passphrase to encrypt the exported keys. You will need to enter the same passphrase to be able to import the keys.
         The E2E room keys have been saved to \'%s\'.\n\nWarning: this file may be deleted if the application is uninstalled.
    +    Keys successfully exported
     
         Encrypted Messages Recovery
         Manage Key Backup
    @@ -1408,6 +1424,7 @@ Why choose Riot.im?
         Share
         Save as File
         The recovery key has been saved to \'%s\'.\n\nWarning: this file may be deleted if the application is uninstalled.
    +    The recovery key has been saved.
     
         A backup already exist on your HomeServer
         It looks like you already have setup key backup from another session. Do you want to replace it with the one you’re creating?
    @@ -1493,17 +1510,24 @@ Why choose Riot.im?
         New Key Backup
         A new secure message key backup has been detected.\n\nIf you didn’t set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.
         It was me
    +
         
         Never lose encrypted messages
         Start using Key Backup
     
    +    Secure Backup
    +    Safeguard against losing access to encrypted messages & data
    +
         Never lose encrypted messages
         Use Key Backup
     
         New secure message keys
         Manage in Key Backup
     
    -    Backing up keys…
    +    Backing up your keys. This may take several minutes…
    +
    +
    +    Set Up Secure Backup
     
         
         All keys backed up
    @@ -1737,6 +1761,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
     
         Enable swipe to reply in timeline
         Merge failed to decrypt message in timeline
    +    Add a dedicated tab for unread notifications on main screen.
     
         Link copied to clipboard
     
    @@ -2514,4 +2539,17 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
         You cannot access this message because your session is not trusted by the sender
         You cannot access this message because the sender purposely did not send the keys
         Waiting for encryption history
    +
    +    Save recovery key in
    +
    +    Add from my phone book
    +    Your phone book is empty
    +    Phone book
    +    Search in my contacts
    +    Retrieving your contacts…
    +    Your contact book is empty
    +    Contacts book
    +
    +    Revoke invite
    +    Revoke invite to %1$s?
     
    diff --git a/vector/src/main/res/values/theme_common.xml b/vector/src/main/res/values/theme_common.xml
    index 151d97c097..414d562ff0 100644
    --- a/vector/src/main/res/values/theme_common.xml
    +++ b/vector/src/main/res/values/theme_common.xml
    @@ -10,4 +10,15 @@
     
         
    +
     
    \ No newline at end of file
    diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml
    index 9917bb0feb..2c52b2198e 100644
    --- a/vector/src/main/res/xml/vector_settings_labs.xml
    +++ b/vector/src/main/res/xml/vector_settings_labs.xml
    @@ -44,6 +44,12 @@
             android:defaultValue="false"
             android:key="SETTINGS_LABS_MERGE_E2E_ERRORS"
             android:title="@string/labs_merge_e2e_in_timeline" />
    +
    +
    +    
         
     
     
    \ No newline at end of file
    diff --git a/vector/src/main/res/xml/vector_settings_security_privacy.xml b/vector/src/main/res/xml/vector_settings_security_privacy.xml
    index 8b4823eac9..9bfe5e944b 100644
    --- a/vector/src/main/res/xml/vector_settings_security_privacy.xml
    +++ b/vector/src/main/res/xml/vector_settings_security_privacy.xml
    @@ -48,6 +48,23 @@
     
         
     
    +    
    +
    +        
    +
    +        
    +    
    +
    +