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() {
// reset flag (that is set from AF deeplink) after removing the last wallet
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,
)
}
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params
is AppRoute.Usedesk -> {
createComponentChild(
context = context,
params = UsedeskComponent.Params(),
params = UsedeskComponent.Params(
userWalletId = route.walletMetaInfo.userWalletId?.stringValue,
),
componentFactory = usedeskComponentFactory,
)
}

View file

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

View file

@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest {
"SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_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 USEDESK_CLIENT_ID_KEY by lazy { stringPreferencesKey(name = "usedeskClientId") }
val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") }
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }

View file

@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
) : FeedbackFeatureToggles {
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 ButtonOpenChat : Settings(event = "Button - Open Chat")
class ColdWalletAdded(
source: AnalyticsParam.ScreensSources?,
) : Settings(

View file

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

View file

@ -7,5 +7,6 @@ internal data class DetailsUM(
val items: ImmutableList<DetailsItemUM>,
val footer: DetailsFooterUM,
val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig,
val selectContactSupportTypeBSConfig: TangemBottomSheetConfig,
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.walletconnect.CheckIsWalletConnectAvailableUseCase
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.GetWalletsUseCase
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.DetailsItemUM
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.utils.ItemsBuilder
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 isUsedeskEnabled = feedbackFeatureToggles.isUsedeskEnabled
private val items: MutableStateFlow<ImmutableList<DetailsItemUM>>
val state: MutableStateFlow<DetailsUM>
@ -89,11 +93,9 @@ internal class DetailsModel @Inject constructor(
itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId,
onSupportEmailClick = ::sendFeedback,
onSupportChatClick = ::openUseDesk,
onSupportClick = ::onContactSupportClick,
onBuyClick = ::onBuyClick,
),
)
@ -108,6 +110,7 @@ internal class DetailsModel @Inject constructor(
appVersion = getAppVersion(),
),
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty,
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() {
modelScope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")

View file

@ -76,6 +76,7 @@ internal fun DetailsScreen(
}
SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig)
SelectContactSupportTypeBottomSheet(state.selectContactSupportTypeBSConfig)
}
@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(
isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId,
onSupportEmailClick: () -> Unit,
onSupportChatClick: () -> Unit,
onSupportClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList {
if (isAddressBookAvailable) {
@ -50,11 +48,7 @@ internal class ItemsBuilder @Inject constructor(
buildShopBlock(onBuyClick).let(::add)
buildSettingsBlock().let(::add)
buildSupportBlock(
onSupportEmailClick = onSupportEmailClick,
onSupportChatClick = onSupportChatClick,
isSupportChatAvailable = isSupportChatAvailable,
).let(::add)
buildSupportBlock(onSupportClick = onSupportClick).let(::add)
}.toImmutableList()
fun addTangemPayItem(items: ImmutableList<DetailsItemUM>, onClick: () -> Unit): ImmutableList<DetailsItemUM> {
@ -148,33 +142,18 @@ internal class ItemsBuilder @Inject constructor(
}.toImmutableList(),
)
private fun buildSupportBlock(
onSupportEmailClick: () -> Unit,
onSupportChatClick: () -> Unit,
isSupportChatAvailable: Boolean,
): DetailsItemUM = DetailsItemUM.Basic(
private fun buildSupportBlock(onSupportClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(
id = "support",
items = buildList {
DetailsItemUM.Basic.Item(
id = "support_email",
id = "contact_support",
block = BlockUM(
text = resourceReference(R.string.common_contact_support),
iconRes = R.drawable.ic_comment_24,
onClick = onSupportEmailClick,
onClick = onSupportClick,
),
).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(
id = "disclaimer",
block = BlockUM(

View file

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

View file

@ -65,24 +65,6 @@ internal class DetailsModelInitTest : DetailsModelTestBase() {
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
fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest {
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.core.analytics.models.Basic
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -17,17 +19,19 @@ import org.junit.jupiter.api.Test
internal class DetailsModelNavigationTest : DetailsModelTestBase() {
@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
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
// Act
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle()
// Assert
@ -36,20 +40,51 @@ internal class DetailsModelNavigationTest : DetailsModelTestBase() {
}
@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)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle()
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
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
fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest {
coEvery {

View file

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

View file

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

View file

@ -6,7 +6,8 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface UsedeskComponent : ComposableContentComponent {
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>

View file

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

View file

@ -1,11 +1,16 @@
package com.tangem.feature.usedesk
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -16,7 +21,7 @@ import com.tangem.feature.usedesk.model.UsedeskModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import ru.usedesk.chat_gui.chat.UsedeskChatScreen
import com.tangem.usedesk.chat_gui.chat.UsedeskChatScreen
internal class DefaultUsedeskComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@ -25,9 +30,11 @@ internal class DefaultUsedeskComponent @AssistedInject constructor(
private val model: UsedeskModel = getOrCreateModel(params)
@Suppress("NestedScopeFunctions")
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val configuration = state.usedeskChatConfiguration
Box(
modifier = Modifier
@ -35,21 +42,65 @@ internal class DefaultUsedeskComponent @AssistedInject constructor(
.systemBarsPadding()
.imePadding(),
) {
AndroidView(
factory = { context ->
FragmentContainerView(context).apply {
id = View.generateViewId()
// Show the chat only once the configuration is ready (clientId is loaded asynchronously).
if (configuration != null) {
val activity = LocalContext.current as? FragmentActivity ?: return@Box
val containerId = rememberSaveable { View.generateViewId() }
val fragment = UsedeskChatScreen.newInstance(
state.usedeskChatConfiguration,
)
(context as FragmentActivity).supportFragmentManager.beginTransaction()
.replace(id, fragment)
.commit()
AndroidView(
factory = { context ->
FragmentContainerView(context).apply {
id = containerId
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 {
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
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.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.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow
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
@Stable
@ModelScoped
internal class UsedeskModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedbackRepository: FeedbackRepository,
paramsContainer: ParamsContainer,
) : 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
private fun getInitialState(): UsedeskState {
return UsedeskState(
UsedeskChatConfiguration(
companyId = COMPANY_ID,
channelId = CHANNEL_ID,
),
// The chat screen opened/error event is sent only once.
private var isScreenLoadEventSent = false
init {
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 {
const val COMPANY_ID = "170509"
const val CHANNEL_ID = "65637"
const val URL_CHAT = "https://pubsub.tangem.org"
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
import androidx.compose.runtime.Stable
import ru.usedesk.chat_sdk.entity.UsedeskChatConfiguration
import com.tangem.usedesk.chat_sdk.entity.UsedeskChatConfiguration
@Stable
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"
markdown = "0.7.2"
markdownComposeView = "0.5.4"
usedesk = "4.4.0"
sumsub = "1.38.0"
haze = "1.7.2"
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" }
markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
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" }
haze = { module = "dev.chrisbanes.haze:haze", 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 ^
tangemHotSdk = "develop-550"
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^
tangemUsedeskSdk = "main-9"
#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^
[libraries]
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-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-m3 = { group = "com.tangem.vico", name = "compose-m3", 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-hot-sdk-kotlin")
includeGroupAndSubgroups("com.tangem.vico")
includeGroupAndSubgroups("com.tangem.usedesk")
includeModule("com.tangem", "blstlib")
includeModule("com.tangem", "blockchain")
includeModule("com.tangem", "wallet-core-proto")
@ -129,6 +130,15 @@ dependencyResolutionManagement {
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://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")
include(":app")