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

View File

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

View File

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

View File

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

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
# 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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.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<*, *>
}

View File

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

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

View File

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

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

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.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

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
@PersistState
val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList()
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
) : MavericksState {
fun isLoading(): Boolean {

View File

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

View File

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

View File

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

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