Updated on 2026-08-14
This commit is contained in:
parent
a298e77060
commit
da98a6afb2
12 changed files with 317 additions and 4 deletions
|
|
@ -3,6 +3,7 @@ package com.tangem.data.feedback
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
import com.tangem.data.feedback.converters.BlockchainInfoConverter
|
import com.tangem.data.feedback.converters.BlockchainInfoConverter
|
||||||
import com.tangem.data.feedback.converters.CardInfoConverter
|
import com.tangem.data.feedback.converters.CardInfoConverter
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
|
@ -13,6 +14,9 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||||
import com.tangem.domain.feedback.models.*
|
import com.tangem.domain.feedback.models.*
|
||||||
import com.tangem.domain.feedback.repository.FeedbackRepository
|
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||||
import com.tangem.domain.wallets.models.UserWallet
|
import com.tangem.domain.wallets.models.UserWallet
|
||||||
|
import com.tangem.domain.wallets.models.UserWalletId
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileWriter
|
import java.io.FileWriter
|
||||||
|
|
@ -35,6 +39,8 @@ internal class DefaultFeedbackRepository(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) : FeedbackRepository {
|
) : FeedbackRepository {
|
||||||
|
|
||||||
|
private val blockchainsErrors = MutableStateFlow<Map<UserWalletId, BlockchainErrorInfo>>(emptyMap())
|
||||||
|
|
||||||
override suspend fun getUserWalletsInfo(): UserWalletsInfo {
|
override suspend fun getUserWalletsInfo(): UserWalletsInfo {
|
||||||
return UserWalletsInfo(
|
return UserWalletsInfo(
|
||||||
selectedUserWalletId = getSelectedUserWallet().walletId.stringValue,
|
selectedUserWalletId = getSelectedUserWallet().walletId.stringValue,
|
||||||
|
|
@ -52,6 +58,16 @@ internal class DefaultFeedbackRepository(
|
||||||
.map(BlockchainInfoConverter::convert)
|
.map(BlockchainInfoConverter::convert)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBlockchainInfo(blockchainId: String, derivationPath: String?): BlockchainInfo? {
|
||||||
|
return walletManagersStore
|
||||||
|
.getSyncOrNull(
|
||||||
|
userWalletId = getSelectedUserWallet().walletId,
|
||||||
|
blockchain = Blockchain.fromId(blockchainId),
|
||||||
|
derivationPath = derivationPath,
|
||||||
|
)
|
||||||
|
?.let(BlockchainInfoConverter::convert)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getPhoneInfo(): PhoneInfo {
|
override fun getPhoneInfo(): PhoneInfo {
|
||||||
return PhoneInfo(
|
return PhoneInfo(
|
||||||
phoneModel = Build.MODEL,
|
phoneModel = Build.MODEL,
|
||||||
|
|
@ -60,6 +76,22 @@ internal class DefaultFeedbackRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) {
|
||||||
|
blockchainsErrors.update {
|
||||||
|
it.toMutableMap().apply {
|
||||||
|
put(getSelectedUserWallet().walletId, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBlockchainErrorInfo(): BlockchainErrorInfo? {
|
||||||
|
return blockchainsErrors.value[getSelectedUserWallet().walletId].also {
|
||||||
|
if (it == null) {
|
||||||
|
Timber.e("Blockchain error info is null for ${getSelectedUserWallet().walletId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAppLogs(): List<AppLogModel> {
|
override suspend fun getAppLogs(): List<AppLogModel> {
|
||||||
return appPreferencesStore.getObjectMap<String>(key = PreferencesKeys.APP_LOGS_KEY)
|
return appPreferencesStore.getObjectMap<String>(key = PreferencesKeys.APP_LOGS_KEY)
|
||||||
.map { AppLogModel(timestamp = it.key.toLong(), message = it.value) }
|
.map { AppLogModel(timestamp = it.key.toLong(), message = it.value) }
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ dependencies {
|
||||||
implementation(deps.jodatime)
|
implementation(deps.jodatime)
|
||||||
|
|
||||||
implementation(projects.core.res)
|
implementation(projects.core.res)
|
||||||
|
implementation(projects.domain.wallets.models)
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
package com.tangem.domain.feedback
|
package com.tangem.domain.feedback
|
||||||
|
|
||||||
import com.tangem.domain.feedback.models.BlockchainInfo
|
import com.tangem.domain.feedback.models.*
|
||||||
import com.tangem.domain.feedback.models.CardInfo
|
|
||||||
import com.tangem.domain.feedback.models.PhoneInfo
|
|
||||||
import com.tangem.domain.feedback.models.UserWalletsInfo
|
|
||||||
import com.tangem.domain.feedback.utils.breakLine
|
import com.tangem.domain.feedback.utils.breakLine
|
||||||
import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses
|
import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses
|
||||||
|
|
||||||
|
|
@ -68,6 +65,24 @@ internal class FeedbackDataBuilder {
|
||||||
builder.appendKeyValue("App version", phoneInfo.appVersion)
|
builder.appendKeyValue("App version", phoneInfo.appVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addBlockchainError(info: BlockchainInfo, error: BlockchainErrorInfo) {
|
||||||
|
builder.appendKeyValue("Blockchain", info.blockchain)
|
||||||
|
builder.appendKeyValue("Derivation path", info.derivationPath)
|
||||||
|
builder.appendKeyValue("Host", info.host)
|
||||||
|
builder.appendKeyValue("Token", error.tokenSymbol)
|
||||||
|
builder.appendKeyValue("Error", error.errorMessage)
|
||||||
|
|
||||||
|
builder.appendDelimiter()
|
||||||
|
|
||||||
|
builder.appendAddresses(
|
||||||
|
key = "Source address${info.addresses.isMultiple(suffix = "es")}",
|
||||||
|
addresses = info.addresses,
|
||||||
|
)
|
||||||
|
builder.appendKeyValue("Destination address", error.destinationAddress)
|
||||||
|
builder.appendKeyValue("Amount", error.amount)
|
||||||
|
builder.appendKeyValue("Fee", error.fee ?: "Unable to receive")
|
||||||
|
}
|
||||||
|
|
||||||
fun addDelimiter(): StringBuilder = builder.appendDelimiter()
|
fun addDelimiter(): StringBuilder = builder.appendDelimiter()
|
||||||
|
|
||||||
fun build(): String = builder.trimEnd().toString()
|
fun build(): String = builder.trimEnd().toString()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.domain.feedback
|
||||||
|
|
||||||
|
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
||||||
|
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save last blockchain error
|
||||||
|
*
|
||||||
|
* @property feedbackRepository feedback repository
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
class SaveBlockchainErrorUseCase(
|
||||||
|
private val feedbackRepository: FeedbackRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun invoke(error: BlockchainErrorInfo) {
|
||||||
|
feedbackRepository.saveBlockchainErrorInfo(error = error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.tangem.domain.feedback.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about blockchain's operation error
|
||||||
|
*
|
||||||
|
* @property errorMessage message about error
|
||||||
|
* @property blockchainId blockchain id
|
||||||
|
* @property derivationPath derivation path
|
||||||
|
* @property destinationAddress destination address
|
||||||
|
* @property tokenSymbol token symbol or null, if it isn't operation with token
|
||||||
|
* @property amount amount
|
||||||
|
* @property fee fee or null, if unable to get
|
||||||
|
*/
|
||||||
|
data class BlockchainErrorInfo(
|
||||||
|
val errorMessage: String,
|
||||||
|
val blockchainId: String,
|
||||||
|
val derivationPath: String?,
|
||||||
|
val destinationAddress: String,
|
||||||
|
val tokenSymbol: String?,
|
||||||
|
val amount: String,
|
||||||
|
val fee: String?,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.tangem.domain.feedback.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email feedback type
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
sealed interface FeedbackEmailType {
|
||||||
|
|
||||||
|
/** User initiate request yourself. Example, button on DetailsScreen or OnboardingScreen */
|
||||||
|
data object DirectUserRequest : FeedbackEmailType
|
||||||
|
|
||||||
|
/** User rate the app as "can be better" */
|
||||||
|
data object RateCanBeBetter : FeedbackEmailType
|
||||||
|
|
||||||
|
/** User has problem with scanning */
|
||||||
|
data object ScanningProblem : FeedbackEmailType
|
||||||
|
|
||||||
|
/** User has problem with sending transaction */
|
||||||
|
data object TransactionSendingProblem : FeedbackEmailType
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,14 @@ interface FeedbackRepository {
|
||||||
|
|
||||||
suspend fun getBlockchainInfoList(): List<BlockchainInfo>
|
suspend fun getBlockchainInfoList(): List<BlockchainInfo>
|
||||||
|
|
||||||
|
suspend fun getBlockchainInfo(blockchainId: String, derivationPath: String?): BlockchainInfo?
|
||||||
|
|
||||||
fun getPhoneInfo(): PhoneInfo
|
fun getPhoneInfo(): PhoneInfo
|
||||||
|
|
||||||
|
fun saveBlockchainErrorInfo(error: BlockchainErrorInfo)
|
||||||
|
|
||||||
|
suspend fun getBlockchainErrorInfo(): BlockchainErrorInfo?
|
||||||
|
|
||||||
suspend fun getAppLogs(): List<AppLogModel>
|
suspend fun getAppLogs(): List<AppLogModel>
|
||||||
|
|
||||||
suspend fun createLogFile(logs: String): File?
|
suspend fun createLogFile(logs: String): File?
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.tangem.domain.feedback.utils
|
||||||
|
|
||||||
|
import com.tangem.domain.feedback.models.AppLogModel
|
||||||
|
import org.joda.time.DateTime
|
||||||
|
import org.joda.time.format.DateTimeFormatter
|
||||||
|
import org.joda.time.format.DateTimeFormatterBuilder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App logs formatter
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class AppLogsFormatter {
|
||||||
|
|
||||||
|
private val dateFormatter = createDateFormatter()
|
||||||
|
|
||||||
|
/** Format [appLogs] to [String] */
|
||||||
|
fun format(appLogs: List<AppLogModel>): String {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
|
||||||
|
var sum = 0
|
||||||
|
for (i in appLogs.lastIndex downTo 0) {
|
||||||
|
val log = appLogs[i]
|
||||||
|
val date = dateFormatter.print(DateTime(log.timestamp))
|
||||||
|
|
||||||
|
val formattedLog = "$date: ${log.message}\n"
|
||||||
|
|
||||||
|
sum += formattedLog.length
|
||||||
|
if (sum < GMAIL_MAX_FILE_SIZE) {
|
||||||
|
builder.insert(0, formattedLog)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example, 00.00 00:00:00.000
|
||||||
|
private fun createDateFormatter(): DateTimeFormatter {
|
||||||
|
return DateTimeFormatterBuilder()
|
||||||
|
.appendDayOfMonth(2)
|
||||||
|
.appendLiteral('.')
|
||||||
|
.appendMonthOfYear(2)
|
||||||
|
.appendLiteral(' ')
|
||||||
|
.appendHourOfDay(2)
|
||||||
|
.appendLiteral(':')
|
||||||
|
.appendMinuteOfHour(2)
|
||||||
|
.appendLiteral(':')
|
||||||
|
.appendSecondOfMinute(2)
|
||||||
|
.appendLiteral('.')
|
||||||
|
.appendMillisOfSecond(MIN_MILLIS_DIGITS)
|
||||||
|
.toFormatter()
|
||||||
|
.withLocale(Locale.getDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_MILLIS_DIGITS = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.tangem.domain.feedback.utils
|
||||||
|
|
||||||
|
import com.tangem.domain.feedback.FeedbackDataBuilder
|
||||||
|
import com.tangem.domain.feedback.models.CardInfo
|
||||||
|
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||||
|
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email message body resolver
|
||||||
|
*
|
||||||
|
* @property feedbackRepository feedback repository
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class EmailMessageBodyResolver(
|
||||||
|
private val feedbackRepository: FeedbackRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Resolve email message body by [type] using [cardInfo] */
|
||||||
|
suspend fun resolve(type: FeedbackEmailType, cardInfo: CardInfo): String = with(FeedbackDataBuilder()) {
|
||||||
|
when (type) {
|
||||||
|
FeedbackEmailType.DirectUserRequest -> addUserRequestBody(cardInfo)
|
||||||
|
FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(cardInfo)
|
||||||
|
FeedbackEmailType.ScanningProblem -> addScanningProblemBody()
|
||||||
|
FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(cardInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) {
|
||||||
|
addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo())
|
||||||
|
addDelimiter()
|
||||||
|
addCardInfo(cardInfo)
|
||||||
|
addDelimiter()
|
||||||
|
addBlockchainInfoList(blockchainInfoList = feedbackRepository.getBlockchainInfoList())
|
||||||
|
addDelimiter()
|
||||||
|
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FeedbackDataBuilder.addScanningProblemBody() {
|
||||||
|
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun FeedbackDataBuilder.addTransactionSendingProblemBody(cardInfo: CardInfo) {
|
||||||
|
addCardInfo(cardInfo)
|
||||||
|
addDelimiter()
|
||||||
|
|
||||||
|
val blockchainError = feedbackRepository.getBlockchainErrorInfo()
|
||||||
|
val blockchainInfo = blockchainError?.let {
|
||||||
|
feedbackRepository.getBlockchainInfo(
|
||||||
|
blockchainId = blockchainError.blockchainId,
|
||||||
|
derivationPath = blockchainError.derivationPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockchainInfo != null) {
|
||||||
|
addBlockchainError(blockchainInfo, blockchainError)
|
||||||
|
addDelimiter()
|
||||||
|
}
|
||||||
|
|
||||||
|
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FeedbackDataBuilder.addCardAndPhoneInfo(cardInfo: CardInfo) {
|
||||||
|
addCardInfo(cardInfo)
|
||||||
|
addDelimiter()
|
||||||
|
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.tangem.domain.feedback.utils
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import com.tangem.domain.feedback.R
|
||||||
|
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email message title resolver
|
||||||
|
*
|
||||||
|
* @property resources resources
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class EmailMessageTitleResolver(private val resources: Resources) {
|
||||||
|
|
||||||
|
/** Resolve email message title by [type] */
|
||||||
|
fun resolve(type: FeedbackEmailType): String {
|
||||||
|
return when (type) {
|
||||||
|
FeedbackEmailType.DirectUserRequest -> R.string.feedback_preface_support
|
||||||
|
FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative
|
||||||
|
FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed
|
||||||
|
FeedbackEmailType.TransactionSendingProblem -> R.string.feedback_preface_tx_failed
|
||||||
|
}
|
||||||
|
.let(resources::getString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.tangem.domain.feedback.utils
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import com.tangem.domain.feedback.R
|
||||||
|
import com.tangem.domain.feedback.models.CardInfo
|
||||||
|
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email subject resolver
|
||||||
|
*
|
||||||
|
* @property resources resources
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class EmailSubjectResolver(private val resources: Resources) {
|
||||||
|
|
||||||
|
/** Resolve email message body by [type] using [cardInfo] */
|
||||||
|
fun resolve(type: FeedbackEmailType, cardInfo: CardInfo): String {
|
||||||
|
return when (type) {
|
||||||
|
FeedbackEmailType.DirectUserRequest -> {
|
||||||
|
if (cardInfo.isStart2Coin) {
|
||||||
|
R.string.feedback_subject_support
|
||||||
|
} else {
|
||||||
|
R.string.feedback_subject_support_tangem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FeedbackEmailType.RateCanBeBetter -> R.string.feedback_subject_rate_negative
|
||||||
|
FeedbackEmailType.ScanningProblem -> R.string.feedback_subject_scan_failed
|
||||||
|
FeedbackEmailType.TransactionSendingProblem -> R.string.feedback_subject_tx_failed
|
||||||
|
}
|
||||||
|
.let(resources::getString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.tangem.domain.feedback.utils
|
||||||
|
|
||||||
|
internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB
|
||||||
|
|
||||||
|
internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com"
|
||||||
|
internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue