From 4a39f126dd9c52a36901c125fa4ed4cf5bd3eb34 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 5 Jan 2024 23:10:06 -0600 Subject: [PATCH] Add support to copy a send url to the clipboard (#508) --- .../util/EnvironmentUrlDataJsonExtensions.kt | 33 +++++ .../ui/tools/feature/send/SendViewModel.kt | 25 +++- .../feature/send/util/SendDataExtensions.kt | 11 +- .../feature/send/util/SendViewExtensions.kt | 11 ++ .../EnvironmentUrlsDataJsonExtensionsTest.kt | 113 ++++++++++++++---- .../ui/tools/feature/send/SendScreenTest.kt | 2 + .../tools/feature/send/SendViewModelTest.kt | 32 +++-- .../send/util/SendDataExtensionsTest.kt | 26 +++- .../tools/feature/send/util/SendViewTest.kt | 17 +++ 9 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt index ad776bd61..cf2501516 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt @@ -4,6 +4,39 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso import com.x8bit.bitwarden.data.platform.repository.model.Environment import java.net.URI +private const val DEFAULT_WEB_VAULT_URL: String = "https://vault.bitwarden.com" +private const val DEFAULT_WEB_SEND_URL: String = "https://send.bitwarden.com/#" + +/** + * Returns the base web vault URL. This will check for a custom [EnvironmentUrlDataJson.webVault] + * before falling back to the [EnvironmentUrlDataJson.base]. This can still return null if both are + * null or blank. + */ +val EnvironmentUrlDataJson.baseWebVaultUrlOrNull: String? + get() = + this + .webVault + .takeIf { !it.isNullOrBlank() } + ?: base.takeIf { it.isNotBlank() } + +/** + * Returns the base web vault URL or the default value if one is not present. + * + * See [baseWebVaultUrlOrNull] for more details. + */ +val EnvironmentUrlDataJson.baseWebVaultUrlOrDefault: String + get() = this.baseWebVaultUrlOrNull ?: DEFAULT_WEB_VAULT_URL + +/** + * Returns the base web send URL or the default value if one is not present. + */ +val EnvironmentUrlDataJson.baseWebSendUrl: String + get() = + this + .baseWebVaultUrlOrNull + ?.let { "$it/#/send/" } + ?: DEFAULT_WEB_SEND_URL + /** * Returns the appropriate pre-defined labels for environments matching the known US/EU values. * Otherwise returns the host of the custom base URL. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index c530c97ea..2f057d80d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -6,7 +6,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -34,6 +36,7 @@ private const val KEY_STATE = "state" class SendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val clipboardManager: BitwardenClipboardManager, + private val environmentRepo: EnvironmentRepository, private val vaultRepo: VaultRepository, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. @@ -84,7 +87,14 @@ class SendViewModel @Inject constructor( is DataState.Loaded -> { mutableStateFlow.update { - it.copy(viewState = dataState.data.toViewState()) + it.copy( + viewState = dataState.data.toViewState( + baseWebSendUrl = environmentRepo + .environment + .environmentUrlData + .baseWebSendUrl, + ), + ) } } @@ -108,7 +118,14 @@ class SendViewModel @Inject constructor( is DataState.Pending -> { mutableStateFlow.update { - it.copy(viewState = dataState.data.toViewState()) + it.copy( + viewState = dataState.data.toViewState( + baseWebSendUrl = environmentRepo + .environment + .environmentUrlData + .baseWebSendUrl, + ), + ) } } } @@ -142,8 +159,7 @@ class SendViewModel @Inject constructor( } private fun handleCopyClick(action: SendAction.CopyClick) { - // TODO: Create a link and copy it to the clipboard BIT-?? - sendEvent(SendEvent.ShowToast("Not yet implemented".asText())) + clipboardManager.setText(text = action.sendItem.shareUrl) } private fun handleSendClick(action: SendAction.SendClick) { @@ -205,6 +221,7 @@ data class SendState( val deletionDate: String, val type: Type, val iconList: List, + val shareUrl: String, ) : Parcelable { /** * Indicates the type of send this, a text or file. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt index 83e8ca8ac..f64c4928d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensions.kt @@ -13,14 +13,18 @@ private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a" /** * Transforms [SendData] into [SendState.ViewState]. */ -fun SendData.toViewState(): SendState.ViewState = +fun SendData.toViewState( + baseWebSendUrl: String, +): SendState.ViewState = this .sendViewList .takeUnless { it.isEmpty() } - ?.toSendContent() + ?.toSendContent(baseWebSendUrl) ?: SendState.ViewState.Empty -private fun List.toSendContent(): SendState.ViewState.Content { +private fun List.toSendContent( + baseWebSendUrl: String, +): SendState.ViewState.Content { return SendState.ViewState.Content( textTypeCount = this.count { it.type == SendType.TEXT }, fileTypeCount = this.count { it.type == SendType.FILE }, @@ -49,6 +53,7 @@ private fun List.toSendContent(): SendState.ViewState.Content { sendView.deletionDate.isBefore(Instant.now()) }, ), + shareUrl = sendView.toSendUrl(baseWebSendUrl), ) } .sortedBy { it.name }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewExtensions.kt new file mode 100644 index 000000000..ded8f48cc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewExtensions.kt @@ -0,0 +1,11 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.util + +import com.bitwarden.core.SendView + +/** + * Creates a sharable url from a [SendView]. + */ +fun SendView.toSendUrl( + baseWebSendUrl: String, + // TODO: The `key` being used here is not correct and should be updated (BIT-1386) +): String = "$baseWebSendUrl$accessId/$key" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt index e71c03af9..cb94b0a56 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt @@ -3,9 +3,86 @@ package com.x8bit.bitwarden.data.platform.repository.util import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.platform.repository.model.Environment import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test class EnvironmentUrlsDataJsonExtensionsTest { + @Test + fun `baseWebVaultUrlOrNull should return webVault when populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebVaultUrlOrNull + assertEquals("webVault", result) + } + + @Test + fun `baseWebVaultUrlOrNull should return base when webvault is not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebVaultUrlOrNull + assertEquals("base", result) + } + + @Test + fun `baseWebVaultUrlOrNull should return null when webvault and base are not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebVaultUrlOrNull + assertNull(result) + } + + @Test + fun `baseWebVaultUrlOrDefault should return webVault when populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebVaultUrlOrDefault + assertEquals("webVault", result) + } + + @Test + fun `baseWebVaultUrlOrDefault should return base when webvault is not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebVaultUrlOrDefault + assertEquals("base", result) + } + + @Suppress("MaxLineLength") + @Test + fun `baseWebVaultUrlOrDefault should return the default when webvault and base are not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebVaultUrlOrDefault + assertEquals("https://vault.bitwarden.com", result) + } + + @Test + fun `baseWebSendUrl should return the correct result when webVault when populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebSendUrl + assertEquals("webVault/#/send/", result) + } + + @Test + fun `baseWebSendUrl should return the correct result when webvault is not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy(webVault = null) + .baseWebSendUrl + assertEquals("base/#/send/", result) + } + + @Test + fun `baseWebSendUrl should return the default when webvault and base are not populated`() { + val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA + .copy( + webVault = null, + base = "", + ) + .baseWebSendUrl + assertEquals("https://send.bitwarden.com/#", result) + } + @Test fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() { val environment = EnvironmentUrlDataJson.DEFAULT_US @@ -52,20 +129,11 @@ class EnvironmentUrlsDataJsonExtensionsTest { @Test fun `toEnvironmentUrls should correctly convert custom urls to the expected type`() { - val environmentUrlData = EnvironmentUrlDataJson( - base = "base", - api = "api", - identity = "identity", - icon = "icon", - notifications = "notifications", - webVault = "webVault", - events = "events", - ) assertEquals( Environment.SelfHosted( - environmentUrlData = environmentUrlData, + environmentUrlData = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA, ), - environmentUrlData.toEnvironmentUrls(), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toEnvironmentUrls(), ) } @@ -87,20 +155,11 @@ class EnvironmentUrlsDataJsonExtensionsTest { @Test fun `toEnvironmentUrlsOrDefault should correctly convert custom urls to the expected type`() { - val environmentUrlData = EnvironmentUrlDataJson( - base = "base", - api = "api", - identity = "identity", - icon = "icon", - notifications = "notifications", - webVault = "webVault", - events = "events", - ) assertEquals( Environment.SelfHosted( - environmentUrlData = environmentUrlData, + environmentUrlData = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA, ), - environmentUrlData.toEnvironmentUrlsOrDefault(), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toEnvironmentUrlsOrDefault(), ) } @@ -112,3 +171,13 @@ class EnvironmentUrlsDataJsonExtensionsTest { ) } } + +private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson( + base = "base", + api = "api", + identity = "identity", + icon = "icon", + notifications = "notifications", + webVault = "webVault", + events = "events", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index c07b9b2b9..8e4ac8485 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -487,6 +487,7 @@ private val DEFAULT_SEND_ITEM: SendState.ViewState.Content.SendItem = deletionDate = "1", type = SendState.ViewState.Content.SendItem.Type.FILE, iconList = emptyList(), + shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1", ) private val DEFAULT_CONTENT_VIEW_STATE: SendState.ViewState.Content = SendState.ViewState.Content( @@ -500,6 +501,7 @@ private val DEFAULT_CONTENT_VIEW_STATE: SendState.ViewState.Content = SendState. deletionDate = "1", type = SendState.ViewState.Content.SendItem.Type.TEXT, iconList = emptyList(), + shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1", ), ), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index 9e8242593..127823d02 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -4,7 +4,10 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -30,6 +33,9 @@ class SendViewModelTest : BaseViewModelTest() { private val mutableSendDataFlow = MutableStateFlow>(DataState.Loading) private val clipboardManager: BitwardenClipboardManager = mockk() + private val environmentRepo: EnvironmentRepository = mockk { + every { environment } returns Environment.Us + } private val vaultRepo: VaultRepository = mockk { every { sendDataStateFlow } returns mutableSendDataFlow } @@ -114,12 +120,18 @@ class SendViewModelTest : BaseViewModelTest() { } @Test - fun `CopyClick should emit ShowToast`() = runTest { + fun `CopyClick should call setText on the ClipboardManager`() { val viewModel = createViewModel() - val sendItem = mockk() - viewModel.eventFlow.test { - viewModel.trySendAction(SendAction.CopyClick(sendItem)) - assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + val testUrl = "www.test.com/" + val sendItem = mockk { + every { shareUrl } returns testUrl + } + every { clipboardManager.setText(testUrl) } just runs + + viewModel.trySendAction(SendAction.CopyClick(sendItem)) + + verify(exactly = 1) { + clipboardManager.setText(testUrl) } } @@ -182,7 +194,9 @@ class SendViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val viewState = mockk() val sendData = mockk { - every { toViewState() } returns viewState + every { + toViewState(Environment.Us.environmentUrlData.baseWebSendUrl) + } returns viewState } mutableSendDataFlow.value = DataState.Loaded(sendData) @@ -225,7 +239,9 @@ class SendViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() val viewState = mockk() val sendData = mockk { - every { toViewState() } returns viewState + every { + toViewState(Environment.Us.environmentUrlData.baseWebSendUrl) + } returns viewState } mutableSendDataFlow.value = DataState.Pending(sendData) @@ -236,12 +252,14 @@ class SendViewModelTest : BaseViewModelTest() { private fun createViewModel( state: SendState? = null, bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager, + environmentRepository: EnvironmentRepository = environmentRepo, vaultRepository: VaultRepository = vaultRepo, ): SendViewModel = SendViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, clipboardManager = bitwardenClipboardManager, + environmentRepo = environmentRepository, vaultRepo = vaultRepository, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensionsTest.kt index 3637a2869..11f668f63 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendDataExtensionsTest.kt @@ -5,6 +5,9 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.ui.tools.feature.send.SendState import com.x8bit.bitwarden.ui.tools.feature.send.model.SendStatusIcon +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -17,32 +20,40 @@ class SendDataExtensionsTest { fun setup() { // Setting the timezone so the tests pass consistently no matter the environment. TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + mockkStatic(SEND_VIEW_EXTENSIONS_PATH) } @AfterEach fun tearDown() { // Clearing the timezone after the test. TimeZone.setDefault(null) + unmockkStatic(SEND_VIEW_EXTENSIONS_PATH) } @Test fun `toViewState should return Empty when SendData is empty`() { val sendData = SendData(emptyList()) - val result = sendData.toViewState() + val result = sendData.toViewState(DEFAULT_BASE_URL) assertEquals(SendState.ViewState.Empty, result) } @Test fun `toViewState should return Content when SendData is not empty`() { + val textSendView = createMockSendView(number = 2, type = SendType.TEXT) + val fileSendView = createMockSendView(number = 1, type = SendType.FILE) val list = listOf( - createMockSendView(number = 2, type = SendType.TEXT), - createMockSendView(number = 1, type = SendType.FILE), + textSendView, + fileSendView, ) + val textSendViewUrl1 = "www.test.com/#/send/mockAccessId-1/mockKey-1" + val textSendViewUrl2 = "www.test.com/#/send/mockAccessId-2/mockKey-2" val sendData = SendData(list) + every { textSendView.toSendUrl(DEFAULT_BASE_URL) } returns textSendViewUrl2 + every { fileSendView.toSendUrl(DEFAULT_BASE_URL) } returns textSendViewUrl1 - val result = sendData.toViewState() + val result = sendData.toViewState(DEFAULT_BASE_URL) assertEquals( SendState.ViewState.Content( @@ -60,6 +71,7 @@ class SendDataExtensionsTest { SendStatusIcon.EXPIRED, SendStatusIcon.PENDING_DELETE, ), + shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1", ), SendState.ViewState.Content.SendItem( id = "mockId-2", @@ -72,6 +84,7 @@ class SendDataExtensionsTest { SendStatusIcon.EXPIRED, SendStatusIcon.PENDING_DELETE, ), + shareUrl = "www.test.com/#/send/mockAccessId-2/mockKey-2", ), ), ), @@ -79,3 +92,8 @@ class SendDataExtensionsTest { ) } } + +private const val SEND_VIEW_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.tools.feature.send.util.SendViewExtensionsKt" + +private const val DEFAULT_BASE_URL: String = "www.test.com/" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewTest.kt new file mode 100644 index 000000000..d146c721f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/util/SendViewTest.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.util + +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SendViewTest { + + @Test + fun `toSendUrl should create an appropriate url`() { + val sendView = createMockSendView(number = 1) + + val result = sendView.toSendUrl(baseWebSendUrl = "www.test.com/") + + assertEquals("www.test.com/mockAccessId-1/mockKey-1", result) + } +}