diff --git a/CHANGES.md b/CHANGES.md index 1200be4a12..861863e45f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,16 +1,15 @@ -Changes in Riot.imX 0.91.5 (2020-XX-XX) +Changes in Riot.imX 0.91.6 (2020-XX-XX) =================================================== Features ✨: - 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) + - Bugfix 🐛: - - Regression | Share action menu do not work (#1647) + - Video calls are shown as a voice ones in the timeline (#1676) + - Fix regression: not able to create a room without IS configured (#1679) Translations 🗣: - @@ -18,12 +17,49 @@ Translations 🗣: SDK API changes ⚠️: - +Build 🧱: + - + +Other changes: + - + +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) =================================================== 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 af3952b2d3..47b3ab240d 100644 --- a/build.gradle +++ b/build.gradle @@ -39,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/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/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/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/call/CallInviteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt index 1fad181fab..b165575ba0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt @@ -59,5 +59,5 @@ data class CallInviteContent( } } - fun isVideo(): Boolean = offer?.sdp?.contains(Offer.SDP_VIDEO) == true + fun isVideo() = offer?.sdp?.contains(Offer.SDP_VIDEO) == true } 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/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/database/EventInsertLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/EventInsertLiveObserver.kt index f0884918c0..98d8806288 100644 --- 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 @@ -72,7 +72,7 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real it.process(realm, domainEvent) } } - realm.where(EventInsertEntity::class.java).findAll().deleteAllFromRealm() + realm.delete(EventInsertEntity::class.java) } } } @@ -88,8 +88,8 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real 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 + Timber.v("Failed to decrypt event") + // TODO -> we should keep track of this and retry, or some processing will never be handled } } } 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 bc681e4eb8..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 @@ -123,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/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/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 83ba76d5b8..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 @@ -52,6 +52,7 @@ 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 @@ -64,6 +65,7 @@ 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 @@ -113,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(), @@ -255,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/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/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/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt index bde0cc512d..5214317f3b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationProcessor.kt @@ -109,7 +109,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(@UserId pr return } val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") - when (event.getClearType()) { + when (event.type) { EventType.REACTION -> { // we got a reaction!! Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") @@ -161,7 +161,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor(@UserId pr if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE || encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE ) { - // we need to decrypt if needed event.getClearContent().toModel()?.let { if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) { Timber.v("###REPLACE in room $roomId for event ${event.eventId}") 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 7fa9c1526a..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,6 +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.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 @@ -139,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 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..4bf54251e5 --- /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 { invites -> + // 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 + + invites.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/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/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/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 16c98770e2..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 @@ -349,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 ) } @@ -369,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) 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 5723568197..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, @@ -73,10 +79,10 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv 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/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index 8e0e4759e9..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 @@ -241,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/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index f3af24001d..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,7 +31,7 @@ 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 @@ -48,6 +48,7 @@ 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.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 @@ -73,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) { @@ -185,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, @@ -221,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 } @@ -263,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 } @@ -307,14 +313,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle 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) @@ -405,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/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 3977a08db3..e4730b4be1 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -279,6 +279,7 @@ 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" @@ -289,7 +290,8 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.1.0' implementation "androidx.fragment:fragment:$fragment_version" implementation "androidx.fragment:fragment-ktx:$fragment_version" - implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta7' + // 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.3.0' implementation "org.threeten:threetenbp:1.4.0:no-tzdb" @@ -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' diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 476d59190f..ef591de1b6 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 ad40980349..241240d7e3 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,16 +42,13 @@ 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.disclaimer.doNotShowDisclaimerDialog
     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
    @@ -80,16 +75,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
     
    @@ -115,7 +107,6 @@ class VectorApplication :
             logInfo()
             LazyThreeTen.init(this)
     
    -        BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
             EpoxyController.defaultDiffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
             EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
             registerActivityLifecycleCallbacks(VectorActivityLifecycleCallbacks(popupAlertManager))
    @@ -142,8 +133,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..4c007e60d6 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 = "element-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 = "element-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/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/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 dc4199e9f5..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, "element-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 e6cf53206f..95fe9b0ffa 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt
    @@ -15,13 +15,13 @@
      */
     package im.vector.riotx.features.crypto.keysbackup.setup
     
    -import android.os.AsyncTask
     import android.os.Bundle
     import android.view.ViewGroup
     import android.view.inputmethod.EditorInfo
     import android.widget.EditText
     import android.widget.ImageView
     import androidx.lifecycle.Observer
    +import androidx.lifecycle.viewModelScope
     import androidx.transition.TransitionManager
     import butterknife.BindView
     import butterknife.OnClick
    @@ -33,6 +33,8 @@ import im.vector.riotx.core.extensions.showPassword
     import im.vector.riotx.core.platform.VectorBaseFragment
     import im.vector.riotx.core.ui.views.PasswordStrengthBar
     import im.vector.riotx.features.settings.VectorLocale
    +import kotlinx.coroutines.Dispatchers
    +import kotlinx.coroutines.launch
     import javax.inject.Inject
     
     class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment() {
    @@ -117,9 +119,9 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment()
                 if (newValue.isEmpty()) {
                     viewModel.passwordStrength.value = null
                 } else {
    -                AsyncTask.execute {
    +                viewModel.viewModelScope.launch(Dispatchers.IO) {
                         val strength = zxcvbn.measure(newValue)
    -                    activity?.runOnUiThread {
    +                    launch(Dispatchers.Main) {
                             viewModel.passwordStrength.value = strength
                         }
                     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt
    index 21a25f1684..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)
    @@ -130,15 +134,15 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
             }
     
             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()
             }
     
    @@ -163,34 +167,32 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment()
             }
         }
     
    -    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/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/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 d38a26c099..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
    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 e2e7700d1f..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) {
    @@ -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..090e2dda3f 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,11 +257,36 @@ 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     -> {
                     val content = event.getClearContent().toModel() ?: return null
    -                val isVideoCall = content.offer?.sdp == CallInviteContent.Offer.SDP_VIDEO
    +                val isVideoCall = content.isVideo()
                     return if (isVideoCall) {
                         if (event.isSentByCurrentUser()) {
                             sp.getString(R.string.notice_placed_video_call_by_you)
    @@ -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..f9cb6ec3dc 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,49 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
                     .into(imageView)
         }
     
    +    /**
    +     * onlyRetrieveFromCache is true!
    +     */
    +    fun renderForSharedElementTransition(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
    +            }
    +        })
    +                .onlyRetrieveFromCache(true)
    +                .fitCenter()
    +                .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 092199759f..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)
    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..9e5facd162
    --- /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.renderForSharedElementTransition(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.renderForSharedElementTransition(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 760d3b12a0..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,
    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 aa414ec2a1..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
    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/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/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/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/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 5d428bd570..6bf5d5d240 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
    @@ -71,137 +71,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 f72404339d..a71634508c 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 bad72d3c0d..dd2fbfce7a 100644
    --- a/vector/src/main/res/values/attrs.xml
    +++ b/vector/src/main/res/values/attrs.xml
    @@ -107,4 +107,10 @@
             
         
     
    +    
    +        
    +        
    +        
    +        
    +    
     
    diff --git a/vector/src/main/res/values/colors_riotx.xml b/vector/src/main/res/values/colors_riotx.xml
    index 66946512a6..8b4e0eff8b 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 Element?
         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 Element?
         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 Element yet. Main missing (and comin
     
         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
     
    @@ -2523,4 +2548,17 @@ Not all features in Riot are implemented in Element yet. Main missing (and comin
     
         element
     
    +
    +    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 @@
     
         
     
    +    
    +
    +        
    +
    +        
    +    
    +
    +