Add support to copy a send url to the clipboard (#508)

This commit is contained in:
David Perez 2024-01-05 23:10:06 -06:00 committed by Álison Fernandes
parent 0a3377d98a
commit 4a39f126dd
9 changed files with 230 additions and 40 deletions

View file

@ -4,6 +4,39 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import java.net.URI 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. * Returns the appropriate pre-defined labels for environments matching the known US/EU values.
* Otherwise returns the host of the custom base URL. * Otherwise returns the host of the custom base URL.

View file

@ -6,7 +6,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -34,6 +36,7 @@ private const val KEY_STATE = "state"
class SendViewModel @Inject constructor( class SendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager, private val clipboardManager: BitwardenClipboardManager,
private val environmentRepo: EnvironmentRepository,
private val vaultRepo: VaultRepository, private val vaultRepo: VaultRepository,
) : BaseViewModel<SendState, SendEvent, SendAction>( ) : BaseViewModel<SendState, SendEvent, SendAction>(
// We load the state from the savedStateHandle for testing purposes. // We load the state from the savedStateHandle for testing purposes.
@ -84,7 +87,14 @@ class SendViewModel @Inject constructor(
is DataState.Loaded -> { is DataState.Loaded -> {
mutableStateFlow.update { 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 -> { is DataState.Pending -> {
mutableStateFlow.update { 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) { private fun handleCopyClick(action: SendAction.CopyClick) {
// TODO: Create a link and copy it to the clipboard BIT-?? clipboardManager.setText(text = action.sendItem.shareUrl)
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
} }
private fun handleSendClick(action: SendAction.SendClick) { private fun handleSendClick(action: SendAction.SendClick) {
@ -205,6 +221,7 @@ data class SendState(
val deletionDate: String, val deletionDate: String,
val type: Type, val type: Type,
val iconList: List<SendStatusIcon>, val iconList: List<SendStatusIcon>,
val shareUrl: String,
) : Parcelable { ) : Parcelable {
/** /**
* Indicates the type of send this, a text or file. * Indicates the type of send this, a text or file.

View file

@ -13,14 +13,18 @@ private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a"
/** /**
* Transforms [SendData] into [SendState.ViewState]. * Transforms [SendData] into [SendState.ViewState].
*/ */
fun SendData.toViewState(): SendState.ViewState = fun SendData.toViewState(
baseWebSendUrl: String,
): SendState.ViewState =
this this
.sendViewList .sendViewList
.takeUnless { it.isEmpty() } .takeUnless { it.isEmpty() }
?.toSendContent() ?.toSendContent(baseWebSendUrl)
?: SendState.ViewState.Empty ?: SendState.ViewState.Empty
private fun List<SendView>.toSendContent(): SendState.ViewState.Content { private fun List<SendView>.toSendContent(
baseWebSendUrl: String,
): SendState.ViewState.Content {
return SendState.ViewState.Content( return SendState.ViewState.Content(
textTypeCount = this.count { it.type == SendType.TEXT }, textTypeCount = this.count { it.type == SendType.TEXT },
fileTypeCount = this.count { it.type == SendType.FILE }, fileTypeCount = this.count { it.type == SendType.FILE },
@ -49,6 +53,7 @@ private fun List<SendView>.toSendContent(): SendState.ViewState.Content {
sendView.deletionDate.isBefore(Instant.now()) sendView.deletionDate.isBefore(Instant.now())
}, },
), ),
shareUrl = sendView.toSendUrl(baseWebSendUrl),
) )
} }
.sortedBy { it.name }, .sortedBy { it.name },

View file

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

View file

@ -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.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class EnvironmentUrlsDataJsonExtensionsTest { 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 @Test
fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() { fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() {
val environment = EnvironmentUrlDataJson.DEFAULT_US val environment = EnvironmentUrlDataJson.DEFAULT_US
@ -52,20 +129,11 @@ class EnvironmentUrlsDataJsonExtensionsTest {
@Test @Test
fun `toEnvironmentUrls should correctly convert custom urls to the expected type`() { 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( assertEquals(
Environment.SelfHosted( 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 @Test
fun `toEnvironmentUrlsOrDefault should correctly convert custom urls to the expected type`() { 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( assertEquals(
Environment.SelfHosted( 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",
)

View file

@ -487,6 +487,7 @@ private val DEFAULT_SEND_ITEM: SendState.ViewState.Content.SendItem =
deletionDate = "1", deletionDate = "1",
type = SendState.ViewState.Content.SendItem.Type.FILE, type = SendState.ViewState.Content.SendItem.Type.FILE,
iconList = emptyList(), iconList = emptyList(),
shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1",
) )
private val DEFAULT_CONTENT_VIEW_STATE: SendState.ViewState.Content = SendState.ViewState.Content( 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", deletionDate = "1",
type = SendState.ViewState.Content.SendItem.Type.TEXT, type = SendState.ViewState.Content.SendItem.Type.TEXT,
iconList = emptyList(), iconList = emptyList(),
shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1",
), ),
), ),
) )

View file

@ -4,7 +4,10 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -30,6 +33,9 @@ class SendViewModelTest : BaseViewModelTest() {
private val mutableSendDataFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading) private val mutableSendDataFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val clipboardManager: BitwardenClipboardManager = mockk() private val clipboardManager: BitwardenClipboardManager = mockk()
private val environmentRepo: EnvironmentRepository = mockk {
every { environment } returns Environment.Us
}
private val vaultRepo: VaultRepository = mockk { private val vaultRepo: VaultRepository = mockk {
every { sendDataStateFlow } returns mutableSendDataFlow every { sendDataStateFlow } returns mutableSendDataFlow
} }
@ -114,12 +120,18 @@ class SendViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `CopyClick should emit ShowToast`() = runTest { fun `CopyClick should call setText on the ClipboardManager`() {
val viewModel = createViewModel() val viewModel = createViewModel()
val sendItem = mockk<SendState.ViewState.Content.SendItem>() val testUrl = "www.test.com/"
viewModel.eventFlow.test { val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { shareUrl } returns testUrl
}
every { clipboardManager.setText(testUrl) } just runs
viewModel.trySendAction(SendAction.CopyClick(sendItem)) viewModel.trySendAction(SendAction.CopyClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
verify(exactly = 1) {
clipboardManager.setText(testUrl)
} }
} }
@ -182,7 +194,9 @@ class SendViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel() val viewModel = createViewModel()
val viewState = mockk<SendState.ViewState.Content>() val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> { val sendData = mockk<SendData> {
every { toViewState() } returns viewState every {
toViewState(Environment.Us.environmentUrlData.baseWebSendUrl)
} returns viewState
} }
mutableSendDataFlow.value = DataState.Loaded(sendData) mutableSendDataFlow.value = DataState.Loaded(sendData)
@ -225,7 +239,9 @@ class SendViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel() val viewModel = createViewModel()
val viewState = mockk<SendState.ViewState.Content>() val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> { val sendData = mockk<SendData> {
every { toViewState() } returns viewState every {
toViewState(Environment.Us.environmentUrlData.baseWebSendUrl)
} returns viewState
} }
mutableSendDataFlow.value = DataState.Pending(sendData) mutableSendDataFlow.value = DataState.Pending(sendData)
@ -236,12 +252,14 @@ class SendViewModelTest : BaseViewModelTest() {
private fun createViewModel( private fun createViewModel(
state: SendState? = null, state: SendState? = null,
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager, bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
environmentRepository: EnvironmentRepository = environmentRepo,
vaultRepository: VaultRepository = vaultRepo, vaultRepository: VaultRepository = vaultRepo,
): SendViewModel = SendViewModel( ): SendViewModel = SendViewModel(
savedStateHandle = SavedStateHandle().apply { savedStateHandle = SavedStateHandle().apply {
set("state", state) set("state", state)
}, },
clipboardManager = bitwardenClipboardManager, clipboardManager = bitwardenClipboardManager,
environmentRepo = environmentRepository,
vaultRepo = vaultRepository, vaultRepo = vaultRepository,
) )
} }

View file

@ -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.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.tools.feature.send.SendState import com.x8bit.bitwarden.ui.tools.feature.send.SendState
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendStatusIcon 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.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -17,32 +20,40 @@ class SendDataExtensionsTest {
fun setup() { fun setup() {
// Setting the timezone so the tests pass consistently no matter the environment. // Setting the timezone so the tests pass consistently no matter the environment.
TimeZone.setDefault(TimeZone.getTimeZone("UTC")) TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
mockkStatic(SEND_VIEW_EXTENSIONS_PATH)
} }
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
// Clearing the timezone after the test. // Clearing the timezone after the test.
TimeZone.setDefault(null) TimeZone.setDefault(null)
unmockkStatic(SEND_VIEW_EXTENSIONS_PATH)
} }
@Test @Test
fun `toViewState should return Empty when SendData is empty`() { fun `toViewState should return Empty when SendData is empty`() {
val sendData = SendData(emptyList()) val sendData = SendData(emptyList())
val result = sendData.toViewState() val result = sendData.toViewState(DEFAULT_BASE_URL)
assertEquals(SendState.ViewState.Empty, result) assertEquals(SendState.ViewState.Empty, result)
} }
@Test @Test
fun `toViewState should return Content when SendData is not empty`() { 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( val list = listOf(
createMockSendView(number = 2, type = SendType.TEXT), textSendView,
createMockSendView(number = 1, type = SendType.FILE), 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) 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( assertEquals(
SendState.ViewState.Content( SendState.ViewState.Content(
@ -60,6 +71,7 @@ class SendDataExtensionsTest {
SendStatusIcon.EXPIRED, SendStatusIcon.EXPIRED,
SendStatusIcon.PENDING_DELETE, SendStatusIcon.PENDING_DELETE,
), ),
shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1",
), ),
SendState.ViewState.Content.SendItem( SendState.ViewState.Content.SendItem(
id = "mockId-2", id = "mockId-2",
@ -72,6 +84,7 @@ class SendDataExtensionsTest {
SendStatusIcon.EXPIRED, SendStatusIcon.EXPIRED,
SendStatusIcon.PENDING_DELETE, 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/"

View file

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