Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 20:02:05 +05:00
parent 2561a7eef6
commit e95243ab79
28 changed files with 422 additions and 112 deletions

View file

@ -627,5 +627,7 @@ internal class DefaultUserWalletsListRepository(
private suspend fun onAllWalletsDeleted() { private suspend fun onAllWalletsDeleted() {
// reset flag (that is set from AF deeplink) after removing the last wallet // reset flag (that is set from AF deeplink) after removing the last wallet
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false)
// wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
} }
} }

View file

@ -487,10 +487,12 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory, componentFactory = feedEntryComponentFactory,
) )
} }
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params is AppRoute.Usedesk -> {
createComponentChild( createComponentChild(
context = context, context = context,
params = UsedeskComponent.Params(), params = UsedeskComponent.Params(
userWalletId = route.walletMetaInfo.userWalletId?.stringValue,
),
componentFactory = usedeskComponentFactory, componentFactory = usedeskComponentFactory,
) )
} }

View file

@ -16,7 +16,7 @@
"version": "5.39" "version": "5.39"
}, },
{ {
"name": "USEDESK_ENABLED", "name": "TWI_485_USEDESK_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{ {

View file

@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest {
"SOLANA_TX_HISTORY_ENABLED", "SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED", "STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED", "SWAP_AB_ENABLED",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED", "VISA_ONBOARDING_ENABLED",
"WALLET_CONNECT_BITCOIN_ENABLED", "WALLET_CONNECT_BITCOIN_ENABLED",

View file

@ -41,6 +41,8 @@ object PreferencesKeys {
val USED_CARDS_INFO_KEY by lazy { stringPreferencesKey(name = "usedCardsInfo_v2") } val USED_CARDS_INFO_KEY by lazy { stringPreferencesKey(name = "usedCardsInfo_v2") }
val USEDESK_CLIENT_ID_KEY by lazy { stringPreferencesKey(name = "usedeskClientId") }
val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") } val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") }
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") } val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }

View file

@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
) : FeedbackFeatureToggles { ) : FeedbackFeatureToggles {
override val isUsedeskEnabled: Boolean override val isUsedeskEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED) get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
} }

View file

@ -13,6 +13,8 @@ sealed class Settings(
class ButtonManageTokens : Settings(event = "Button - Manage Tokens") class ButtonManageTokens : Settings(event = "Button - Manage Tokens")
class ButtonOpenChat : Settings(event = "Button - Open Chat")
class ColdWalletAdded( class ColdWalletAdded(
source: AnalyticsParam.ScreensSources?, source: AnalyticsParam.ScreensSources?,
) : Settings( ) : Settings(

View file

@ -30,11 +30,9 @@ internal class PreviewDetailsComponent : DetailsComponent {
).buildAll( ).buildAll(
isWalletConnectAvailable = true, isWalletConnectAvailable = true,
isAddressBookAvailable = true, isAddressBookAvailable = true,
isSupportChatAvailable = true,
hasAnyMobileWallet = true, hasAnyMobileWallet = true,
userWalletId = UserWalletId(""), userWalletId = UserWalletId(""),
onSupportEmailClick = {}, onSupportClick = {},
onSupportChatClick = {},
onBuyClick = {}, onBuyClick = {},
) )
} }
@ -48,6 +46,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
items = previewBlocks, items = previewBlocks,
footer = previewFooter, footer = previewFooter,
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty,
popBack = { /* no-op */ }, popBack = { /* no-op */ },
) )

View file

@ -7,5 +7,6 @@ internal data class DetailsUM(
val items: ImmutableList<DetailsItemUM>, val items: ImmutableList<DetailsItemUM>,
val footer: DetailsFooterUM, val footer: DetailsFooterUM,
val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig, val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig,
val selectContactSupportTypeBSConfig: TangemBottomSheetConfig,
val popBack: () -> Unit, val popBack: () -> Unit,
) )

View file

@ -0,0 +1,16 @@
package com.tangem.features.details.entity
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.details.impl.R
internal data class SelectContactSupportTypeBS(
val onOptionClick: (Option) -> Unit,
) : TangemBottomSheetConfigContent {
enum class Option(val text: TextReference) {
Mail(resourceReference(R.string.support_selector_view_email_button)),
Chat(resourceReference(R.string.support_selector_view_chat_button)),
}
}

View file

@ -24,6 +24,7 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.analytics.Settings
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressBookFeatureToggles
@ -31,6 +32,7 @@ import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.entity.DetailsUM import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder import com.tangem.features.details.utils.SocialsBuilder
@ -71,6 +73,8 @@ internal class DetailsModel @Inject constructor(
private val params: DetailsComponent.Params = paramsContainer.require() private val params: DetailsComponent.Params = paramsContainer.require()
private val isUsedeskEnabled = feedbackFeatureToggles.isUsedeskEnabled
private val items: MutableStateFlow<ImmutableList<DetailsItemUM>> private val items: MutableStateFlow<ImmutableList<DetailsItemUM>>
val state: MutableStateFlow<DetailsUM> val state: MutableStateFlow<DetailsUM>
@ -89,11 +93,9 @@ internal class DetailsModel @Inject constructor(
itemsBuilder.buildAll( itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable, isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled, isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot }, hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
onSupportEmailClick = ::sendFeedback, onSupportClick = ::onContactSupportClick,
onSupportChatClick = ::openUseDesk,
onBuyClick = ::onBuyClick, onBuyClick = ::onBuyClick,
), ),
) )
@ -108,6 +110,7 @@ internal class DetailsModel @Inject constructor(
appVersion = getAppVersion(), appVersion = getAppVersion(),
), ),
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty,
popBack = router::pop, popBack = router::pop,
), ),
) )
@ -159,6 +162,46 @@ internal class DetailsModel @Inject constructor(
} }
} }
private fun onContactSupportClick() {
// Offer the mail/chat choice only when the chat is available; otherwise open mail directly.
if (isUsedeskEnabled) {
showContactSupportChooserBS()
} else {
sendFeedback()
}
}
private fun showContactSupportChooserBS() {
state.update { current ->
current.copy(
selectContactSupportTypeBSConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::hideContactSupportChooserBS,
content = SelectContactSupportTypeBS(
onOptionClick = { option ->
hideContactSupportChooserBS()
when (option) {
SelectContactSupportTypeBS.Option.Mail -> sendFeedback()
SelectContactSupportTypeBS.Option.Chat -> {
analyticsEventHandler.send(Settings.ButtonOpenChat())
openUseDesk()
}
}
},
),
),
)
}
}
private fun hideContactSupportChooserBS() {
state.update { current ->
current.copy(
selectContactSupportTypeBSConfig = current.selectContactSupportTypeBSConfig.copy(isShown = false),
)
}
}
private fun openUseDesk() { private fun openUseDesk() {
modelScope.launch { modelScope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")

View file

@ -76,6 +76,7 @@ internal fun DetailsScreen(
} }
SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig) SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig)
SelectContactSupportTypeBottomSheet(state.selectContactSupportTypeBSConfig)
} }
@Composable @Composable

View file

@ -0,0 +1,58 @@
package com.tangem.features.details.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.impl.R
@Composable
internal fun SelectContactSupportTypeBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<SelectContactSupportTypeBS>(
config = config,
titleText = resourceReference(R.string.common_contact_support),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: SelectContactSupportTypeBS) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
SelectContactSupportTypeBS.Option.entries.forEachIndexed { index, type ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = SelectContactSupportTypeBS.Option.entries.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { content.onOptionClick(type) },
showDivider = index != SelectContactSupportTypeBS.Option.entries.lastIndex,
) {
InputRowChecked(
text = type.text,
checked = false,
)
}
}
}
}

View file

@ -27,11 +27,9 @@ internal class ItemsBuilder @Inject constructor(
fun buildAll( fun buildAll(
isWalletConnectAvailable: Boolean, isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean, isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean, hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId, userWalletId: UserWalletId,
onSupportEmailClick: () -> Unit, onSupportClick: () -> Unit,
onSupportChatClick: () -> Unit,
onBuyClick: () -> Unit, onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList { ): ImmutableList<DetailsItemUM> = buildList {
if (isAddressBookAvailable) { if (isAddressBookAvailable) {
@ -50,11 +48,7 @@ internal class ItemsBuilder @Inject constructor(
buildShopBlock(onBuyClick).let(::add) buildShopBlock(onBuyClick).let(::add)
buildSettingsBlock().let(::add) buildSettingsBlock().let(::add)
buildSupportBlock( buildSupportBlock(onSupportClick = onSupportClick).let(::add)
onSupportEmailClick = onSupportEmailClick,
onSupportChatClick = onSupportChatClick,
isSupportChatAvailable = isSupportChatAvailable,
).let(::add)
}.toImmutableList() }.toImmutableList()
fun addTangemPayItem(items: ImmutableList<DetailsItemUM>, onClick: () -> Unit): ImmutableList<DetailsItemUM> { fun addTangemPayItem(items: ImmutableList<DetailsItemUM>, onClick: () -> Unit): ImmutableList<DetailsItemUM> {
@ -148,33 +142,18 @@ internal class ItemsBuilder @Inject constructor(
}.toImmutableList(), }.toImmutableList(),
) )
private fun buildSupportBlock( private fun buildSupportBlock(onSupportClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(
onSupportEmailClick: () -> Unit,
onSupportChatClick: () -> Unit,
isSupportChatAvailable: Boolean,
): DetailsItemUM = DetailsItemUM.Basic(
id = "support", id = "support",
items = buildList { items = buildList {
DetailsItemUM.Basic.Item( DetailsItemUM.Basic.Item(
id = "support_email", id = "contact_support",
block = BlockUM( block = BlockUM(
text = resourceReference(R.string.common_contact_support), text = resourceReference(R.string.common_contact_support),
iconRes = R.drawable.ic_comment_24, iconRes = R.drawable.ic_comment_24,
onClick = onSupportEmailClick, onClick = onSupportClick,
), ),
).let(::add) ).let(::add)
if (isSupportChatAvailable) {
DetailsItemUM.Basic.Item(
id = "support_chat",
block = BlockUM(
text = resourceReference(R.string.details_row_title_contact_to_support_chat),
iconRes = R.drawable.ic_chat_24,
onClick = onSupportChatClick,
),
).let(::add)
}
DetailsItemUM.Basic.Item( DetailsItemUM.Basic.Item(
id = "disclaimer", id = "disclaimer",
block = BlockUM( block = BlockUM(

View file

@ -34,7 +34,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
// Act // Act
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onEmailSlot.captured.invoke() onSupportSlot.captured.invoke()
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -54,7 +54,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onEmailSlot.captured.invoke() onSupportSlot.captured.invoke()
advanceUntilIdle() advanceUntilIdle()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) } verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
@ -73,7 +73,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onEmailSlot.captured.invoke() onSupportSlot.captured.invoke()
advanceUntilIdle() advanceUntilIdle()
val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig
@ -93,7 +93,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onEmailSlot.captured.invoke() onSupportSlot.captured.invoke()
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) } coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
@ -183,7 +183,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
currentModel = createModel(this) currentModel = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onEmailSlot.captured.invoke() onSupportSlot.captured.invoke()
advanceUntilIdle() advanceUntilIdle()
return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS

View file

@ -65,24 +65,6 @@ internal class DetailsModelInitTest : DetailsModelTestBase() {
assertThat(abSlot.captured).isFalse() assertThat(abSlot.captured).isFalse()
} }
@Test
fun `GIVEN usedesk enabled WHEN init THEN buildAll receives isSupportChatAvailable true`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isTrue()
}
@Test
fun `GIVEN usedesk disabled WHEN init THEN buildAll receives isSupportChatAvailable false`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns false
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isFalse()
}
@Test @Test
fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest { fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest {
every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1)) every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1))

View file

@ -5,7 +5,9 @@ import arrow.core.right
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -17,17 +19,19 @@ import org.junit.jupiter.api.Test
internal class DetailsModelNavigationTest : DetailsModelTestBase() { internal class DetailsModelNavigationTest : DetailsModelTestBase() {
@Test @Test
fun `GIVEN selected wallet and meta WHEN support chat clicked THEN router pushes Usedesk`() = runTest { fun `GIVEN usedesk enabled WHEN chat option selected THEN router pushes Usedesk`() = runTest {
// Arrange // Arrange
val wallet = hotWallet(wallet1) val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1) val meta = metaInfo(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right() every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right() coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
// Act // Act
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onChatSlot.captured.invoke() onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -36,20 +40,51 @@ internal class DetailsModelNavigationTest : DetailsModelTestBase() {
} }
@Test @Test
fun `GIVEN meta info missing WHEN support chat clicked THEN no navigation`() = runTest { fun `GIVEN meta info missing WHEN chat option selected THEN no navigation`() = runTest {
val wallet = hotWallet(wallet1) val wallet = hotWallet(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right() every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left() coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
val model = createModel(this) val model = createModel(this)
advanceUntilIdle() advanceUntilIdle()
onChatSlot.captured.invoke() onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle() advanceUntilIdle()
verify(exactly = 0) { router.push(route = any(), onComplete = any()) } verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
model.onDestroy() model.onDestroy()
} }
@Test
fun `GIVEN usedesk enabled WHEN mail option selected THEN sends email and does not open Usedesk`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
// Act
val model = createModel(this)
advanceUntilIdle()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Mail)
advanceUntilIdle()
// Assert
coVerify { sendFeedbackEmailUseCase(any()) }
verify(exactly = 0) { router.push(route = AppRoute.Usedesk(meta), onComplete = any()) }
model.onDestroy()
}
private fun selectContactSupportOption(model: DetailsModel, option: SelectContactSupportTypeBS.Option) {
val content = model.state.value.selectContactSupportTypeBSConfig.content as SelectContactSupportTypeBS
content.onOptionClick(option)
}
@Test @Test
fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest { fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest {
coEvery { coEvery {

View file

@ -70,11 +70,9 @@ internal abstract class DetailsModelTestBase {
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>() protected val wcSlot = slot<Boolean>()
protected val abSlot = slot<Boolean>() protected val abSlot = slot<Boolean>()
protected val chatSlot = slot<Boolean>()
protected val mobileSlot = slot<Boolean>() protected val mobileSlot = slot<Boolean>()
protected val walletIdSlot = slot<UserWalletId>() protected val walletIdSlot = slot<UserWalletId>()
protected val onEmailSlot = slot<() -> Unit>() protected val onSupportSlot = slot<() -> Unit>()
protected val onChatSlot = slot<() -> Unit>()
protected val onBuySlot = slot<() -> Unit>() protected val onBuySlot = slot<() -> Unit>()
protected val onTangemPaySlot = slot<() -> Unit>() protected val onTangemPaySlot = slot<() -> Unit>()
@ -96,11 +94,9 @@ internal abstract class DetailsModelTestBase {
itemsBuilder.buildAll( itemsBuilder.buildAll(
isWalletConnectAvailable = capture(wcSlot), isWalletConnectAvailable = capture(wcSlot),
isAddressBookAvailable = capture(abSlot), isAddressBookAvailable = capture(abSlot),
isSupportChatAvailable = capture(chatSlot),
hasAnyMobileWallet = capture(mobileSlot), hasAnyMobileWallet = capture(mobileSlot),
userWalletId = capture(walletIdSlot), userWalletId = capture(walletIdSlot),
onSupportEmailClick = capture(onEmailSlot), onSupportClick = capture(onSupportSlot),
onSupportChatClick = capture(onChatSlot),
onBuyClick = capture(onBuySlot), onBuyClick = capture(onBuySlot),
) )
} returns persistentListOf() } returns persistentListOf()
@ -136,7 +132,7 @@ internal abstract class DetailsModelTestBase {
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) { protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {
every { every {
itemsBuilder.buildAll(any(), any(), any(), any(), any(), any(), any(), any()) itemsBuilder.buildAll(any(), any(), any(), any(), any(), any())
} returns list } returns list
} }

View file

@ -192,7 +192,6 @@ internal class ItemsBuilderTest {
val result = buildAll( val result = buildAll(
isWalletConnectAvailable = false, isWalletConnectAvailable = false,
isAddressBookAvailable = false, isAddressBookAvailable = false,
isSupportChatAvailable = false,
hasAnyMobileWallet = false, hasAnyMobileWallet = false,
) )
@ -209,7 +208,7 @@ internal class ItemsBuilderTest {
assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet") assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
val support = result.single { it.id == "support" } as DetailsItemUM.Basic val support = result.single { it.id == "support" } as DetailsItemUM.Basic
assertThat(support.items.map { it.id }).containsExactly("support_email", "disclaimer").inOrder() assertThat(support.items.map { it.id }).containsExactly("contact_support", "disclaimer").inOrder()
} }
@Test @Test
@ -254,16 +253,13 @@ internal class ItemsBuilderTest {
private fun buildAll( private fun buildAll(
isWalletConnectAvailable: Boolean = false, isWalletConnectAvailable: Boolean = false,
isAddressBookAvailable: Boolean = false, isAddressBookAvailable: Boolean = false,
isSupportChatAvailable: Boolean = false,
hasAnyMobileWallet: Boolean = false, hasAnyMobileWallet: Boolean = false,
): ImmutableList<DetailsItemUM> = itemsBuilder.buildAll( ): ImmutableList<DetailsItemUM> = itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable, isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = isAddressBookAvailable, isAddressBookAvailable = isAddressBookAvailable,
isSupportChatAvailable = isSupportChatAvailable,
hasAnyMobileWallet = hasAnyMobileWallet, hasAnyMobileWallet = hasAnyMobileWallet,
userWalletId = USER_WALLET_ID, userWalletId = USER_WALLET_ID,
onSupportEmailClick = {}, onSupportClick = {},
onSupportChatClick = {},
onBuyClick = {}, onBuyClick = {},
) )

View file

@ -6,7 +6,8 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface UsedeskComponent : ComposableContentComponent { interface UsedeskComponent : ComposableContentComponent {
data class Params( data class Params(
val feedback: String? = null, // TODO [REDACTED_TASK_KEY] /** User wallet id (hex string) sent to Usedesk as the client email to identify the user. */
val userWalletId: String? = null,
) )
interface Factory : ComponentFactory<Params, UsedeskComponent> interface Factory : ComponentFactory<Params, UsedeskComponent>

View file

@ -20,6 +20,13 @@ dependencies {
implementation(projects.common.routing) implementation(projects.common.routing)
implementation(projects.features.usedesk.api) implementation(projects.features.usedesk.api)
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.analytics)
/** Domain */
implementation(projects.domain.feedback)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)
@ -29,8 +36,8 @@ dependencies {
implementation(deps.compose.ui) implementation(deps.compose.ui)
/** Usedesk */ /** Usedesk */
implementation(deps.usedesk.chat.sdk) implementation(tangemDeps.usedesk.chat.sdk)
implementation(deps.usedesk.chat.gui) implementation(tangemDeps.usedesk.chat.gui)
} }

View file

@ -1,11 +1,16 @@
package com.tangem.feature.usedesk package com.tangem.feature.usedesk
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -16,7 +21,7 @@ import com.tangem.feature.usedesk.model.UsedeskModel
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import ru.usedesk.chat_gui.chat.UsedeskChatScreen import com.tangem.usedesk.chat_gui.chat.UsedeskChatScreen
internal class DefaultUsedeskComponent @AssistedInject constructor( internal class DefaultUsedeskComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
@ -25,9 +30,11 @@ internal class DefaultUsedeskComponent @AssistedInject constructor(
private val model: UsedeskModel = getOrCreateModel(params) private val model: UsedeskModel = getOrCreateModel(params)
@Suppress("NestedScopeFunctions")
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val configuration = state.usedeskChatConfiguration
Box( Box(
modifier = Modifier modifier = Modifier
@ -35,21 +42,65 @@ internal class DefaultUsedeskComponent @AssistedInject constructor(
.systemBarsPadding() .systemBarsPadding()
.imePadding(), .imePadding(),
) { ) {
AndroidView( // Show the chat only once the configuration is ready (clientId is loaded asynchronously).
factory = { context -> if (configuration != null) {
FragmentContainerView(context).apply { val activity = LocalContext.current as? FragmentActivity ?: return@Box
id = View.generateViewId() val containerId = rememberSaveable { View.generateViewId() }
val fragment = UsedeskChatScreen.newInstance( AndroidView(
state.usedeskChatConfiguration, factory = { context ->
) FragmentContainerView(context).apply {
(context as FragmentActivity).supportFragmentManager.beginTransaction() id = containerId
.replace(id, fragment)
.commit() val fragment = UsedeskChatScreen.newInstance(
usedeskChatConfiguration = configuration,
allowedFileExtensions = ALLOWED_FILE_EXTENSIONS,
cameraEnabled = false,
).apply {
onChatLoaded = { model.onChatLoaded() }
onChatLoadError = { model.onChatLoadError() }
onAttachLogs = { onReady ->
model.provideLogsFile { file ->
val uri = file?.let { logsFile ->
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
logsFile,
)
}
onReady(uri)
}
}
}
activity.supportFragmentManager.beginTransaction()
.replace(containerId, fragment)
.commit()
}
},
modifier = Modifier.fillMaxSize(),
)
DisposableEffect(Unit) {
onDispose {
// When leaving the chat, hide the keyboard via the (still alive) activity
// window and remove the hosted fragment. Otherwise the input connection to
// the destroyed EditText leaks: the keyboard stays open and crashes the app
// when it is dismissed later.
activity.getSystemService(InputMethodManager::class.java)
?.hideSoftInputFromWindow(activity.window.decorView.windowToken, 0)
val fragmentManager = activity.supportFragmentManager
if (!fragmentManager.isStateSaved) {
fragmentManager.executePendingTransactions()
fragmentManager.findFragmentById(containerId)?.let { fragment ->
fragmentManager.beginTransaction()
.remove(fragment)
.commit()
}
}
} }
}, }
modifier = Modifier.fillMaxSize(), }
)
} }
} }
@ -57,4 +108,14 @@ internal class DefaultUsedeskComponent @AssistedInject constructor(
interface Factory : UsedeskComponent.Factory { interface Factory : UsedeskComponent.Factory {
override fun create(context: AppComponentContext, params: UsedeskComponent.Params): DefaultUsedeskComponent override fun create(context: AppComponentContext, params: UsedeskComponent.Params): DefaultUsedeskComponent
} }
private companion object {
// Allowed attachment types: zip, images (jpg/jpeg/png/gif/webp/heic),
// videos (mp4/mov/webm). jpeg is added as a synonym for jpg.
val ALLOWED_FILE_EXTENSIONS = listOf(
"zip",
"jpg", "jpeg", "png", "gif", "webp", "heic",
"mp4", "mov", "webm",
)
}
} }

View file

@ -0,0 +1,19 @@
package com.tangem.feature.usedesk.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
internal sealed class UsedeskAnalyticsEvents(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Support", event = event, params = params) {
class ChatScreenOpened(source: AnalyticsParam.ScreensSources) : UsedeskAnalyticsEvents(
event = "Chat Screen Opened",
params = mapOf(AnalyticsParam.SOURCE to source.value),
)
class ChatScreenError : UsedeskAnalyticsEvents(event = "Chat Screen Error")
class ChatScreenClosed : UsedeskAnalyticsEvents(event = "Chat Screen Closed")
}

View file

@ -1,35 +1,109 @@
package com.tangem.feature.usedesk.model package com.tangem.feature.usedesk.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.feature.usedesk.analytics.UsedeskAnalyticsEvents
import com.tangem.feature.usedesk.api.UsedeskComponent
import com.tangem.usedesk.chat_sdk.entity.UsedeskChatConfiguration
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import ru.usedesk.chat_sdk.entity.UsedeskChatConfiguration import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@Stable @Stable
@ModelScoped @ModelScoped
internal class UsedeskModel @Inject constructor( internal class UsedeskModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedbackRepository: FeedbackRepository,
paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
private val _state = MutableStateFlow(getInitialState()) private val params = paramsContainer.require<UsedeskComponent.Params>()
private val _state = MutableStateFlow(UsedeskState(usedeskChatConfiguration = null))
val state: StateFlow<UsedeskState> = _state val state: StateFlow<UsedeskState> = _state
private fun getInitialState(): UsedeskState { // The chat screen opened/error event is sent only once.
return UsedeskState( private var isScreenLoadEventSent = false
UsedeskChatConfiguration(
companyId = COMPANY_ID, init {
channelId = CHANNEL_ID, modelScope.launch {
), val clientId = getOrCreateClientId()
_state.value = UsedeskState(
UsedeskChatConfiguration(
urlChat = URL_CHAT,
urlChatApi = URL_CHAT_API,
companyId = COMPANY_ID,
channelId = CHANNEL_ID,
clientId = clientId,
clientEmail = params.userWalletId,
),
)
}
}
fun onChatLoaded() {
if (isScreenLoadEventSent) return
isScreenLoadEventSent = true
analyticsEventHandler.send(
UsedeskAnalyticsEvents.ChatScreenOpened(source = AnalyticsParam.ScreensSources.Settings),
) )
} }
fun onChatLoadError() {
if (isScreenLoadEventSent) return
isScreenLoadEventSent = true
analyticsEventHandler.send(UsedeskAnalyticsEvents.ChatScreenError())
}
/**
* Collects the app logs into a zip archive (the same one attached to the support email)
* and returns it via [onReady] on the main thread, or null if there are no logs.
*/
fun provideLogsFile(onReady: (File?) -> Unit) {
modelScope.launch {
val file = runSuspendCatching { feedbackRepository.getZipLogFile() }.getOrNull()
withContext(dispatchers.main) { onReady(file) }
}
}
override fun onDestroy() {
analyticsEventHandler.send(UsedeskAnalyticsEvents.ChatScreenClosed())
super.onDestroy()
}
private suspend fun getOrCreateClientId(): String {
var clientId = ""
appPreferencesStore.editData { preferences ->
val existing = preferences[PreferencesKeys.USEDESK_CLIENT_ID_KEY]
clientId = if (existing.isNullOrBlank()) {
UUID.randomUUID().toString().also { preferences[PreferencesKeys.USEDESK_CLIENT_ID_KEY] = it }
} else {
existing
}
}
return clientId
}
private companion object { private companion object {
const val COMPANY_ID = "170509" const val URL_CHAT = "https://pubsub.tangem.org"
const val CHANNEL_ID = "65637" const val URL_CHAT_API = "https://ud.tangem.org"
const val COMPANY_ID = "2"
const val CHANNEL_ID = "54"
} }
} }

View file

@ -1,9 +1,10 @@
package com.tangem.feature.usedesk.model package com.tangem.feature.usedesk.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import ru.usedesk.chat_sdk.entity.UsedeskChatConfiguration import com.tangem.usedesk.chat_sdk.entity.UsedeskChatConfiguration
@Stable @Stable
internal data class UsedeskState( internal data class UsedeskState(
val usedeskChatConfiguration: UsedeskChatConfiguration, // null while the clientId (UUID from AppPreferencesStore) is being prepared asynchronously.
val usedeskChatConfiguration: UsedeskChatConfiguration?,
) )

View file

@ -96,7 +96,6 @@ decompose = "3.3.0"
room = "2.7.2" room = "2.7.2"
markdown = "0.7.2" markdown = "0.7.2"
markdownComposeView = "0.5.4" markdownComposeView = "0.5.4"
usedesk = "4.4.0"
sumsub = "1.38.0" sumsub = "1.38.0"
haze = "1.7.2" haze = "1.7.2"
kotlinpoet = "1.18.1" kotlinpoet = "1.18.1"
@ -306,8 +305,6 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" } markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
markdown-composeview = { module = "com.github.jeziellago:compose-markdown", version.ref = "markdownComposeView" } markdown-composeview = { module = "com.github.jeziellago:compose-markdown", version.ref = "markdownComposeView" }
usedesk-chat-sdk = { module = "com.github.Usedesk.Android_SDK:chat-sdk", version.ref = "usedesk" }
usedesk-chat-gui = { module = "com.github.Usedesk.Android_SDK:chat-gui", version.ref = "usedesk" }
sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" } sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }

View file

@ -13,8 +13,8 @@ tangemVico = "tangem-master-21"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^ #tangemVico = "0.0.1" # Keep it! - used for local builds ^
tangemHotSdk = "develop-550" tangemHotSdk = "develop-550"
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^
tangemUsedeskSdk = "main-9"
#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^
[libraries] [libraries]
blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" }
@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange
hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" }
hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" }
usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" }
usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" }
vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" }
vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" }
vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" }

View file

@ -42,6 +42,7 @@ dependencyResolutionManagement {
includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin") includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin")
includeGroupAndSubgroups("com.tangem.tangem-hot-sdk-kotlin") includeGroupAndSubgroups("com.tangem.tangem-hot-sdk-kotlin")
includeGroupAndSubgroups("com.tangem.vico") includeGroupAndSubgroups("com.tangem.vico")
includeGroupAndSubgroups("com.tangem.usedesk")
includeModule("com.tangem", "blstlib") includeModule("com.tangem", "blstlib")
includeModule("com.tangem", "blockchain") includeModule("com.tangem", "blockchain")
includeModule("com.tangem", "wallet-core-proto") includeModule("com.tangem", "wallet-core-proto")
@ -129,6 +130,15 @@ dependencyResolutionManagement {
includeGroupAndSubgroups("org.web3j") includeGroupAndSubgroups("org.web3j")
} }
} }
maven {
// setting any repository from tangem project allows maven search all packages in the project
url = uri("https://maven.pkg.github.com/tangem/ud-android-sdk")
credentials {
username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")
password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")
}
content { includeGroupAndSubgroups("com.tangem.usedesk") }
}
maven("https://jitpack.io") maven("https://jitpack.io")
maven("https://maven.sumsub.com/repository/maven-public/") maven("https://maven.sumsub.com/repository/maven-public/")
} }
@ -162,6 +172,30 @@ if (properties.getProperty("blockchainSdk.local").toBoolean()) {
} }
} }
// Optional local composite build for the Usedesk SDK fork.
// Enable it from local.properties (which is git-ignored, so it never reaches CI/develop):
//
// usedesk.local=true
// usedesk.path=../ud-android-sdk # optional, this is the default
//
// When enabled, com.tangem.usedesk:* is resolved from local sources instead of the
// published Maven artifact (tangemUsedeskSdk in gradle/tangem_dependencies.toml).
if (properties.getProperty("usedesk.local").toBoolean()) {
val usedeskPath = properties.getProperty("usedesk.path") ?: "../ud-android-sdk"
println("Usedesk SDK: using local composite build from '$usedeskPath'")
includeBuild(usedeskPath) {
dependencySubstitution {
substitute(module("com.tangem.usedesk:common-sdk")).using(project(":common-sdk"))
substitute(module("com.tangem.usedesk:common-gui")).using(project(":common-gui"))
substitute(module("com.tangem.usedesk:chat-sdk")).using(project(":chat-sdk"))
substitute(module("com.tangem.usedesk:chat-gui")).using(project(":chat-gui"))
substitute(module("com.tangem.usedesk:knowledgebase-sdk")).using(project(":knowledgebase-sdk"))
substitute(module("com.tangem.usedesk:knowledgebase-gui")).using(project(":knowledgebase-gui"))
}
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app") include(":app")
@ -480,4 +514,4 @@ include(":data:yield-supply")
include(":data:news") include(":data:news")
include(":data:earn") include(":data:earn")
include(":data:search") include(":data:search")
// endregion Data modules // endregion Data modules