diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 91dc6d830b..1ba71c1f61 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,6 +20,10 @@ jobs:
       fail-fast: false
       matrix:
         target: [ Gplay, Fdroid ]
+    # Allow all jobs on develop. Just one per PR.
+    concurrency:
+      group: ${{ github.ref == 'refs/heads/develop' && format('integration-tests-develop-{0}-{1}', matrix.target, github.sha) || format('build-debug-{0}-{1}', matrix.target, github.ref)  }}
+      cancel-in-progress: true
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
@@ -43,6 +47,7 @@ jobs:
     name: Build unsigned GPlay APKs
     runs-on: ubuntu-latest
     if: github.ref == 'refs/heads/main'
+    # Only runs on main, no concurrency.
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
index 405a2b3065..ee4a87293f 100644
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -5,6 +5,7 @@ jobs:
   validation:
     name: "Validation"
     runs-on: ubuntu-latest
+    # No concurrency required, this is a prerequisite to other actions and should run every time.
     steps:
       - uses: actions/checkout@v2
       - uses: gradle/wrapper-validation-action@v1
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index f688f5579e..192be1fe9e 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -20,6 +20,7 @@ jobs:
   build-android-test-matrix-sdk:
     name: Matrix SDK - Build Android Tests
     runs-on: macos-latest
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-java@v2
@@ -41,6 +42,7 @@ jobs:
   build-android-test-app:
     name: App - Build Android Tests
     runs-on: macos-latest
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-java@v2
@@ -58,7 +60,7 @@ jobs:
       - name: Build Android Tests for vector
         run: ./gradlew clean vector:assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace
 
- # Run Android Tests
+  # Run Android Tests
   integration-tests:
     name: Matrix SDK - Running Integration Tests
     runs-on: macos-latest
@@ -66,6 +68,7 @@ jobs:
       fail-fast: false
       matrix:
         api-level: [ 28 ]
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - uses: gradle/wrapper-validation-action@v1
@@ -91,7 +94,7 @@ jobs:
           curl https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh -o start.sh
           chmod 777 start.sh
           ./start.sh --no-rate-limit
-# package: org.matrix.android.sdk.session
+      # package: org.matrix.android.sdk.session
       - name: Run integration tests for  Matrix SDK [org.matrix.android.sdk.session] API[${{ matrix.api-level }}]
         uses: reactivecircus/android-emulator-runner@v2
         with:
@@ -121,7 +124,7 @@ jobs:
         if: always()
         id: get-comment-body-account
         run: python3 ./tools/ci/render_test_output.py account ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml
-# package: org.matrix.android.sdk.internal
+      # package: org.matrix.android.sdk.internal
       - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.internal] API[${{ matrix.api-level }}]
         if: always()
         uses: reactivecircus/android-emulator-runner@v2
@@ -137,7 +140,7 @@ jobs:
         if: always()
         id: get-comment-body-internal
         run: python3 ./tools/ci/render_test_output.py internal ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml
-# package: org.matrix.android.sdk.ordering
+      # package: org.matrix.android.sdk.ordering
       - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.ordering] API[${{ matrix.api-level }}]
         if: always()
         uses: reactivecircus/android-emulator-runner@v2
@@ -153,7 +156,7 @@ jobs:
         if: always()
         id: get-comment-body-ordering
         run: python3 ./tools/ci/render_test_output.py ordering ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml
-# package: class PermalinkParserTest
+      # package: class PermalinkParserTest
       - name: Run integration tests for Matrix SDK class [org.matrix.android.sdk.PermalinkParserTest] API[${{ matrix.api-level }}]
         if: always()
         uses: reactivecircus/android-emulator-runner@v2
@@ -169,7 +172,7 @@ jobs:
         if: always()
         id: get-comment-body-permalink
         run: python3 ./tools/ci/render_test_output.py permalink ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml
-# package: class PermalinkParserTest
+      # package: class PermalinkParserTest
       - name: Find Comment
         if: always() && github.event_name == 'pull_request'
         uses: peter-evans/find-comment@v1
@@ -201,6 +204,7 @@ jobs:
       fail-fast: false
       matrix:
         api-level: [ 28 ]
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
         with:
@@ -255,14 +259,15 @@ jobs:
   notify:
     runs-on: ubuntu-latest
     needs:
-    - integration-tests
-    - ui-tests
+      - integration-tests
+      - ui-tests
     if: always() && github.event_name != 'workflow_dispatch'
+    # No concurrency required, runs every time on a schedule.
     steps:
-    - uses: michaelkaye/matrix-hookshot-action@v0.2.0
-      with:
-        github_token: ${{ secrets.GITHUB_TOKEN }}
-        matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
-        matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }}
-        text_template: "Nightly test run: {{#each job_statuses }}{{#with this }}{{#if completed }}  {{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
-        html_template: "Nightly test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}<br />{{name}} {{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a>{{/if}}{{/with}}{{/each}}"
+      - uses: michaelkaye/matrix-hookshot-action@v0.2.0
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
+          matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }}
+          text_template: "Nightly test run: {{#each job_statuses }}{{#with this }}{{#if completed }}  {{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
+          html_template: "Nightly test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}<br />{{name}} {{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a>{{/if}}{{/with}}{{/each}}"
\ No newline at end of file
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 69f17a3875..02827e7f17 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -18,6 +18,10 @@ jobs:
   ktlint:
     name: Kotlin Linter
     runs-on: ubuntu-latest
+    # Allow all jobs on main and develop. Just one per PR.
+    concurrency:
+      group: ${{ github.ref == 'refs/heads/main' && format('ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('ktlint-develop-{0}', github.sha) || format('ktlint-{0}', github.ref) }}
+      cancel-in-progress: true
     steps:
       - uses: actions/checkout@v2
       - name: Run ktlint
@@ -87,6 +91,10 @@ jobs:
   android-lint:
     name: Android Linter
     runs-on: ubuntu-latest
+    # Allow all jobs on main and develop. Just one per PR.
+    concurrency:
+      group: ${{ github.ref == 'refs/heads/main' && format('android-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('android-lint-develop-{0}', github.sha) || format('android-lint-{0}', github.ref) }}
+      cancel-in-progress: true
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
@@ -116,6 +124,10 @@ jobs:
       fail-fast: false
       matrix:
         target: [ Gplay, Fdroid ]
+    # Allow all jobs on develop. Just one per PR.
+    concurrency:
+      group: ${{ github.ref == 'refs/heads/develop' && format('apk-lint-develop-{0}-{1}', matrix.target, github.sha) || format('apk-lint-{0}-{1}', matrix.target, github.ref) }}
+      cancel-in-progress: true
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
diff --git a/.github/workflows/sanity_test.yml b/.github/workflows/sanity_test.yml
deleted file mode 100644
index 83ad067446..0000000000
--- a/.github/workflows/sanity_test.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-name: Sanity Test
-
-on:
-  schedule:
-    # At 20:00 every day UTC
-    - cron: '0 20 * * *'
-
-# Enrich gradle.properties for CI/CD
-env:
-  CI_GRADLE_ARG_PROPERTIES: >
-    -Porg.gradle.jvmargs=-Xmx4g
-    -Porg.gradle.parallel=false
-
-jobs:
-  integration-tests:
-    name: Sanity Tests (Synapse)
-    runs-on: macos-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        api-level: [ 28 ]
-    steps:
-      - uses: actions/checkout@v2
-        with:
-          ref: develop
-      - name: Set up Python 3.8
-        uses: actions/setup-python@v2
-        with:
-          python-version: 3.8
-      - uses: actions/cache@v2
-        with:
-          path: |
-            ~/.gradle/caches
-            ~/.gradle/wrapper
-          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
-          restore-keys: |
-            ${{ runner.os }}-gradle-
-      - name: Start synapse server
-        run: |
-          pip install matrix-synapse
-          curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \
-            | sed s/127.0.0.1/0.0.0.0/g | sed 's/http:\/\/localhost/http:\/\/10.0.2.2/g' | bash -s -- --no-rate-limit
-      - uses: actions/setup-java@v2
-        with:
-          distribution: 'adopt'
-          java-version: '11'
-      - name: Run sanity tests on API ${{ matrix.api-level }}
-        uses: reactivecircus/android-emulator-runner@v2
-        with:
-          api-level: ${{ matrix.api-level }}
-          arch: x86
-          profile: Nexus 5X
-          force-avd-creation: false
-          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
-          emulator-build: 7425822  # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
-          script: |
-            adb root
-            adb logcat -c
-            touch emulator.log
-            chmod 777 emulator.log
-            adb logcat >> emulator.log &
-            ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || (adb pull storage/emulated/0/Pictures/failure_screenshots && exit 1 )
-      - name: Upload Test Report Log
-        uses: actions/upload-artifact@v2
-        if: always()
-        with:
-          name: sanity-error-results
-          path: |
-            emulator.log
-            failure_screenshots/
-
-
-  notify:
-    runs-on: ubuntu-latest
-    needs: integration-tests
-    if: always()
-    steps:
-    - uses: michaelkaye/matrix-hookshot-action@v0.2.0
-      with:
-        github_token: ${{ secrets.GITHUB_TOKEN }}
-        matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
-        matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }}
-        text_template: "Sanity test run: {{#each job_statuses }}{{#with this }}{{#if completed }}  {{name}} {{conclusion}} at {{completed_at}} {{html_url}}{{/if}}{{/with}}{{/each}}"
-        html_template: "CI Sanity test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}  {{name}} {{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a>{{/if}}{{/with}}{{/each}}"
diff --git a/.github/workflows/sync-from-external-sources.yml b/.github/workflows/sync-from-external-sources.yml
index a890082575..2323af0554 100644
--- a/.github/workflows/sync-from-external-sources.yml
+++ b/.github/workflows/sync-from-external-sources.yml
@@ -9,6 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     # Skip in forks
     if: github.repository == 'vector-im/element-android'
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python 3.8
@@ -35,6 +36,7 @@ jobs:
     runs-on: ubuntu-latest
     # Skip in forks
     if: github.repository == 'vector-im/element-android'
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python 3.8
@@ -60,6 +62,7 @@ jobs:
     runs-on: ubuntu-latest
     # Skip in forks
     if: github.repository == 'vector-im/element-android'
+    # No concurrency required, runs every time on a schedule.
     steps:
       - uses: actions/checkout@v2
       - name: Run analytics import script
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 50195638de..d6e194916b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -15,6 +15,10 @@ jobs:
   unit-tests:
     name: Run Unit Tests
     runs-on: ubuntu-latest
+    # Allow all jobs on main and develop. Just one per PR.
+    concurrency:
+      group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
+      cancel-in-progress: true
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
diff --git a/changelog.d/5005.feature b/changelog.d/5005.feature
new file mode 100644
index 0000000000..ce3b2ad1f9
--- /dev/null
+++ b/changelog.d/5005.feature
@@ -0,0 +1 @@
+Add possibility to save media from Gallery + reorder choices in message context menu
diff --git a/changelog.d/5325.feature b/changelog.d/5325.feature
new file mode 100644
index 0000000000..23754c790d
--- /dev/null
+++ b/changelog.d/5325.feature
@@ -0,0 +1 @@
+Adds forceLoginFallback feature flag and usages to FTUE login and registration
\ No newline at end of file
diff --git a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt
index 573138bf5c..21af114c26 100644
--- a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt
+++ b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt
@@ -45,6 +45,8 @@ import kotlin.math.abs
 
 abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener {
 
+    protected val rootView: View
+        get() = views.rootContainer
     protected val pager2: ViewPager2
         get() = views.attachmentPager
     protected val imageTransitionView: ImageView
@@ -298,10 +300,11 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
 
     private fun createSwipeToDismissHandler(): SwipeToDismissHandler =
             SwipeToDismissHandler(
-            swipeView = views.dismissContainer,
-            shouldAnimateDismiss = { shouldAnimateDismiss() },
-            onDismiss = { animateClose() },
-            onSwipeViewMove = ::handleSwipeViewMove)
+                    swipeView = views.dismissContainer,
+                    shouldAnimateDismiss = { shouldAnimateDismiss() },
+                    onDismiss = { animateClose() },
+                    onSwipeViewMove = ::handleSwipeViewMove
+            )
 
     private fun createSwipeDirectionDetector() =
             SwipeDirectionDetector(this) { swipeDirection = it }
diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt
index 5cc4bd3bde..8702c8d966 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt
@@ -53,7 +53,7 @@ class DebugFeaturesStateFactory @Inject constructor(
                         label = "FTUE Personalize profile",
                         key = DebugFeatureKeys.onboardingPersonalize,
                         factory = VectorFeatures::isOnboardingPersonalizeEnabled
-                )
+                ),
         ))
     }
 
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
index 808c379354..b54d776901 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
@@ -43,9 +43,13 @@ class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSett
         views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked ->
             viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked))
         }
+        views.forceLoginFallback.setOnCheckedChangeListener { _, isChecked ->
+            viewModel.handle(DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled(isChecked))
+        }
     }
 
     override fun invalidate() = withState(viewModel) {
         views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
+        views.forceLoginFallback.isChecked = it.forceLoginFallback
     }
 }
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
index ecbb241387..1c76cf6fb2 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
@@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
 
 sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
     data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
+    data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
 }
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
index 624c46556a..038b1e6cc7 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
@@ -45,15 +45,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
 
     private fun observeVectorDataStore() {
         vectorDataStore.forceDialPadDisplayFlow.setOnEach {
-            copy(
-                    dialPadVisible = it
-            )
+            copy(dialPadVisible = it)
+        }
+
+        vectorDataStore.forceLoginFallbackFlow.setOnEach {
+            copy(forceLoginFallback = it)
         }
     }
 
     override fun handle(action: DebugPrivateSettingsViewActions) {
         when (action) {
-            is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
+            is DebugPrivateSettingsViewActions.SetDialPadVisibility         -> handleSetDialPadVisibility(action)
+            is DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled -> handleSetForceLoginFallbackEnabled(action)
         }
     }
 
@@ -62,4 +65,10 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
             vectorDataStore.setForceDialPadDisplay(action.force)
         }
     }
+
+    private fun handleSetForceLoginFallbackEnabled(action: DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled) {
+        viewModelScope.launch {
+            vectorDataStore.setForceLoginFallbackFlow(action.force)
+        }
+    }
 }
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
index 0ad4b185ec..7fca29af8c 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
@@ -19,5 +19,6 @@ package im.vector.app.features.debug.settings
 import com.airbnb.mvrx.MavericksState
 
 data class DebugPrivateSettingsViewState(
-        val dialPadVisible: Boolean = false
+        val dialPadVisible: Boolean = false,
+        val forceLoginFallback: Boolean = false,
 ) : MavericksState
diff --git a/vector/src/debug/res/layout/fragment_debug_private_settings.xml b/vector/src/debug/res/layout/fragment_debug_private_settings.xml
index b4186e7bba..6760c68169 100644
--- a/vector/src/debug/res/layout/fragment_debug_private_settings.xml
+++ b/vector/src/debug/res/layout/fragment_debug_private_settings.xml
@@ -25,6 +25,12 @@
                 android:layout_height="wrap_content"
                 android:text="Force DialPad tab display" />
 
+            <CheckBox
+                android:id="@+id/forceLoginFallback"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="Force login and registration fallback" />
+
         </LinearLayout>
 
     </ScrollView>
diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
index 2cd7136ffc..33afcf1dfb 100644
--- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
+++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
@@ -58,6 +58,7 @@ import im.vector.app.features.login.LoginViewModel
 import im.vector.app.features.login2.LoginViewModel2
 import im.vector.app.features.login2.created.AccountCreatedViewModel
 import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel
+import im.vector.app.features.media.VectorAttachmentViewerViewModel
 import im.vector.app.features.onboarding.OnboardingViewModel
 import im.vector.app.features.poll.create.CreatePollViewModel
 import im.vector.app.features.qrcode.QrCodeScannerViewModel
@@ -594,4 +595,9 @@ interface MavericksViewModelModule {
     @IntoMap
     @MavericksViewModelKey(LocationSharingViewModel::class)
     fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
+
+    @Binds
+    @IntoMap
+    @MavericksViewModelKey(VectorAttachmentViewerViewModel::class)
+    fun vectorAttachmentViewerViewModelFactory(factory: VectorAttachmentViewerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
index 745cb0c731..5575d9b7f6 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
@@ -343,24 +343,6 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
                 add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType()))
             }
 
-            if (canRedact(timelineEvent, actionPermissions)) {
-                if (timelineEvent.root.getClearType() == EventType.POLL_START) {
-                    add(EventSharedAction.Redact(
-                            eventId,
-                            askForReason = informationData.senderId != session.myUserId,
-                            dialogTitleRes = R.string.delete_poll_dialog_title,
-                            dialogDescriptionRes = R.string.delete_poll_dialog_content
-                    ))
-                } else {
-                    add(EventSharedAction.Redact(
-                            eventId,
-                            askForReason = informationData.senderId != session.myUserId,
-                            dialogTitleRes = R.string.delete_event_dialog_title,
-                            dialogDescriptionRes = R.string.delete_event_dialog_content
-                    ))
-                }
-            }
-
             if (canCopy(msgType)) {
                 // TODO copy images? html? see ClipBoard
                 add(EventSharedAction.Copy(messageContent!!.body))
@@ -382,12 +364,30 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
                 add(EventSharedAction.ViewEditHistory(informationData))
             }
 
+            if (canSave(msgType) && messageContent is MessageWithAttachmentContent) {
+                add(EventSharedAction.Save(timelineEvent.eventId, messageContent))
+            }
+
             if (canShare(msgType)) {
                 add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!))
             }
 
-            if (canSave(msgType) && messageContent is MessageWithAttachmentContent) {
-                add(EventSharedAction.Save(timelineEvent.eventId, messageContent))
+            if (canRedact(timelineEvent, actionPermissions)) {
+                if (timelineEvent.root.getClearType() == EventType.POLL_START) {
+                    add(EventSharedAction.Redact(
+                            eventId,
+                            askForReason = informationData.senderId != session.myUserId,
+                            dialogTitleRes = R.string.delete_poll_dialog_title,
+                            dialogDescriptionRes = R.string.delete_poll_dialog_content
+                    ))
+                } else {
+                    add(EventSharedAction.Redact(
+                            eventId,
+                            askForReason = informationData.senderId != session.myUserId,
+                            dialogTitleRes = R.string.delete_event_dialog_title,
+                            dialogDescriptionRes = R.string.delete_event_dialog_content
+                    ))
+                }
             }
         }
 
diff --git a/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt b/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt
new file mode 100644
index 0000000000..b0cb913596
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media
+
+interface AttachmentInteractionListener {
+    fun onDismiss()
+    fun onShare()
+    fun onDownload()
+    fun onPlayPause(play: Boolean)
+    fun videoSeekTo(percent: Int)
+}
diff --git a/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt b/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt
index f79fb03898..58d10d2f2d 100644
--- a/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt
+++ b/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt
@@ -30,35 +30,33 @@ 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
-
+    var interactionListener: AttachmentInteractionListener? = null
     val views: MergeImageAttachmentOverlayBinding
 
-    var isPlaying = false
-
-    var suspendSeekBarUpdate = false
+    private var isPlaying = false
+    private var suspendSeekBarUpdate = false
 
     init {
         inflate(context, R.layout.merge_image_attachment_overlay, this)
         views = MergeImageAttachmentOverlayBinding.bind(this)
         setBackgroundColor(Color.TRANSPARENT)
         views.overlayBackButton.setOnClickListener {
-            onBack?.invoke()
+            interactionListener?.onDismiss()
         }
         views.overlayShareButton.setOnClickListener {
-            onShareCallback?.invoke()
+            interactionListener?.onShare()
+        }
+        views.overlayDownloadButton.setOnClickListener {
+            interactionListener?.onDownload()
         }
         views.overlayPlayPauseButton.setOnClickListener {
-            onPlayPause?.invoke(!isPlaying)
+            interactionListener?.onPlayPause(!isPlaying)
         }
 
         views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
             override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                 if (fromUser) {
-                    videoSeekTo?.invoke(progress)
+                    interactionListener?.videoSeekTo(progress)
                 }
             }
 
diff --git a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt
index ca469bfbcb..4039ea112b 100644
--- a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt
+++ b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt
@@ -49,14 +49,7 @@ abstract class BaseAttachmentProvider<Type>(
         private val stringProvider: StringProvider
 ) : AttachmentSourceProvider {
 
-    interface InteractionListener {
-        fun onDismissTapped()
-        fun onShareTapped()
-        fun onPlayPause(play: Boolean)
-        fun videoSeekTo(percent: Int)
-    }
-
-    var interactionListener: InteractionListener? = null
+    var interactionListener: AttachmentInteractionListener? = null
 
     private var overlayView: AttachmentOverlayView? = null
 
@@ -68,18 +61,7 @@ abstract class BaseAttachmentProvider<Type>(
         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)
-            }
+            overlayView?.interactionListener = interactionListener
         }
 
         val timelineEvent = getTimelineEventAtPosition(position)
diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt
new file mode 100644
index 0000000000..5af3cd193a
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media
+
+import im.vector.app.core.platform.VectorViewModelAction
+import java.io.File
+
+sealed class VectorAttachmentViewerAction : VectorViewModelAction {
+    data class DownloadMedia(val file: File) : VectorAttachmentViewerAction()
+}
diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt
index 103511bad5..d8c2b83f9b 100644
--- a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt
@@ -17,6 +17,7 @@ package im.vector.app.features.media
 
 import android.content.Context
 import android.content.Intent
+import android.os.Build
 import android.os.Bundle
 import android.os.Parcelable
 import android.view.View
@@ -30,16 +31,25 @@ import androidx.core.view.isInvisible
 import androidx.core.view.isVisible
 import androidx.lifecycle.lifecycleScope
 import androidx.transition.Transition
+import com.airbnb.mvrx.viewModel
 import dagger.hilt.android.AndroidEntryPoint
 import im.vector.app.R
 import im.vector.app.core.di.ActiveSessionHolder
+import im.vector.app.core.extensions.singletonEntryPoint
 import im.vector.app.core.intent.getMimeTypeFromUri
+import im.vector.app.core.platform.showOptimizedSnackbar
+import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES
+import im.vector.app.core.utils.checkPermissions
+import im.vector.app.core.utils.onPermissionDeniedDialog
+import im.vector.app.core.utils.registerForPermissionsResult
 import im.vector.app.core.utils.shareMedia
 import im.vector.app.features.themes.ActivityOtherThemes
 import im.vector.app.features.themes.ThemeUtils
 import im.vector.lib.attachmentviewer.AttachmentCommands
 import im.vector.lib.attachmentviewer.AttachmentViewerActivity
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.parcelize.Parcelize
@@ -47,7 +57,7 @@ import timber.log.Timber
 import javax.inject.Inject
 
 @AndroidEntryPoint
-class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener {
+class VectorAttachmentViewerActivity : AttachmentViewerActivity(), AttachmentInteractionListener {
 
     @Parcelize
     data class Args(
@@ -58,15 +68,28 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
 
     @Inject
     lateinit var sessionHolder: ActiveSessionHolder
+
     @Inject
     lateinit var dataSourceFactory: AttachmentProviderFactory
+
     @Inject
     lateinit var imageContentRenderer: ImageContentRenderer
 
+    private val viewModel: VectorAttachmentViewerViewModel by viewModel()
+    private val errorFormatter by lazy(LazyThreadSafetyMode.NONE) { singletonEntryPoint().errorFormatter() }
     private var initialIndex = 0
     private var isAnimatingOut = false
-
     private var currentSourceProvider: BaseAttachmentProvider<*>? = null
+    private val downloadActionResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+        if (allGranted) {
+            viewModel.pendingAction?.let {
+                viewModel.handle(it)
+            }
+        } else if (deniedPermanently) {
+            onPermissionDeniedDialog(R.string.denied_permission_generic)
+        }
+        viewModel.pendingAction = null
+    }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -128,6 +151,8 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
 
         window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha)
         window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha)
+
+        observeViewEvents()
     }
 
     override fun onResume() {
@@ -140,12 +165,6 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
         Timber.i("onPause Activity ${javaClass.simpleName}")
     }
 
-    private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
-
-    override fun shouldAnimateDismiss(): Boolean {
-        return currentPosition != initialIndex
-    }
-
     override fun onBackPressed() {
         if (currentPosition == initialIndex) {
             // show back the transition view
@@ -156,6 +175,10 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
         super.onBackPressed()
     }
 
+    override fun shouldAnimateDismiss(): Boolean {
+        return currentPosition != initialIndex
+    }
+
     override fun animateClose() {
         if (currentPosition == initialIndex) {
             // show back the transition view
@@ -166,9 +189,7 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
         ActivityCompat.finishAfterTransition(this)
     }
 
-    // ==========================================================================================
-    // PRIVATE METHODS
-    // ==========================================================================================
+    private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
 
     /**
      * Try and add a [Transition.TransitionListener] to the entering shared element
@@ -218,10 +239,72 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
                 })
     }
 
+    private fun observeViewEvents() {
+        viewModel.viewEvents
+                .stream()
+                .onEach(::handleViewEvents)
+                .launchIn(lifecycleScope)
+    }
+
+    private fun handleViewEvents(event: VectorAttachmentViewerViewEvents) {
+        when (event) {
+            is VectorAttachmentViewerViewEvents.ErrorDownloadingMedia -> showSnackBarError(event.error)
+        }
+    }
+
+    private fun showSnackBarError(error: Throwable) {
+        rootView.showOptimizedSnackbar(errorFormatter.toHumanReadable(error))
+    }
+
+    private fun hasWritePermission() =
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
+                    checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, downloadActionResultLauncher)
+
+    override fun onDismiss() {
+        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 onShare() {
+        lifecycleScope.launch(Dispatchers.IO) {
+            val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch
+
+            withContext(Dispatchers.Main) {
+                shareMedia(
+                        this@VectorAttachmentViewerActivity,
+                        file,
+                        getMimeTypeFromUri(this@VectorAttachmentViewerActivity, file.toUri())
+                )
+            }
+        }
+    }
+
+    override fun onDownload() {
+        lifecycleScope.launch(Dispatchers.IO) {
+            val hasWritePermission = withContext(Dispatchers.Main) {
+                hasWritePermission()
+            }
+
+            val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch
+            if (hasWritePermission) {
+                viewModel.handle(VectorAttachmentViewerAction.DownloadMedia(file))
+            } else {
+                viewModel.pendingAction = VectorAttachmentViewerAction.DownloadMedia(file)
+            }
+        }
+    }
+
     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"
+        private const val EXTRA_ARGS = "EXTRA_ARGS"
+        private const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA"
+        private const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA"
 
         fun newIntent(context: Context,
                       mediaData: AttachmentData,
@@ -236,30 +319,4 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
             }
         }
     }
-
-    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() {
-        lifecycleScope.launch(Dispatchers.IO) {
-            val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch
-
-            withContext(Dispatchers.Main) {
-                shareMedia(
-                        this@VectorAttachmentViewerActivity,
-                        file,
-                        getMimeTypeFromUri(this@VectorAttachmentViewerActivity, file.toUri())
-                )
-            }
-        }
-    }
 }
diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt
new file mode 100644
index 0000000000..e46ee02155
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media
+
+import im.vector.app.core.platform.VectorViewEvents
+
+sealed class VectorAttachmentViewerViewEvents : VectorViewEvents {
+    data class ErrorDownloadingMedia(val error: Throwable) : VectorAttachmentViewerViewEvents()
+}
diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt
new file mode 100644
index 0000000000..807c69caff
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media
+
+import com.airbnb.mvrx.MavericksViewModelFactory
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import im.vector.app.core.di.MavericksAssistedViewModelFactory
+import im.vector.app.core.di.hiltMavericksViewModelFactory
+import im.vector.app.core.platform.VectorDummyViewState
+import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.media.domain.usecase.DownloadMediaUseCase
+import im.vector.app.features.session.coroutineScope
+import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.session.Session
+
+class VectorAttachmentViewerViewModel @AssistedInject constructor(
+        @Assisted initialState: VectorDummyViewState,
+        private val session: Session,
+        private val downloadMediaUseCase: DownloadMediaUseCase
+) : VectorViewModel<VectorDummyViewState, VectorAttachmentViewerAction, VectorAttachmentViewerViewEvents>(initialState) {
+
+    @AssistedFactory
+    interface Factory : MavericksAssistedViewModelFactory<VectorAttachmentViewerViewModel, VectorDummyViewState> {
+        override fun create(initialState: VectorDummyViewState): VectorAttachmentViewerViewModel
+    }
+
+    companion object : MavericksViewModelFactory<VectorAttachmentViewerViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory()
+
+    var pendingAction: VectorAttachmentViewerAction? = null
+
+    override fun handle(action: VectorAttachmentViewerAction) {
+        when (action) {
+            is VectorAttachmentViewerAction.DownloadMedia -> handleDownloadAction(action)
+        }
+    }
+
+    private fun handleDownloadAction(action: VectorAttachmentViewerAction.DownloadMedia) {
+        // launch in the coroutine scope session to avoid binding the coroutine to the lifecycle of the VM
+        session.coroutineScope.launch {
+            // Success event is handled via a notification inside the use case
+            downloadMediaUseCase.execute(action.file)
+                    .onFailure { _viewEvents.post(VectorAttachmentViewerViewEvents.ErrorDownloadingMedia(it)) }
+        }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt b/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt
new file mode 100644
index 0000000000..b0401ccd30
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media.domain.usecase
+
+import android.content.Context
+import androidx.core.net.toUri
+import dagger.hilt.android.qualifiers.ApplicationContext
+import im.vector.app.core.intent.getMimeTypeFromUri
+import im.vector.app.core.utils.saveMedia
+import im.vector.app.features.notifications.NotificationUtils
+import kotlinx.coroutines.withContext
+import org.matrix.android.sdk.api.session.Session
+import java.io.File
+import javax.inject.Inject
+
+class DownloadMediaUseCase @Inject constructor(
+        @ApplicationContext private val appContext: Context,
+        private val session: Session,
+        private val notificationUtils: NotificationUtils
+) {
+
+    suspend fun execute(input: File): Result<Unit> = withContext(session.coroutineDispatchers.io) {
+        runCatching {
+            saveMedia(
+                    context = appContext,
+                    file = input,
+                    title = input.name,
+                    mediaMimeType = getMimeTypeFromUri(appContext, input.toUri()),
+                    notificationUtils = notificationUtils
+            )
+        }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
index ca3c3644bd..63f1875235 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
@@ -46,6 +46,7 @@ import im.vector.app.features.login.LoginMode
 import im.vector.app.features.login.ReAuthHelper
 import im.vector.app.features.login.ServerType
 import im.vector.app.features.login.SignMode
+import im.vector.app.features.settings.VectorDataStore
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
 import org.matrix.android.sdk.api.MatrixPatterns.getDomain
@@ -78,7 +79,8 @@ class OnboardingViewModel @AssistedInject constructor(
         private val stringProvider: StringProvider,
         private val homeServerHistoryService: HomeServerHistoryService,
         private val vectorFeatures: VectorFeatures,
-        private val analyticsTracker: AnalyticsTracker
+        private val analyticsTracker: AnalyticsTracker,
+        private val vectorDataStore: VectorDataStore,
 ) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
 
     @AssistedFactory
@@ -90,6 +92,7 @@ class OnboardingViewModel @AssistedInject constructor(
 
     init {
         getKnownCustomHomeServersUrls()
+        observeDataStore()
     }
 
     private fun getKnownCustomHomeServersUrls() {
@@ -98,6 +101,12 @@ class OnboardingViewModel @AssistedInject constructor(
         }
     }
 
+    private fun observeDataStore() = viewModelScope.launch {
+        vectorDataStore.forceLoginFallbackFlow.setOnEach { isForceLoginFallbackEnabled ->
+            copy(isForceLoginFallbackEnabled = isForceLoginFallbackEnabled)
+        }
+    }
+
     // Store the last action, to redo it after user has trusted the untrusted certificate
     private var lastAction: OnboardingAction? = null
     private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
index 7bad2682a9..39c5094d30 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
@@ -62,7 +62,8 @@ data class OnboardingViewState(
         // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
         @PersistState
         val loginModeSupportedTypes: List<String> = emptyList(),
-        val knownCustomHomeServersUrls: List<String> = emptyList()
+        val knownCustomHomeServersUrls: List<String> = emptyList(),
+        val isForceLoginFallbackEnabled: Boolean = false,
 ) : MavericksState {
 
     fun isLoading(): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
index 1e792df427..0093cb20ea 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
@@ -75,6 +75,8 @@ class FtueAuthVariant(
     private val popEnterAnim = R.anim.no_anim
     private val popExitAnim = R.anim.exit_fade_out
 
+    private var isForceLoginFallbackEnabled = false
+
     private val topFragment: Fragment?
         get() = supportFragmentManager.findFragmentById(views.loginFragmentContainer.id)
 
@@ -109,10 +111,6 @@ class FtueAuthVariant(
         }
     }
 
-    override fun setIsLoading(isLoading: Boolean) {
-        // do nothing
-    }
-
     private fun addFirstFragment() {
         val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) {
             true -> FtueAuthSplashCarouselFragment::class.java
@@ -121,11 +119,25 @@ class FtueAuthVariant(
         activity.addFragment(views.loginFragmentContainer, splashFragment)
     }
 
+    private fun updateWithState(viewState: OnboardingViewState) {
+        isForceLoginFallbackEnabled = viewState.isForceLoginFallbackEnabled
+        views.loginLoading.isVisible = shouldShowLoading(viewState)
+    }
+
+    private fun shouldShowLoading(viewState: OnboardingViewState) =
+            if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
+                viewState.isLoading()
+            } else {
+                // Keep loading when during success because of the delay when switching to the next Activity
+                viewState.isLoading() || viewState.isAuthTaskCompleted()
+            }
+
+    override fun setIsLoading(isLoading: Boolean) = Unit
+
     private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
         when (viewEvents) {
             is OnboardingViewEvents.RegistrationFlowResult                     -> {
-                // Check that all flows are supported by the application
-                if (viewEvents.flowResult.missingStages.any { !it.isSupported() }) {
+                if (registrationShouldFallback(viewEvents)) {
                     // Display a popup to propose use web fallback
                     onRegistrationStageNotSupported()
                 } else {
@@ -136,11 +148,7 @@ class FtueAuthVariant(
                         // First ask for login and password
                         // I add a tag to indicate that this fragment is a registration stage.
                         // This way it will be automatically popped in when starting the next registration stage
-                        activity.addFragmentToBackstack(views.loginFragmentContainer,
-                                FtueAuthLoginFragment::class.java,
-                                tag = FRAGMENT_REGISTRATION_STAGE_TAG,
-                                option = commonOption
-                        )
+                        openAuthLoginFragmentWithTag(FRAGMENT_REGISTRATION_STAGE_TAG)
                     }
                 }
             }
@@ -228,13 +236,23 @@ class FtueAuthVariant(
         }.exhaustive
     }
 
-    private fun updateWithState(viewState: OnboardingViewState) {
-        views.loginLoading.isVisible = if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
-            viewState.isLoading()
-        } else {
-            // Keep loading when during success because of the delay when switching to the next Activity
-            viewState.isLoading() || viewState.isAuthTaskCompleted()
-        }
+    private fun registrationShouldFallback(registrationFlowResult: OnboardingViewEvents.RegistrationFlowResult) =
+            isForceLoginFallbackEnabled || registrationFlowResult.containsUnsupportedRegistrationFlow()
+
+    private fun OnboardingViewEvents.RegistrationFlowResult.containsUnsupportedRegistrationFlow() =
+            flowResult.missingStages.any { !it.isSupported() }
+
+    private fun onRegistrationStageNotSupported() {
+        MaterialAlertDialogBuilder(activity)
+                .setTitle(R.string.app_name)
+                .setMessage(activity.getString(R.string.login_registration_not_supported))
+                .setPositiveButton(R.string.yes) { _, _ ->
+                    activity.addFragmentToBackstack(views.loginFragmentContainer,
+                            FtueAuthWebFragment::class.java,
+                            option = commonOption)
+                }
+                .setNegativeButton(R.string.no, null)
+                .show()
     }
 
     private fun onWebLoginError(onWebLoginError: OnboardingViewEvents.OnWebLoginError) {
@@ -264,29 +282,58 @@ class FtueAuthVariant(
         // state.signMode could not be ready yet. So use value from the ViewEvent
         when (OnboardingViewEvents.signMode) {
             SignMode.Unknown            -> error("Sign mode has to be set before calling this method")
-            SignMode.SignUp             -> {
-                // This is managed by the OnboardingViewEvents
-            }
-            SignMode.SignIn             -> {
-                // It depends on the LoginMode
-                when (state.loginMode) {
-                    LoginMode.Unknown,
-                    is LoginMode.Sso      -> error("Developer error")
-                    is LoginMode.SsoAndPassword,
-                    LoginMode.Password    -> activity.addFragmentToBackstack(views.loginFragmentContainer,
-                            FtueAuthLoginFragment::class.java,
-                            tag = FRAGMENT_LOGIN_TAG,
-                            option = commonOption)
-                    LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
-                }.exhaustive
-            }
-            SignMode.SignInWithMatrixId -> activity.addFragmentToBackstack(views.loginFragmentContainer,
-                    FtueAuthLoginFragment::class.java,
-                    tag = FRAGMENT_LOGIN_TAG,
-                    option = commonOption)
+            SignMode.SignUp             -> Unit // This case is processed in handleOnboardingViewEvents
+            SignMode.SignIn             -> handleSignInSelected(state)
+            SignMode.SignInWithMatrixId -> handleSignInWithMatrixId(state)
         }.exhaustive
     }
 
+    private fun handleSignInSelected(state: OnboardingViewState) {
+        if (isForceLoginFallbackEnabled) {
+            onLoginModeNotSupported(state.loginModeSupportedTypes)
+        } else {
+            disambiguateLoginMode(state)
+        }
+    }
+
+    private fun disambiguateLoginMode(state: OnboardingViewState) = when (state.loginMode) {
+        LoginMode.Unknown,
+        is LoginMode.Sso      -> error("Developer error")
+        is LoginMode.SsoAndPassword,
+        LoginMode.Password    -> openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
+        LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
+    }
+
+    private fun openAuthLoginFragmentWithTag(tag: String) {
+        activity.addFragmentToBackstack(views.loginFragmentContainer,
+                FtueAuthLoginFragment::class.java,
+                tag = tag,
+                option = commonOption)
+    }
+
+    private fun onLoginModeNotSupported(supportedTypes: List<String>) {
+        MaterialAlertDialogBuilder(activity)
+                .setTitle(R.string.app_name)
+                .setMessage(activity.getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" }))
+                .setPositiveButton(R.string.yes) { _, _ -> openAuthWebFragment() }
+                .setNegativeButton(R.string.no, null)
+                .show()
+    }
+
+    private fun handleSignInWithMatrixId(state: OnboardingViewState) {
+        if (isForceLoginFallbackEnabled) {
+            onLoginModeNotSupported(state.loginModeSupportedTypes)
+        } else {
+            openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
+        }
+    }
+
+    private fun openAuthWebFragment() {
+        activity.addFragmentToBackstack(views.loginFragmentContainer,
+                FtueAuthWebFragment::class.java,
+                option = commonOption)
+    }
+
     /**
      * Handle the SSO redirection here
      */
@@ -296,32 +343,6 @@ class FtueAuthVariant(
                 ?.let { onboardingViewModel.handle(OnboardingAction.LoginWithToken(it)) }
     }
 
-    private fun onRegistrationStageNotSupported() {
-        MaterialAlertDialogBuilder(activity)
-                .setTitle(R.string.app_name)
-                .setMessage(activity.getString(R.string.login_registration_not_supported))
-                .setPositiveButton(R.string.yes) { _, _ ->
-                    activity.addFragmentToBackstack(views.loginFragmentContainer,
-                            FtueAuthWebFragment::class.java,
-                            option = commonOption)
-                }
-                .setNegativeButton(R.string.no, null)
-                .show()
-    }
-
-    private fun onLoginModeNotSupported(supportedTypes: List<String>) {
-        MaterialAlertDialogBuilder(activity)
-                .setTitle(R.string.app_name)
-                .setMessage(activity.getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" }))
-                .setPositiveButton(R.string.yes) { _, _ ->
-                    activity.addFragmentToBackstack(views.loginFragmentContainer,
-                            FtueAuthWebFragment::class.java,
-                            option = commonOption)
-                }
-                .setNegativeButton(R.string.no, null)
-                .show()
-    }
-
     private fun handleRegistrationNavigation(flowResult: FlowResult) {
         // Complete all mandatory stages first
         val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory }
diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorDataStore.kt b/vector/src/main/java/im/vector/app/features/settings/VectorDataStore.kt
index 6a5ef0ac99..a7981a8b2a 100644
--- a/vector/src/main/java/im/vector/app/features/settings/VectorDataStore.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/VectorDataStore.kt
@@ -59,4 +59,16 @@ class VectorDataStore @Inject constructor(
             settings[forceDialPadDisplay] = force
         }
     }
+
+    private val forceLoginFallback = booleanPreferencesKey("force_login_fallback")
+
+    val forceLoginFallbackFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
+        preferences[forceLoginFallback].orFalse()
+    }
+
+    suspend fun setForceLoginFallbackFlow(force: Boolean) {
+        context.dataStore.edit { settings ->
+            settings[forceLoginFallback] = force
+        }
+    }
 }
diff --git a/vector/src/main/res/layout/merge_image_attachment_overlay.xml b/vector/src/main/res/layout/merge_image_attachment_overlay.xml
index d8e2142f87..1a5c6d8bf4 100644
--- a/vector/src/main/res/layout/merge_image_attachment_overlay.xml
+++ b/vector/src/main/res/layout/merge_image_attachment_overlay.xml
@@ -67,6 +67,23 @@
         app:layout_constraintTop_toBottomOf="@id/overlayCounterText"
         tools:text="Bill 29 Jun at 19:42" />
 
+    <ImageView
+        android:id="@+id/overlayDownloadButton"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
+        android:background="?attr/selectableItemBackgroundBorderless"
+        android:clickable="true"
+        android:contentDescription="@string/action_download"
+        android:focusable="true"
+        android:padding="6dp"
+        app:layout_constraintBottom_toBottomOf="@id/overlayTopBackground"
+        app:layout_constraintEnd_toStartOf="@id/overlayShareButton"
+        app:layout_constraintTop_toTopOf="@id/overlayTopBackground"
+        app:srcCompat="@drawable/ic_material_save"
+        app:tint="?colorOnPrimary"
+        tools:ignore="MissingPrefix" />
+
     <ImageView
         android:id="@+id/overlayShareButton"
         android:layout_width="wrap_content"
diff --git a/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt
new file mode 100644
index 0000000000..2fa8c7d5f7
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.media.domain.usecase
+
+import android.content.Context
+import android.net.Uri
+import androidx.core.net.toUri
+import com.airbnb.mvrx.test.MvRxTestRule
+import im.vector.app.core.intent.getMimeTypeFromUri
+import im.vector.app.core.utils.saveMedia
+import im.vector.app.features.notifications.NotificationUtils
+import im.vector.app.test.fakes.FakeFile
+import im.vector.app.test.fakes.FakeSession
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.OverrideMockKs
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import io.mockk.verifyAll
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class DownloadMediaUseCaseTest {
+
+    @get:Rule
+    val mvRxTestRule = MvRxTestRule()
+
+    @MockK
+    lateinit var appContext: Context
+
+    private val session = FakeSession()
+
+    @MockK
+    lateinit var notificationUtils: NotificationUtils
+
+    private val file = FakeFile()
+
+    @OverrideMockKs
+    lateinit var downloadMediaUseCase: DownloadMediaUseCase
+
+    @Before
+    fun setUp() {
+        MockKAnnotations.init(this)
+        mockkStatic("im.vector.app.core.utils.ExternalApplicationsUtilKt")
+        mockkStatic("im.vector.app.core.intent.VectorMimeTypeKt")
+    }
+
+    @After
+    fun tearDown() {
+        unmockkStatic("im.vector.app.core.utils.ExternalApplicationsUtilKt")
+        unmockkStatic("im.vector.app.core.intent.VectorMimeTypeKt")
+        file.tearDown()
+    }
+
+    @Test
+    fun `given a file when calling execute then save the file in local with success`() = runBlockingTest {
+        // Given
+        val uri = mockk<Uri>()
+        val mimeType = "mimeType"
+        val name = "filename"
+        every { getMimeTypeFromUri(appContext, uri) } returns mimeType
+        file.givenName(name)
+        file.givenUri(uri)
+        coEvery { saveMedia(any(), any(), any(), any(), any()) } just runs
+
+        // When
+        val result = downloadMediaUseCase.execute(file.instance)
+
+        // Then
+        assert(result.isSuccess)
+        verifyAll {
+            file.instance.name
+            file.instance.toUri()
+        }
+        verify {
+            getMimeTypeFromUri(appContext, uri)
+        }
+        coVerify {
+            saveMedia(appContext, file.instance, name, mimeType, notificationUtils)
+        }
+    }
+
+    @Test
+    fun `given a file when calling execute then save the file in local with error`() = runBlockingTest {
+        // Given
+        val uri = mockk<Uri>()
+        val mimeType = "mimeType"
+        val name = "filename"
+        val error = Throwable()
+        file.givenName(name)
+        file.givenUri(uri)
+        every { getMimeTypeFromUri(appContext, uri) } returns mimeType
+        coEvery { saveMedia(any(), any(), any(), any(), any()) } throws error
+
+        // When
+        val result = downloadMediaUseCase.execute(file.instance)
+
+        // Then
+        assert(result.isFailure && result.exceptionOrNull() == error)
+        verifyAll {
+            file.instance.name
+            file.instance.toUri()
+        }
+        verify {
+            getMimeTypeFromUri(appContext, uri)
+        }
+        coVerify {
+            saveMedia(appContext, file.instance, name, mimeType, notificationUtils)
+        }
+    }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt
new file mode 100644
index 0000000000..652d3f93fd
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.test.fakes
+
+import android.net.Uri
+import androidx.core.net.toUri
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import java.io.File
+
+class FakeFile {
+
+    val instance = mockk<File>()
+
+    init {
+        mockkStatic(Uri::class)
+    }
+
+    /**
+     * To be called after tests.
+     */
+    fun tearDown() {
+        unmockkStatic(Uri::class)
+    }
+
+    fun givenName(name: String) {
+        every { instance.name } returns name
+    }
+
+    fun givenUri(uri: Uri) {
+        every { instance.toUri() } returns uri
+    }
+}