Long press on the whole content item

This commit is contained in:
Maxime NATUREL 2022-09-09 17:06:32 +02:00
parent 6cd0fbb614
commit 279820224c
12 changed files with 242 additions and 11 deletions

View File

@ -0,0 +1,34 @@
/*
* 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.core.utils
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class CopyToClipboardUseCase @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun execute(text: CharSequence) {
context.getSystemService<ClipboardManager>()
?.setPrimaryClip(ClipData.newPlainText("", text))
}
}

View File

@ -19,8 +19,6 @@ package im.vector.app.core.utils
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -100,8 +98,7 @@ fun requestDisablingBatteryOptimization(activity: Activity, activityResultLaunch
* @param toastMessage content of the toast message as a String resource * @param toastMessage content of the toast message as a String resource
*/ */
fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true, @StringRes toastMessage: Int = R.string.copied_to_clipboard) { fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true, @StringRes toastMessage: Int = R.string.copied_to_clipboard) {
val clipboard = context.getSystemService<ClipboardManager>()!! CopyToClipboardUseCase(context).execute(text)
clipboard.setPrimaryClip(ClipData.newPlainText("", text))
if (showToast) { if (showToast) {
context.toast(toastMessage) context.toast(toastMessage)
} }

View File

@ -18,4 +18,6 @@ package im.vector.app.features.settings.devices.v2.details
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
sealed class SessionDetailsAction : VectorViewModelAction sealed class SessionDetailsAction : VectorViewModelAction {
data class CopyToClipboard(val content: String) : SessionDetailsAction()
}

View File

@ -24,7 +24,6 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.extensions.copyOnLongClick
@EpoxyModelClass @EpoxyModelClass
abstract class SessionDetailsContentItem : VectorEpoxyModel<SessionDetailsContentItem.Holder>(R.layout.item_session_details_content) { abstract class SessionDetailsContentItem : VectorEpoxyModel<SessionDetailsContentItem.Holder>(R.layout.item_session_details_content) {
@ -38,11 +37,15 @@ abstract class SessionDetailsContentItem : VectorEpoxyModel<SessionDetailsConten
@EpoxyAttribute @EpoxyAttribute
var hasDivider: Boolean = true var hasDivider: Boolean = true
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onLongClickListener: View.OnLongClickListener? = null
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.sessionDetailsContentTitle.text = title holder.sessionDetailsContentTitle.text = title
holder.sessionDetailsContentDescription.text = description holder.sessionDetailsContentDescription.text = description
holder.sessionDetailsContentDescription.copyOnLongClick() holder.view.isClickable = onLongClickListener != null
holder.view.setOnLongClickListener(onLongClickListener)
holder.sessionDetailsContentDivider.isVisible = hasDivider holder.sessionDetailsContentDivider.isVisible = hasDivider
} }

View File

@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.details package im.vector.app.features.settings.devices.v2.details
import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R import im.vector.app.R
@ -34,6 +35,12 @@ class SessionDetailsController @Inject constructor(
private val dimensionConverter: DimensionConverter, private val dimensionConverter: DimensionConverter,
) : TypedEpoxyController<DeviceInfo>() { ) : TypedEpoxyController<DeviceInfo>() {
var callback: Callback? = null
interface Callback {
fun onItemLongClicked(content: String)
}
override fun buildModels(data: DeviceInfo?) { override fun buildModels(data: DeviceInfo?) {
data?.let { info -> data?.let { info ->
val hasSectionSession = hasSectionSession(data) val hasSectionSession = hasSectionSession(data)
@ -64,6 +71,10 @@ class SessionDetailsController @Inject constructor(
title(host.stringProvider.getString(titleResId)) title(host.stringProvider.getString(titleResId))
description(value) description(value)
hasDivider(hasDivider) hasDivider(hasDivider)
onLongClickListener(View.OnLongClickListener {
host.callback?.onItemLongClicked(value)
true
})
} }
} }

View File

@ -31,6 +31,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.showOptimizedSnackbar
import im.vector.app.databinding.FragmentSessionDetailsBinding import im.vector.app.databinding.FragmentSessionDetailsBinding
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import javax.inject.Inject import javax.inject.Inject
@ -54,6 +55,7 @@ class SessionDetailsFragment :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
initToolbar() initToolbar()
initSessionDetails() initSessionDetails()
observeViewEvents()
} }
private fun initToolbar() { private fun initToolbar() {
@ -63,15 +65,29 @@ class SessionDetailsFragment :
} }
private fun initSessionDetails() { private fun initSessionDetails() {
sessionDetailsController.callback = object : SessionDetailsController.Callback {
override fun onItemLongClicked(content: String) {
viewModel.handle(SessionDetailsAction.CopyToClipboard(content))
}
}
views.sessionDetails.configureWith(sessionDetailsController) views.sessionDetails.configureWith(sessionDetailsController)
} }
private fun observeViewEvents() {
viewModel.observeViewEvents { viewEvent ->
when (viewEvent) {
SessionDetailsViewEvent.ContentCopiedToClipboard -> view?.showOptimizedSnackbar(getString(R.string.copied_to_clipboard))
}
}
}
override fun onDestroyView() { override fun onDestroyView() {
cleanUpSessionDetails() cleanUpSessionDetails()
super.onDestroyView() super.onDestroyView()
} }
private fun cleanUpSessionDetails() { private fun cleanUpSessionDetails() {
sessionDetailsController.callback = null
views.sessionDetails.cleanup() views.sessionDetails.cleanup()
} }

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.settings.devices.v2.details
import im.vector.app.core.platform.VectorViewEvents
sealed class SessionDetailsViewEvent : VectorViewEvents {
object ContentCopiedToClipboard : SessionDetailsViewEvent()
}

View File

@ -23,8 +23,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.utils.CopyToClipboardUseCase
import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -32,7 +32,8 @@ import kotlinx.coroutines.flow.onEach
class SessionDetailsViewModel @AssistedInject constructor( class SessionDetailsViewModel @AssistedInject constructor(
@Assisted val initialState: SessionDetailsViewState, @Assisted val initialState: SessionDetailsViewState,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase, private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
) : VectorViewModel<SessionDetailsViewState, SessionDetailsAction, EmptyViewEvents>(initialState) { private val copyToClipboardUseCase: CopyToClipboardUseCase,
) : VectorViewModel<SessionDetailsViewState, SessionDetailsAction, SessionDetailsViewEvent>(initialState) {
companion object : MavericksViewModelFactory<SessionDetailsViewModel, SessionDetailsViewState> by hiltMavericksViewModelFactory() companion object : MavericksViewModelFactory<SessionDetailsViewModel, SessionDetailsViewState> by hiltMavericksViewModelFactory()
@ -52,6 +53,13 @@ class SessionDetailsViewModel @AssistedInject constructor(
} }
override fun handle(action: SessionDetailsAction) { override fun handle(action: SessionDetailsAction) {
TODO("Implement when adding the first action") return when (action) {
is SessionDetailsAction.CopyToClipboard -> handleCopyToClipboard(action)
}
}
private fun handleCopyToClipboard(copyToClipboard: SessionDetailsAction.CopyToClipboard) {
copyToClipboardUseCase.execute(copyToClipboard.content)
_viewEvents.post(SessionDetailsViewEvent.ContentCopiedToClipboard)
} }
} }

View File

@ -0,0 +1,65 @@
/*
* 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.core.utils
import android.content.ClipData
import im.vector.app.test.fakes.FakeContext
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Test
private const val A_TEXT = "text"
class CopyToClipboardUseCaseTest {
private val fakeContext = FakeContext()
private val copyToClipboardUseCase = CopyToClipboardUseCase(
context = fakeContext.instance
)
@Before
fun setup() {
mockkStatic(ClipData::class)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a text when executing the use case then the text is copied into the clipboard`() {
// Given
val clipboardManager = fakeContext.givenClipboardManager()
clipboardManager.givenSetPrimaryClip()
val clipData = mockk<ClipData>()
every { ClipData.newPlainText(any(), any()) } returns clipData
// When
copyToClipboardUseCase.execute(A_TEXT)
// Then
clipboardManager.verifySetPrimaryClip(clipData)
verify { ClipData.newPlainText("", A_TEXT) }
}
}

View File

@ -18,12 +18,15 @@ package im.vector.app.features.settings.devices.v2.details
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MvRxTestRule import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.core.utils.CopyToClipboardUseCase
import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
@ -31,6 +34,7 @@ import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
private const val A_SESSION_ID = "session-id" private const val A_SESSION_ID = "session-id"
private const val A_TEXT = "text"
class SessionDetailsViewModelTest { class SessionDetailsViewModelTest {
@ -41,10 +45,12 @@ class SessionDetailsViewModelTest {
deviceId = A_SESSION_ID deviceId = A_SESSION_ID
) )
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>() private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>()
private val copyToClipboardUseCase = mockk<CopyToClipboardUseCase>()
private fun createViewModel() = SessionDetailsViewModel( private fun createViewModel() = SessionDetailsViewModel(
initialState = SessionDetailsViewState(args), initialState = SessionDetailsViewState(args),
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
copyToClipboardUseCase = copyToClipboardUseCase,
) )
@Test @Test
@ -68,4 +74,26 @@ class SessionDetailsViewModelTest {
.finish() .finish()
verify { getDeviceFullInfoUseCase.execute(A_SESSION_ID) } verify { getDeviceFullInfoUseCase.execute(A_SESSION_ID) }
} }
@Test
fun `given copyToClipboard action when viewModel handle it then related use case is executed and viewEvent is updated`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
val deviceInfo = mockk<DeviceInfo>()
every { deviceFullInfo.deviceInfo } returns deviceInfo
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID) } returns flowOf(deviceFullInfo)
val action = SessionDetailsAction.CopyToClipboard(A_TEXT)
every { copyToClipboardUseCase.execute(any()) } just runs
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest
.assertEvent { it is SessionDetailsViewEvent.ContentCopiedToClipboard }
.finish()
verify { copyToClipboardUseCase.execute(A_TEXT) }
}
} }

View File

@ -0,0 +1,37 @@
/*
* 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.content.ClipData
import android.content.ClipboardManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
class FakeClipboardManager {
val instance = mockk<ClipboardManager>()
fun givenSetPrimaryClip() {
every { instance.setPrimaryClip(any()) } just runs
}
fun verifySetPrimaryClip(clipData: ClipData) {
verify { instance.setPrimaryClip(clipData) }
}
}

View File

@ -16,6 +16,7 @@
package im.vector.app.test.fakes package im.vector.app.test.fakes
import android.content.ClipboardManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -74,4 +75,10 @@ class FakeContext(
fun givenStartActivity(intent: Intent) { fun givenStartActivity(intent: Intent) {
every { instance.startActivity(intent) } just runs every { instance.startActivity(intent) } just runs
} }
fun givenClipboardManager(): FakeClipboardManager {
val fakeClipboardManager = FakeClipboardManager()
givenService(Context.CLIPBOARD_SERVICE, ClipboardManager::class.java, fakeClipboardManager.instance)
return fakeClipboardManager
}
} }