diff --git a/vector/src/main/java/im/vector/app/core/utils/CopyToClipboardUseCase.kt b/vector/src/main/java/im/vector/app/core/utils/CopyToClipboardUseCase.kt new file mode 100644 index 0000000000..19ad9e2bba --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/CopyToClipboardUseCase.kt @@ -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() + ?.setPrimaryClip(ClipData.newPlainText("", text)) + } +} diff --git a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt index 6cfe8acc16..cde4fe2a35 100644 --- a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt @@ -19,8 +19,6 @@ package im.vector.app.core.utils import android.annotation.TargetApi import android.app.Activity import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent 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 */ fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true, @StringRes toastMessage: Int = R.string.copied_to_clipboard) { - val clipboard = context.getSystemService()!! - clipboard.setPrimaryClip(ClipData.newPlainText("", text)) + CopyToClipboardUseCase(context).execute(text) if (showToast) { context.toast(toastMessage) } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsAction.kt index 5c3743add4..0fa524dab4 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsAction.kt @@ -18,4 +18,6 @@ package im.vector.app.features.settings.devices.v2.details import im.vector.app.core.platform.VectorViewModelAction -sealed class SessionDetailsAction : VectorViewModelAction +sealed class SessionDetailsAction : VectorViewModelAction { + data class CopyToClipboard(val content: String) : SessionDetailsAction() +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsContentItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsContentItem.kt index 0363e320cd..665ba5126c 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsContentItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsContentItem.kt @@ -24,7 +24,6 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel -import im.vector.app.core.extensions.copyOnLongClick @EpoxyModelClass abstract class SessionDetailsContentItem : VectorEpoxyModel(R.layout.item_session_details_content) { @@ -38,11 +37,15 @@ abstract class SessionDetailsContentItem : VectorEpoxyModel() { + var callback: Callback? = null + + interface Callback { + fun onItemLongClicked(content: String) + } + override fun buildModels(data: DeviceInfo?) { data?.let { info -> val hasSectionSession = hasSectionSession(data) @@ -64,6 +71,10 @@ class SessionDetailsController @Inject constructor( title(host.stringProvider.getString(titleResId)) description(value) hasDivider(hasDivider) + onLongClickListener(View.OnLongClickListener { + host.callback?.onItemLongClicked(value) + true + }) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsFragment.kt index bf0037b0e8..5d7717e5f7 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsFragment.kt @@ -31,6 +31,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.databinding.FragmentSessionDetailsBinding import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import javax.inject.Inject @@ -54,6 +55,7 @@ class SessionDetailsFragment : super.onViewCreated(view, savedInstanceState) initToolbar() initSessionDetails() + observeViewEvents() } private fun initToolbar() { @@ -63,15 +65,29 @@ class SessionDetailsFragment : } private fun initSessionDetails() { + sessionDetailsController.callback = object : SessionDetailsController.Callback { + override fun onItemLongClicked(content: String) { + viewModel.handle(SessionDetailsAction.CopyToClipboard(content)) + } + } 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() { cleanUpSessionDetails() super.onDestroyView() } private fun cleanUpSessionDetails() { + sessionDetailsController.callback = null views.sessionDetails.cleanup() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewEvent.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewEvent.kt new file mode 100644 index 0000000000..02b313319e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.details + +import im.vector.app.core.platform.VectorViewEvents + +sealed class SessionDetailsViewEvent : VectorViewEvents { + object ContentCopiedToClipboard : SessionDetailsViewEvent() +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModel.kt index 2064f8ffdd..c37858cc54 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModel.kt @@ -23,8 +23,8 @@ 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.EmptyViewEvents 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 kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -32,7 +32,8 @@ import kotlinx.coroutines.flow.onEach class SessionDetailsViewModel @AssistedInject constructor( @Assisted val initialState: SessionDetailsViewState, private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase, -) : VectorViewModel(initialState) { + private val copyToClipboardUseCase: CopyToClipboardUseCase, +) : VectorViewModel(initialState) { companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() @@ -52,6 +53,13 @@ class SessionDetailsViewModel @AssistedInject constructor( } 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) } } diff --git a/vector/src/test/java/im/vector/app/core/utils/CopyToClipboardUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/utils/CopyToClipboardUseCaseTest.kt new file mode 100644 index 0000000000..8de6991a2e --- /dev/null +++ b/vector/src/test/java/im/vector/app/core/utils/CopyToClipboardUseCaseTest.kt @@ -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() + every { ClipData.newPlainText(any(), any()) } returns clipData + + // When + copyToClipboardUseCase.execute(A_TEXT) + + // Then + clipboardManager.verifySetPrimaryClip(clipData) + verify { ClipData.newPlainText("", A_TEXT) } + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModelTest.kt index 8444378193..df0613e06b 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsViewModelTest.kt @@ -18,12 +18,15 @@ package im.vector.app.features.settings.devices.v2.details import com.airbnb.mvrx.Success 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.overview.GetDeviceFullInfoUseCase import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.flowOf import org.junit.Rule @@ -31,6 +34,7 @@ import org.junit.Test import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo private const val A_SESSION_ID = "session-id" +private const val A_TEXT = "text" class SessionDetailsViewModelTest { @@ -41,10 +45,12 @@ class SessionDetailsViewModelTest { deviceId = A_SESSION_ID ) private val getDeviceFullInfoUseCase = mockk() + private val copyToClipboardUseCase = mockk() private fun createViewModel() = SessionDetailsViewModel( initialState = SessionDetailsViewState(args), - getDeviceFullInfoUseCase = getDeviceFullInfoUseCase + getDeviceFullInfoUseCase = getDeviceFullInfoUseCase, + copyToClipboardUseCase = copyToClipboardUseCase, ) @Test @@ -68,4 +74,26 @@ class SessionDetailsViewModelTest { .finish() 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() + val deviceInfo = mockk() + 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) } + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeClipboardManager.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeClipboardManager.kt new file mode 100644 index 0000000000..983902b496 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeClipboardManager.kt @@ -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() + + fun givenSetPrimaryClip() { + every { instance.setPrimaryClip(any()) } just runs + } + + fun verifySetPrimaryClip(clipData: ClipData) { + verify { instance.setPrimaryClip(clipData) } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt index d74ebcb678..9a94313fec 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt @@ -16,6 +16,7 @@ package im.vector.app.test.fakes +import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -74,4 +75,10 @@ class FakeContext( fun givenStartActivity(intent: Intent) { every { instance.startActivity(intent) } just runs } + + fun givenClipboardManager(): FakeClipboardManager { + val fakeClipboardManager = FakeClipboardManager() + givenService(Context.CLIPBOARD_SERVICE, ClipboardManager::class.java, fakeClipboardManager.instance) + return fakeClipboardManager + } }