Merge branch 'develop' into michaelk/force_java_version

This commit is contained in:
Michael Kaye 2022-02-28 14:40:23 +00:00 committed by GitHub
commit bb57b6f9c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 706 additions and 267 deletions

View file

@ -20,6 +20,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: [ Gplay, Fdroid ] 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2 - uses: actions/cache@v2
@ -43,6 +47,7 @@ jobs:
name: Build unsigned GPlay APKs name: Build unsigned GPlay APKs
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
# Only runs on main, no concurrency.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2 - uses: actions/cache@v2

View file

@ -5,6 +5,7 @@ jobs:
validation: validation:
name: "Validation" name: "Validation"
runs-on: ubuntu-latest runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v1

View file

@ -20,6 +20,7 @@ jobs:
build-android-test-matrix-sdk: build-android-test-matrix-sdk:
name: Matrix SDK - Build Android Tests name: Matrix SDK - Build Android Tests
runs-on: macos-latest runs-on: macos-latest
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
@ -41,6 +42,7 @@ jobs:
build-android-test-app: build-android-test-app:
name: App - Build Android Tests name: App - Build Android Tests
runs-on: macos-latest runs-on: macos-latest
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
@ -58,7 +60,7 @@ jobs:
- name: Build Android Tests for vector - name: Build Android Tests for vector
run: ./gradlew clean vector:assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace run: ./gradlew clean vector:assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace
# Run Android Tests # Run Android Tests
integration-tests: integration-tests:
name: Matrix SDK - Running Integration Tests name: Matrix SDK - Running Integration Tests
runs-on: macos-latest runs-on: macos-latest
@ -66,6 +68,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
api-level: [ 28 ] api-level: [ 28 ]
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1 - 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 curl https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh -o start.sh
chmod 777 start.sh chmod 777 start.sh
./start.sh --no-rate-limit ./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 }}] - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.session] API[${{ matrix.api-level }}]
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
with: with:
@ -121,7 +124,7 @@ jobs:
if: always() if: always()
id: get-comment-body-account id: get-comment-body-account
run: python3 ./tools/ci/render_test_output.py account ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml 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 }}] - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.internal] API[${{ matrix.api-level }}]
if: always() if: always()
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -137,7 +140,7 @@ jobs:
if: always() if: always()
id: get-comment-body-internal id: get-comment-body-internal
run: python3 ./tools/ci/render_test_output.py internal ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml 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 }}] - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.ordering] API[${{ matrix.api-level }}]
if: always() if: always()
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -153,7 +156,7 @@ jobs:
if: always() if: always()
id: get-comment-body-ordering id: get-comment-body-ordering
run: python3 ./tools/ci/render_test_output.py ordering ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml 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 }}] - name: Run integration tests for Matrix SDK class [org.matrix.android.sdk.PermalinkParserTest] API[${{ matrix.api-level }}]
if: always() if: always()
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -169,7 +172,7 @@ jobs:
if: always() if: always()
id: get-comment-body-permalink id: get-comment-body-permalink
run: python3 ./tools/ci/render_test_output.py permalink ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml 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 - name: Find Comment
if: always() && github.event_name == 'pull_request' if: always() && github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1 uses: peter-evans/find-comment@v1
@ -201,6 +204,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
api-level: [ 28 ] api-level: [ 28 ]
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
@ -255,14 +259,15 @@ jobs:
notify: notify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- integration-tests - integration-tests
- ui-tests - ui-tests
if: always() && github.event_name != 'workflow_dispatch' if: always() && github.event_name != 'workflow_dispatch'
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: michaelkaye/matrix-hookshot-action@v0.2.0 - uses: michaelkaye/matrix-hookshot-action@v0.2.0
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }} matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }} 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}}" 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}}" 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}}"

View file

@ -18,6 +18,10 @@ jobs:
ktlint: ktlint:
name: Kotlin Linter name: Kotlin Linter
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Run ktlint - name: Run ktlint
@ -87,6 +91,10 @@ jobs:
android-lint: android-lint:
name: Android Linter name: Android Linter
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2 - uses: actions/cache@v2
@ -116,6 +124,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: [ Gplay, Fdroid ] 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2 - uses: actions/cache@v2

View file

@ -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}}"

View file

@ -9,6 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip in forks # Skip in forks
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python 3.8 - name: Set up Python 3.8
@ -35,6 +36,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip in forks # Skip in forks
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python 3.8 - name: Set up Python 3.8
@ -60,6 +62,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip in forks # Skip in forks
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Run analytics import script - name: Run analytics import script

View file

@ -15,6 +15,10 @@ jobs:
unit-tests: unit-tests:
name: Run Unit Tests name: Run Unit Tests
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/cache@v2 - uses: actions/cache@v2

1
changelog.d/5005.feature Normal file
View file

@ -0,0 +1 @@
Add possibility to save media from Gallery + reorder choices in message context menu

1
changelog.d/5325.feature Normal file
View file

@ -0,0 +1 @@
Adds forceLoginFallback feature flag and usages to FTUE login and registration

View file

@ -45,6 +45,8 @@ import kotlin.math.abs
abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener { abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener {
protected val rootView: View
get() = views.rootContainer
protected val pager2: ViewPager2 protected val pager2: ViewPager2
get() = views.attachmentPager get() = views.attachmentPager
protected val imageTransitionView: ImageView protected val imageTransitionView: ImageView
@ -298,10 +300,11 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
private fun createSwipeToDismissHandler(): SwipeToDismissHandler = private fun createSwipeToDismissHandler(): SwipeToDismissHandler =
SwipeToDismissHandler( SwipeToDismissHandler(
swipeView = views.dismissContainer, swipeView = views.dismissContainer,
shouldAnimateDismiss = { shouldAnimateDismiss() }, shouldAnimateDismiss = { shouldAnimateDismiss() },
onDismiss = { animateClose() }, onDismiss = { animateClose() },
onSwipeViewMove = ::handleSwipeViewMove) onSwipeViewMove = ::handleSwipeViewMove
)
private fun createSwipeDirectionDetector() = private fun createSwipeDirectionDetector() =
SwipeDirectionDetector(this) { swipeDirection = it } SwipeDirectionDetector(this) { swipeDirection = it }

View file

@ -53,7 +53,7 @@ class DebugFeaturesStateFactory @Inject constructor(
label = "FTUE Personalize profile", label = "FTUE Personalize profile",
key = DebugFeatureKeys.onboardingPersonalize, key = DebugFeatureKeys.onboardingPersonalize,
factory = VectorFeatures::isOnboardingPersonalizeEnabled factory = VectorFeatures::isOnboardingPersonalizeEnabled
) ),
)) ))
} }

View file

@ -43,9 +43,13 @@ class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSett
views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked -> views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked ->
viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked)) viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked))
} }
views.forceLoginFallback.setOnCheckedChangeListener { _, isChecked ->
viewModel.handle(DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled(isChecked))
}
} }
override fun invalidate() = withState(viewModel) { override fun invalidate() = withState(viewModel) {
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
views.forceLoginFallback.isChecked = it.forceLoginFallback
} }
} }

View file

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction { sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions() data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
} }

View file

@ -45,15 +45,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
private fun observeVectorDataStore() { private fun observeVectorDataStore() {
vectorDataStore.forceDialPadDisplayFlow.setOnEach { vectorDataStore.forceDialPadDisplayFlow.setOnEach {
copy( copy(dialPadVisible = it)
dialPadVisible = it }
)
vectorDataStore.forceLoginFallbackFlow.setOnEach {
copy(forceLoginFallback = it)
} }
} }
override fun handle(action: DebugPrivateSettingsViewActions) { override fun handle(action: DebugPrivateSettingsViewActions) {
when (action) { 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) vectorDataStore.setForceDialPadDisplay(action.force)
} }
} }
private fun handleSetForceLoginFallbackEnabled(action: DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled) {
viewModelScope.launch {
vectorDataStore.setForceLoginFallbackFlow(action.force)
}
}
} }

View file

@ -19,5 +19,6 @@ package im.vector.app.features.debug.settings
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
data class DebugPrivateSettingsViewState( data class DebugPrivateSettingsViewState(
val dialPadVisible: Boolean = false val dialPadVisible: Boolean = false,
val forceLoginFallback: Boolean = false,
) : MavericksState ) : MavericksState

View file

@ -25,6 +25,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Force DialPad tab display" /> 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> </LinearLayout>
</ScrollView> </ScrollView>

View file

@ -58,6 +58,7 @@ import im.vector.app.features.login.LoginViewModel
import im.vector.app.features.login2.LoginViewModel2 import im.vector.app.features.login2.LoginViewModel2
import im.vector.app.features.login2.created.AccountCreatedViewModel import im.vector.app.features.login2.created.AccountCreatedViewModel
import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel 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.onboarding.OnboardingViewModel
import im.vector.app.features.poll.create.CreatePollViewModel import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.qrcode.QrCodeScannerViewModel import im.vector.app.features.qrcode.QrCodeScannerViewModel
@ -594,4 +595,9 @@ interface MavericksViewModelModule {
@IntoMap @IntoMap
@MavericksViewModelKey(LocationSharingViewModel::class) @MavericksViewModelKey(LocationSharingViewModel::class)
fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(VectorAttachmentViewerViewModel::class)
fun vectorAttachmentViewerViewModelFactory(factory: VectorAttachmentViewerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
} }

View file

@ -343,24 +343,6 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType())) 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)) { if (canCopy(msgType)) {
// TODO copy images? html? see ClipBoard // TODO copy images? html? see ClipBoard
add(EventSharedAction.Copy(messageContent!!.body)) add(EventSharedAction.Copy(messageContent!!.body))
@ -382,12 +364,30 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.ViewEditHistory(informationData)) add(EventSharedAction.ViewEditHistory(informationData))
} }
if (canSave(msgType) && messageContent is MessageWithAttachmentContent) {
add(EventSharedAction.Save(timelineEvent.eventId, messageContent))
}
if (canShare(msgType)) { if (canShare(msgType)) {
add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!))
} }
if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { if (canRedact(timelineEvent, actionPermissions)) {
add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) 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
))
}
} }
} }

View file

@ -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)
}

View file

@ -30,35 +30,33 @@ class AttachmentOverlayView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener { ) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener {
var onShareCallback: (() -> Unit)? = null var interactionListener: AttachmentInteractionListener? = null
var onBack: (() -> Unit)? = null
var onPlayPause: ((play: Boolean) -> Unit)? = null
var videoSeekTo: ((progress: Int) -> Unit)? = null
val views: MergeImageAttachmentOverlayBinding val views: MergeImageAttachmentOverlayBinding
var isPlaying = false private var isPlaying = false
private var suspendSeekBarUpdate = false
var suspendSeekBarUpdate = false
init { init {
inflate(context, R.layout.merge_image_attachment_overlay, this) inflate(context, R.layout.merge_image_attachment_overlay, this)
views = MergeImageAttachmentOverlayBinding.bind(this) views = MergeImageAttachmentOverlayBinding.bind(this)
setBackgroundColor(Color.TRANSPARENT) setBackgroundColor(Color.TRANSPARENT)
views.overlayBackButton.setOnClickListener { views.overlayBackButton.setOnClickListener {
onBack?.invoke() interactionListener?.onDismiss()
} }
views.overlayShareButton.setOnClickListener { views.overlayShareButton.setOnClickListener {
onShareCallback?.invoke() interactionListener?.onShare()
}
views.overlayDownloadButton.setOnClickListener {
interactionListener?.onDownload()
} }
views.overlayPlayPauseButton.setOnClickListener { views.overlayPlayPauseButton.setOnClickListener {
onPlayPause?.invoke(!isPlaying) interactionListener?.onPlayPause(!isPlaying)
} }
views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) { if (fromUser) {
videoSeekTo?.invoke(progress) interactionListener?.videoSeekTo(progress)
} }
} }

View file

@ -49,14 +49,7 @@ abstract class BaseAttachmentProvider<Type>(
private val stringProvider: StringProvider private val stringProvider: StringProvider
) : AttachmentSourceProvider { ) : AttachmentSourceProvider {
interface InteractionListener { var interactionListener: AttachmentInteractionListener? = null
fun onDismissTapped()
fun onShareTapped()
fun onPlayPause(play: Boolean)
fun videoSeekTo(percent: Int)
}
var interactionListener: InteractionListener? = null
private var overlayView: AttachmentOverlayView? = null private var overlayView: AttachmentOverlayView? = null
@ -68,18 +61,7 @@ abstract class BaseAttachmentProvider<Type>(
if (position == -1) return null if (position == -1) return null
if (overlayView == null) { if (overlayView == null) {
overlayView = AttachmentOverlayView(context) overlayView = AttachmentOverlayView(context)
overlayView?.onBack = { overlayView?.interactionListener = interactionListener
interactionListener?.onDismissTapped()
}
overlayView?.onShareCallback = {
interactionListener?.onShareTapped()
}
overlayView?.onPlayPause = { play ->
interactionListener?.onPlayPause(play)
}
overlayView?.videoSeekTo = { percent ->
interactionListener?.videoSeekTo(percent)
}
} }
val timelineEvent = getTimelineEventAtPosition(position) val timelineEvent = getTimelineEventAtPosition(position)

View file

@ -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()
}

View file

@ -17,6 +17,7 @@ package im.vector.app.features.media
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.View import android.view.View
@ -30,16 +31,25 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.Transition import androidx.transition.Transition
import com.airbnb.mvrx.viewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder 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.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.core.utils.shareMedia
import im.vector.app.features.themes.ActivityOtherThemes import im.vector.app.features.themes.ActivityOtherThemes
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.lib.attachmentviewer.AttachmentCommands import im.vector.lib.attachmentviewer.AttachmentCommands
import im.vector.lib.attachmentviewer.AttachmentViewerActivity import im.vector.lib.attachmentviewer.AttachmentViewerActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -47,7 +57,7 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener { class VectorAttachmentViewerActivity : AttachmentViewerActivity(), AttachmentInteractionListener {
@Parcelize @Parcelize
data class Args( data class Args(
@ -58,15 +68,28 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
@Inject @Inject
lateinit var sessionHolder: ActiveSessionHolder lateinit var sessionHolder: ActiveSessionHolder
@Inject @Inject
lateinit var dataSourceFactory: AttachmentProviderFactory lateinit var dataSourceFactory: AttachmentProviderFactory
@Inject @Inject
lateinit var imageContentRenderer: ImageContentRenderer 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 initialIndex = 0
private var isAnimatingOut = false private var isAnimatingOut = false
private var currentSourceProvider: BaseAttachmentProvider<*>? = null 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -128,6 +151,8 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha) window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha)
window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha) window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha)
observeViewEvents()
} }
override fun onResume() { override fun onResume() {
@ -140,12 +165,6 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
Timber.i("onPause Activity ${javaClass.simpleName}") Timber.i("onPause Activity ${javaClass.simpleName}")
} }
private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
override fun shouldAnimateDismiss(): Boolean {
return currentPosition != initialIndex
}
override fun onBackPressed() { override fun onBackPressed() {
if (currentPosition == initialIndex) { if (currentPosition == initialIndex) {
// show back the transition view // show back the transition view
@ -156,6 +175,10 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
super.onBackPressed() super.onBackPressed()
} }
override fun shouldAnimateDismiss(): Boolean {
return currentPosition != initialIndex
}
override fun animateClose() { override fun animateClose() {
if (currentPosition == initialIndex) { if (currentPosition == initialIndex) {
// show back the transition view // show back the transition view
@ -166,9 +189,7 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
ActivityCompat.finishAfterTransition(this) ActivityCompat.finishAfterTransition(this)
} }
// ========================================================================================== private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
// PRIVATE METHODS
// ==========================================================================================
/** /**
* Try and add a [Transition.TransitionListener] to the entering shared element * 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 { companion object {
const val EXTRA_ARGS = "EXTRA_ARGS" private const val EXTRA_ARGS = "EXTRA_ARGS"
const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA" private const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA"
const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA" private const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA"
fun newIntent(context: Context, fun newIntent(context: Context,
mediaData: AttachmentData, 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())
)
}
}
}
} }

View file

@ -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()
}

View file

@ -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)) }
}
}
}

View file

@ -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
)
}
}
}

View file

@ -46,6 +46,7 @@ import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.ServerType import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode import im.vector.app.features.login.SignMode
import im.vector.app.features.settings.VectorDataStore
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain import org.matrix.android.sdk.api.MatrixPatterns.getDomain
@ -78,7 +79,8 @@ class OnboardingViewModel @AssistedInject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val homeServerHistoryService: HomeServerHistoryService, private val homeServerHistoryService: HomeServerHistoryService,
private val vectorFeatures: VectorFeatures, private val vectorFeatures: VectorFeatures,
private val analyticsTracker: AnalyticsTracker private val analyticsTracker: AnalyticsTracker,
private val vectorDataStore: VectorDataStore,
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) { ) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
@ -90,6 +92,7 @@ class OnboardingViewModel @AssistedInject constructor(
init { init {
getKnownCustomHomeServersUrls() getKnownCustomHomeServersUrls()
observeDataStore()
} }
private fun getKnownCustomHomeServersUrls() { 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 // Store the last action, to redo it after user has trusted the untrusted certificate
private var lastAction: OnboardingAction? = null private var lastAction: OnboardingAction? = null
private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null

View file

@ -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 // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
@PersistState @PersistState
val loginModeSupportedTypes: List<String> = emptyList(), val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList() val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
) : MavericksState { ) : MavericksState {
fun isLoading(): Boolean { fun isLoading(): Boolean {

View file

@ -75,6 +75,8 @@ class FtueAuthVariant(
private val popEnterAnim = R.anim.no_anim private val popEnterAnim = R.anim.no_anim
private val popExitAnim = R.anim.exit_fade_out private val popExitAnim = R.anim.exit_fade_out
private var isForceLoginFallbackEnabled = false
private val topFragment: Fragment? private val topFragment: Fragment?
get() = supportFragmentManager.findFragmentById(views.loginFragmentContainer.id) get() = supportFragmentManager.findFragmentById(views.loginFragmentContainer.id)
@ -109,10 +111,6 @@ class FtueAuthVariant(
} }
} }
override fun setIsLoading(isLoading: Boolean) {
// do nothing
}
private fun addFirstFragment() { private fun addFirstFragment() {
val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) { val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) {
true -> FtueAuthSplashCarouselFragment::class.java true -> FtueAuthSplashCarouselFragment::class.java
@ -121,11 +119,25 @@ class FtueAuthVariant(
activity.addFragment(views.loginFragmentContainer, splashFragment) 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) { private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
when (viewEvents) { when (viewEvents) {
is OnboardingViewEvents.RegistrationFlowResult -> { is OnboardingViewEvents.RegistrationFlowResult -> {
// Check that all flows are supported by the application if (registrationShouldFallback(viewEvents)) {
if (viewEvents.flowResult.missingStages.any { !it.isSupported() }) {
// Display a popup to propose use web fallback // Display a popup to propose use web fallback
onRegistrationStageNotSupported() onRegistrationStageNotSupported()
} else { } else {
@ -136,11 +148,7 @@ class FtueAuthVariant(
// First ask for login and password // First ask for login and password
// I add a tag to indicate that this fragment is a registration stage. // 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 // This way it will be automatically popped in when starting the next registration stage
activity.addFragmentToBackstack(views.loginFragmentContainer, openAuthLoginFragmentWithTag(FRAGMENT_REGISTRATION_STAGE_TAG)
FtueAuthLoginFragment::class.java,
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
option = commonOption
)
} }
} }
} }
@ -228,13 +236,23 @@ class FtueAuthVariant(
}.exhaustive }.exhaustive
} }
private fun updateWithState(viewState: OnboardingViewState) { private fun registrationShouldFallback(registrationFlowResult: OnboardingViewEvents.RegistrationFlowResult) =
views.loginLoading.isVisible = if (vectorFeatures.isOnboardingPersonalizeEnabled()) { isForceLoginFallbackEnabled || registrationFlowResult.containsUnsupportedRegistrationFlow()
viewState.isLoading()
} else { private fun OnboardingViewEvents.RegistrationFlowResult.containsUnsupportedRegistrationFlow() =
// Keep loading when during success because of the delay when switching to the next Activity flowResult.missingStages.any { !it.isSupported() }
viewState.isLoading() || viewState.isAuthTaskCompleted()
} 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) { 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 // state.signMode could not be ready yet. So use value from the ViewEvent
when (OnboardingViewEvents.signMode) { when (OnboardingViewEvents.signMode) {
SignMode.Unknown -> error("Sign mode has to be set before calling this method") SignMode.Unknown -> error("Sign mode has to be set before calling this method")
SignMode.SignUp -> { SignMode.SignUp -> Unit // This case is processed in handleOnboardingViewEvents
// This is managed by the OnboardingViewEvents SignMode.SignIn -> handleSignInSelected(state)
} SignMode.SignInWithMatrixId -> handleSignInWithMatrixId(state)
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)
}.exhaustive }.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 * Handle the SSO redirection here
*/ */
@ -296,32 +343,6 @@ class FtueAuthVariant(
?.let { onboardingViewModel.handle(OnboardingAction.LoginWithToken(it)) } ?.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) { private fun handleRegistrationNavigation(flowResult: FlowResult) {
// Complete all mandatory stages first // Complete all mandatory stages first
val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory } val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory }

View file

@ -59,4 +59,16 @@ class VectorDataStore @Inject constructor(
settings[forceDialPadDisplay] = force 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
}
}
} }

View file

@ -67,6 +67,23 @@
app:layout_constraintTop_toBottomOf="@id/overlayCounterText" app:layout_constraintTop_toBottomOf="@id/overlayCounterText"
tools:text="Bill 29 Jun at 19:42" /> 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 <ImageView
android:id="@+id/overlayShareButton" android:id="@+id/overlayShareButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

@ -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)
}
}
}

View file

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