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 + } +}