Updated on 2026-08-14

This commit is contained in:
Tangem 2020-09-07 09:58:40 +00:00
commit e7faca0cc1
23 changed files with 585 additions and 164 deletions

View file

@ -61,7 +61,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'com.google.android.material:material:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10'
implementation 'com.tangem:blockchain:1.28.0'

View file

@ -36,6 +36,9 @@ private val coroutineContext: CoroutineContext
get() = Job() + Dispatchers.IO + initCoroutineExceptionHandler()
val scope = CoroutineScope(coroutineContext)
private val mainCoroutineContext: CoroutineContext
get() = Job() + Dispatchers.Main
val mainScope = CoroutineScope(mainCoroutineContext)
private fun initCoroutineExceptionHandler(): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, throwable ->

View file

@ -0,0 +1,3 @@
package com.tangem.tap.common.extensions
fun Int.isEven() = this and 1 == 0

View file

@ -1,6 +1,7 @@
package com.tangem.tap.common.qrCodeScan
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@ -9,14 +10,17 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.zxing.Result
import com.otaliastudios.cameraview.CameraView.PERMISSION_REQUEST_CODE
import com.tangem.tap.features.send.redux.AddressPayIdActionUI
import com.tangem.tap.store
import me.dm7.barcodescanner.zxing.ZXingScannerView
/**
[REDACTED_AUTHOR]
*/
class ScanQrCodeActivity : AppCompatActivity(), ZXingScannerView.ResultHandler {
companion object {
val SCAN_QR_REQUEST_CODE = 1001
val SCAN_RESULT = "scanResult"
}
private lateinit var mScannerView: ZXingScannerView
override fun onCreate(state: Bundle?) {
@ -40,7 +44,7 @@ class ScanQrCodeActivity : AppCompatActivity(), ZXingScannerView.ResultHandler {
}
override fun handleResult(result: Result) {
store.dispatch(AddressPayIdActionUI.SetAddressOrPayId(result.text))
setResult(SCAN_QR_REQUEST_CODE, Intent().apply { putExtra(SCAN_RESULT, result.text) })
finish()
}

View file

@ -9,7 +9,7 @@ import timber.log.Timber
val logMiddleware: Middleware<AppState> = { dispatch, appState ->
{ nextDispatch ->
{ action ->
Timber.d("$action")
Timber.d("Dispatch action: $action")
nextDispatch(action)
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.tap.common.text
import android.widget.TextView
import com.tangem.tap.common.extensions.isEven
/**
[REDACTED_AUTHOR]
*/
enum class TruncateType {
START, MIDDLE, END
}
interface Truncate {
fun apply(tv: TextView, text: String, with: String): String
companion object {
fun create(type: TruncateType): Truncate {
return when (type) {
TruncateType.START -> TruncateStart()
TruncateType.MIDDLE -> TruncateMiddle()
TruncateType.END -> TruncateEnd()
}
}
}
}
abstract class BaseTruncate : Truncate {
protected var hasBeenTruncated = false
override fun apply(tv: TextView, text: String, with: String): String {
val roughLength = getRoughFitLength(tv, text, with)
val fittedText = preciseFitting(tv, roughTruncate(text, roughLength), with)
return if (hasBeenTruncated) attachWith(fittedText, with) else fittedText
}
protected fun getRoughFitLength(tv: TextView, text: String, with: String): Int {
val existingSpace = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd)
val textWillTakeSpace = tv.paint.measureText(text)
val overSizeRatio: Float = textWillTakeSpace / existingSpace
val maxLengthOfText = text.length / overSizeRatio
if (text.length <= maxLengthOfText) return text.length
return maxLengthOfText.toInt()
}
protected fun preciseFitting(tv: TextView, text: String, with: String): String {
if (!hasBeenTruncated) return text
val spaceForText = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd)
var fittedText = text
while (tv.paint.measureText(fittedText + with) > spaceForText) {
fittedText = preciseTruncate(fittedText)
}
return fittedText
}
protected abstract fun roughTruncate(text: String, residualLength: Int): String
protected abstract fun preciseTruncate(text: String): String
protected abstract fun attachWith(text: String, with: String): String
}
class TruncateStart : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
return text.substring(residualLength, text.length)
}
override fun preciseTruncate(text: String): String = text.substring(1, text.length)
override fun attachWith(text: String, with: String): String = with + text
}
class TruncateMiddle : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
val halfOfResidualLength = residualLength / 2
val leftSide = text.substring(0, halfOfResidualLength)
val rightSide = text.substring(text.length - halfOfResidualLength, text.length)
return leftSide + rightSide
}
override fun preciseTruncate(text: String): String {
val middlePosition = text.length / 2
return if (text.length.isEven()) {
val leftSide = text.substring(0, middlePosition - 1)
val rightSide = text.substring(middlePosition, text.length)
leftSide + rightSide
} else {
val leftSide = text.substring(0, middlePosition)
val rightSide = text.substring(middlePosition + 1, text.length)
leftSide + rightSide
}
}
override fun attachWith(text: String, with: String): String {
val cuttingPosition = text.length / 2
val leftSide = text.substring(0, cuttingPosition)
val rightSide = text.substring(cuttingPosition, text.length)
return leftSide + with + rightSide
}
}
class TruncateEnd : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
return text.substring(0, residualLength)
}
override fun preciseTruncate(text: String): String = text.substring(0, text.length - 1)
override fun attachWith(text: String, with: String): String = text + with
}
fun TextView.truncateWith(text: String, type: TruncateType, with: String = "..."): String {
val truncate = Truncate.create(type)
return truncate.apply(this, text, with)
}
fun TextView.truncateStartWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.START, with)
fun TextView.truncateMiddleWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.MIDDLE, with)
fun TextView.truncateEndWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.END, with)

View file

@ -3,7 +3,9 @@ package com.tangem.tap.domain
import com.tangem.blockchain.common.Blockchain
import com.tangem.commands.common.network.Result
import com.tangem.tap.network.payid.PayIdService
import com.tangem.tap.network.payid.PayIdVerifyService
import com.tangem.tap.network.payid.SetPayIdResponse
import com.tangem.tap.network.payid.VerifyPayIdResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
@ -41,13 +43,19 @@ class PayIdManager {
}
}
suspend fun verifyPayId(payId: String, blockchain: Blockchain): Result<VerifyPayIdResponse> = withContext(Dispatchers.IO) {
val splitPayId = payId.split("\$")
val user = splitPayId[0]
val baseUrl = "https://${splitPayId[1]}/"
return@withContext PayIdVerifyService(baseUrl).verifyAddress(user, blockchain.getPayIdNetwork())
}
private fun Blockchain.getPayIdNetwork(): String {
return when (this) {
Blockchain.XRP -> "XRPL"
Blockchain.RSK -> "RSK"
else -> this.currency
}
}.toLowerCase()
}
companion object {

View file

@ -1,13 +1,19 @@
package com.tangem.tap.features.send.redux
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.commands.common.network.Result
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.PayIdManager
import com.tangem.tap.domain.PayIdManager.Companion.isPayId
import com.tangem.tap.domain.isPayIdSupported
import com.tangem.tap.features.send.redux.AddressPayIdActionUI.SetAddressOrPayId
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.SetAddressOrPayId
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.*
import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.rekotlin.Action
import org.rekotlin.Middleware
@ -27,61 +33,85 @@ private fun handleSendAction(action: Action) {
val sendAction = action as? SendScreenActionUI ?: return
when (sendAction) {
is AddressPayIdActionUI -> {
is AddressPayIdActionUi -> {
when (sendAction) {
is SetAddressOrPayId -> AddressPayIdHandler().handle(sendAction.data?.toString())
is SetAddressOrPayId -> AddressPayIdHandler().handle(sendAction.data)
}
}
}
}
internal class AddressPayIdHandler {
fun handle(data: String?) {
fun handle(data: String) {
val walletManager = store.state.globalState.walletManager ?: return
val clipboardData = data ?: return
if (data == store.state.sendState.addressPayIdState.etFieldValue) return
if (PayIdManager.isPayId(clipboardData)) {
if (walletManager.wallet.blockchain.isPayIdSupported()) {
store.dispatch(AddressPayIdAction.Verification.PayIdNotSupportedByBlockchain)
} else {
scope.launch {
val response = verifyPayID(walletManager, clipboardData)
if (response == null) {
store.dispatch(AddressPayIdAction.Verification.Failed)
} else {
val address = "some address D:" // extract from response
store.dispatch(AddressPayIdAction.Verification.Success(address))
}
}
}
if (isPayId(data)) {
verifyPayId(data, walletManager)
} else {
val supposedAddress = extractAddressFromShareUri(data)
store.dispatch(PayIdVerification.SetError(supposedAddress, FailReason.IS_NOT_PAY_ID))
verifyAddress(walletManager, supposedAddress)
}
}
private suspend fun verifyPayID(walletManager: WalletManager, payID: String?): String? {
val cardId = walletManager.cardId
val publicKey = store.state.globalState.card?.cardPublicKey
private fun verifyPayId(payId: String, walletManager: WalletManager) {
val blockchain = walletManager.wallet.blockchain
if (!blockchain.isPayIdSupported()) {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN))
return
}
// val result = PayIdManager().getPayId(cardId, publicKey.toHexString())
// withContext(Dispatchers.Main) {
// when (result) {
// is Result.Success -> {
// val payId = result.data
// if (payId == null) {
// store.dispatch(WalletAction.LoadPayId.NotCreated)
// } else {
// store.dispatch(WalletAction.LoadPayId.Success(payId))
// }
// }
// is Result.Failure -> store.dispatch(WalletAction.LoadPayId.Failure)
// }
// }
return null
scope.launch {
val result = PayIdManager().verifyPayId(payId, blockchain)
withContext(Dispatchers.Main) {
when (result) {
is Result.Success -> {
val address = result.data.getAddress()
if (address == null) {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_NOT_REGISTERED))
return@withContext
}
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, address)
val actionToSend = if (failReason == FailReason.NONE) PayIdVerification.SetPayIdWalletAddress(payId, address)
else AddressVerification.SetError(payId, failReason)
store.dispatch(actionToSend)
}
is Result.Failure -> {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_REQUEST_FAILED))
}
}
}
}
}
private suspend fun verifyWalletAddress(payID: String?): Boolean {
val isRealAddress = true
return isRealAddress
private fun verifyAddress(walletManager: WalletManager, supposedAddress: String) {
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, supposedAddress)
val actionToSend = if (failReason == FailReason.NONE) AddressVerification.SetWalletAddress(supposedAddress)
else AddressVerification.SetError(supposedAddress, failReason)
store.dispatch(actionToSend)
}
private fun isValidBlockchainAddressAndNotTheSameAsWallet(wallet: Wallet, address: String): FailReason {
return if (wallet.blockchain.validateAddress(address)) {
if (wallet.address != address) {
FailReason.NONE
} else {
FailReason.ADDRESS_SAME_AS_WALLET
}
} else {
FailReason.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN
}
}
//TODO: move to the blockchainSDK
private fun extractAddressFromShareUri(shareUri: String): String {
val sharePrefix = listOf("bitcoin:", "ethereum:", "ripple:")
val prefixes = sharePrefix.filter { shareUri.contains(it) }
return if (prefixes.isEmpty()) shareUri
else shareUri.replace(prefixes[0], "")
}
}

View file

@ -1,8 +1,10 @@
package com.tangem.tap.features.send.redux
import android.view.View
import com.tangem.tap.features.send.redux.AddressPayIdActionUI.SetAddressOrPayId
import com.tangem.tap.features.send.redux.FeeActionUI.*
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.*
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.AddressVerification
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.PayIdVerification
import com.tangem.tap.features.send.redux.FeeActionUi.*
import org.rekotlin.Action
import org.rekotlin.StateType
import timber.log.Timber
@ -12,60 +14,67 @@ import timber.log.Timber
*/
class SendReducer {
companion object {
fun reduce(action: Action, sendState: SendState): SendState = internalReduce(action, sendState)
fun reduce(action: Action, sendState: SendState): SendState {
val newState = internalReduce(action, sendState)
if (newState == sendState) Timber.i("state didn't modified.")
else Timber.i("state was updated to: $newState.")
return newState
}
}
}
private fun internalReduce(action: Action, sendState: SendState): SendState {
if (action is ReleaseSendState) return SendState()
val sendAction = action as? SendScreenAction ?: return sendState
private fun internalReduce(incomingAction: Action, sendState: SendState): SendState {
if (incomingAction is ReleaseSendState) return SendState()
val action = incomingAction as? SendScreenAction ?: return sendState
return when (sendAction) {
is AddressPayIdActionUI -> handleAddressPayIdAction(sendAction, sendState, sendState.addressPayIDState)
is FeeActionUI -> handleFeeLayoutAction(sendAction, sendState, sendState.feeLayoutState)
return when (action) {
is AddressPayIdActionUi -> handleAddressPayIdActionUi(action, sendState, sendState.addressPayIdState)
is AddressPayIdVerifyAction -> handleAddressPayIdAction(action, sendState, sendState.addressPayIdState)
is FeeActionUi -> handleFeeActionUi(action, sendState, sendState.feeLayoutState)
else -> sendState
}
}
private fun handleAddressPayIdAction(
action: AddressPayIdActionUI,
fun handleAddressPayIdActionUi(
action: AddressPayIdActionUi,
sendState: SendState,
state: AddressPayIDState
state: AddressPayIdState
): SendState {
var state = state
when (action) {
is SetAddressOrPayId -> {
state = state.copy(value = action.data?.toString())
return updateLastState(sendState.copy(addressPayIDState = state), state)
val result = when (action) {
is SetAddressOrPayId -> state
is SetTruncateHandler -> state.copy(truncateHandler = action.handler)
is TruncateOrRestore -> {
if(action.truncate) state.copy(etFieldValue = state.truncatedFieldValue)
else state.copy(etFieldValue = state.normalFieldValue)
}
}
return sendState
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
private fun handleFeeLayoutAction(action: FeeActionUI, sendState: SendState, state: FeeLayoutState): SendState {
return when (action) {
private fun handleAddressPayIdAction(
action: AddressPayIdVerifyAction,
sendState: SendState,
state: AddressPayIdState
): SendState {
val result = when (action) {
is PayIdVerification.SetPayIdWalletAddress -> state.copyPayIdWalletAddress(action.payId, action.payIdWalletAddress)
is PayIdVerification.SetError -> state.copyPaiIdError(action.payId, action.reason)
is AddressVerification.SetWalletAddress -> state.copyWalletAddress(action.address)
is AddressVerification.SetError -> state.copyError(action.address, action.reason)
}
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
private fun handleFeeActionUi(action: FeeActionUi, sendState: SendState, state: FeeLayoutState): SendState {
val result = when (action) {
is ToggleFeeLayoutVisibility -> {
val visibility = if (state.visibility == View.VISIBLE) View.GONE
else View.VISIBLE
val result = state.copy(visibility = visibility)
updateLastState(sendState.copy(feeLayoutState = result), result)
}
is ChangeSelectedFee -> {
val result = state.copy(selectedFeeId = action.id)
updateLastState(sendState.copy(feeLayoutState = result), result)
}
is ChangeIncludeFee -> {
val result = state.copy(includeFeeIsChecked = action.isChecked)
updateLastState(sendState.copy(feeLayoutState = result), result)
state.copy(visibility = if (state.visibility == View.VISIBLE) View.GONE else View.VISIBLE)
}
is ChangeSelectedFee -> state.copy(selectedFeeId = action.id)
is ChangeIncludeFee -> state.copy(includeFeeIsChecked = action.isChecked)
}
return updateLastState(sendState.copy(feeLayoutState = result), result)
}
private fun updateLastState(sendState: SendState, state: StateType): SendState {
val sendState = sendState.copy(lastChangedStateType = state)
Timber.d("$sendState")
return sendState
}
private fun updateLastState(sendState: SendState, lastChangedState: StateType): SendState =
sendState.copy(lastChangedStateType = lastChangedState)

View file

@ -10,20 +10,37 @@ interface SendScreenActionUI : SendScreenAction
object ReleaseSendState : Action
sealed class FeeActionUI : SendScreenActionUI {
object ToggleFeeLayoutVisibility : FeeActionUI()
data class ChangeSelectedFee(val id: Int) : FeeActionUI()
class ChangeIncludeFee(val isChecked: Boolean) : FeeActionUI()
sealed class FeeActionUi : SendScreenActionUI {
object ToggleFeeLayoutVisibility : FeeActionUi()
data class ChangeSelectedFee(val id: Int) : FeeActionUi()
class ChangeIncludeFee(val isChecked: Boolean) : FeeActionUi()
}
sealed class AddressPayIdActionUI : SendScreenActionUI {
data class SetAddressOrPayId(val data: CharSequence?) : AddressPayIdActionUI()
// shortness AddressOrPayId = APid
sealed class AddressPayIdActionUi : SendScreenActionUI {
data class SetAddressOrPayId(val data: String) : AddressPayIdActionUi()
data class SetTruncateHandler(val handler: (String) -> String) : AddressPayIdActionUi()
data class TruncateOrRestore(val truncate: Boolean) : AddressPayIdActionUi()
}
sealed class AddressPayIdAction : SendScreenAction {
object Verification : AddressPayIdAction() {
object PayIdNotSupportedByBlockchain : AddressPayIdAction()
object Failed : AddressPayIdAction()
data class Success(val payIdWalletAddress: String) : AddressPayIdAction()
sealed class AddressPayIdVerifyAction : SendScreenAction {
enum class FailReason {
NONE,
IS_NOT_PAY_ID,
PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN,
PAY_ID_NOT_REGISTERED,
PAY_ID_REQUEST_FAILED,
ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN,
ADDRESS_SAME_AS_WALLET
}
sealed class PayIdVerification : AddressPayIdVerifyAction() {
data class SetError(val payId: String, val reason: FailReason) : PayIdVerification()
data class SetPayIdWalletAddress(val payId: String, val payIdWalletAddress: String) : PayIdVerification()
}
sealed class AddressVerification : AddressPayIdVerifyAction() {
data class SetError(val address: String, val reason: FailReason) : AddressVerification()
data class SetWalletAddress(val address: String) : AddressVerification()
}
}

View file

@ -11,17 +11,67 @@ import org.rekotlin.StateType
data class SendState(
val walletManager: WalletManager? = null,
val lastChangedStateType: StateType = NoneState(),
val addressPayIDState: AddressPayIDState = AddressPayIDState(),
val addressPayIdState: AddressPayIdState = AddressPayIdState(),
val feeLayoutState: FeeLayoutState = FeeLayoutState()
) : StateType
class NoneState : StateType
data class AddressPayIDState(
val value: String? = null,
val payIDWalletAddress: String? = null,
val error: String? = null,
) : StateType
data class AddressPayIdState(
val etFieldValue: String? = null,
val normalFieldValue: String? = null,
val truncatedFieldValue: String? = null,
val walletAddress: String? = null,
val error: AddressPayIdVerifyAction.FailReason? = null,
val truncateHandler: ((String) -> String)? = null
) : StateType {
fun isPayIdState(): Boolean = walletAddress != null && walletAddress != normalFieldValue
fun copyWalletAddress(address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
walletAddress = address,
error = null
)
}
fun copyError(address: String, error: AddressPayIdVerifyAction.FailReason): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
error = error,
walletAddress = null
)
}
fun copyPayIdWalletAddress(payId: String, address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
walletAddress = address,
error = null
)
}
fun copyPaiIdError(payId: String, error: AddressPayIdVerifyAction.FailReason): AddressPayIdState {
val truncated = truncateHandler?.invoke(payId) ?: payId
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
error = error,
walletAddress = null
)
}
}
data class FeeLayoutState(
val visibility: Int = View.GONE,

View file

@ -3,19 +3,26 @@ package com.tangem.tap.features.send.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.EditText
import androidx.core.widget.addTextChangedListener
import com.tangem.tap.common.extensions.getFromClipboard
import com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity
import com.tangem.tap.common.text.truncateMiddleWith
import com.tangem.tap.features.send.BaseStoreFragment
import com.tangem.tap.features.send.redux.AddressPayIdActionUI.SetAddressOrPayId
import com.tangem.tap.features.send.redux.FeeActionUI.*
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.*
import com.tangem.tap.features.send.redux.FeeActionUi.*
import com.tangem.tap.features.send.redux.ReleaseSendState
import com.tangem.tap.features.send.ui.stateSubscribers.SendStateSubscriber
import com.tangem.tap.features.send.ui.stateSubscribers.WalletStateSubscriber
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.btn_paste.*
import kotlinx.android.synthetic.main.btn_qr_code.*
import kotlinx.android.synthetic.main.layout_send_address_payid.*
import kotlinx.android.synthetic.main.layout_send_network_fee.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
/**
[REDACTED_AUTHOR]
@ -23,11 +30,47 @@ import kotlinx.android.synthetic.main.layout_send_network_fee.*
class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
private val sendSubscriber = SendStateSubscriber(this)
private val walletSubscriber = WalletStateSubscriber(this)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupAddressOrPayIdLayout()
setupFeeLayout()
}
private fun setupAddressOrPayIdLayout() {
store.dispatch(SetTruncateHandler { etAddressOrPayId.truncateMiddleWith(it, " *** ") })
etAddressOrPayId.setOnFocusChangeListener { v, hasFocus ->
store.dispatch(TruncateOrRestore(!hasFocus))
}
etAddressOrPayId.inputedTextAsFlow()
.debounce(400)
.filter { store.state.sendState.addressPayIdState.etFieldValue != it }
.onEach { store.dispatch(SetAddressOrPayId(it)) }
.launchIn(mainScope)
imvPaste.setOnClickListener {
store.dispatch(SetAddressOrPayId(requireContext().getFromClipboard()?.toString() ?: ""))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
}
imvQrCode.setOnClickListener {
startActivityForResult(
Intent(requireContext(), ScanQrCodeActivity::class.java),
ScanQrCodeActivity.SCAN_QR_REQUEST_CODE
)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != ScanQrCodeActivity.SCAN_QR_REQUEST_CODE) return
val scannedCode = data?.getStringExtra(ScanQrCodeActivity.SCAN_RESULT) ?: ""
store.dispatch(SetAddressOrPayId(scannedCode))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
}
private fun setupFeeLayout() {
flExpandCollapse.setOnClickListener {
store.dispatch(ToggleFeeLayoutVisibility)
}
@ -37,24 +80,13 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
swIncludeFee.setOnCheckedChangeListener { btn, isChecked ->
store.dispatch(ChangeIncludeFee(isChecked))
}
imvPaste.setOnClickListener {
store.dispatch(SetAddressOrPayId(requireContext().getFromClipboard()))
}
imvQrCode.setOnClickListener {
requireActivity().startActivity(Intent(requireContext(), ScanQrCodeActivity::class.java))
}
}
override fun subscribeToStore() {
store.subscribe(walletSubscriber) { appState ->
appState.skipRepeats { oldState, newState -> false }.select { it.walletState }
}
store.subscribe(sendSubscriber) { appState ->
appState.skipRepeats { oldState, newState -> false }.select { it.sendState }
appState.skipRepeats { oldState, newState -> oldState == newState }.select { it.sendState }
}
storeSubscribersList.add(walletSubscriber)
storeSubscribersList.add(sendSubscriber)
}
@ -64,5 +96,11 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
}
}
@ExperimentalCoroutinesApi
fun EditText.inputedTextAsFlow(): Flow<String> = callbackFlow {
val watcher = addTextChangedListener { editable -> offer(editable?.toString() ?: "") }
awaitClose { removeTextChangedListener(watcher) }
}

View file

@ -1,11 +1,14 @@
package com.tangem.tap.features.send.ui.stateSubscribers
import android.content.Context
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.transition.TransitionManager
import com.tangem.tap.features.send.redux.AddressPayIDState
import com.tangem.tap.features.send.redux.AddressPayIdState
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.FailReason
import com.tangem.tap.features.send.redux.FeeLayoutState
import com.tangem.tap.features.send.redux.SendState
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.btn_expand_collapse.*
import kotlinx.android.synthetic.main.layout_send_address_payid.*
import kotlinx.android.synthetic.main.layout_send_network_fee.*
@ -18,12 +21,45 @@ class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendStat
override fun updateWithNewState(fg: Fragment, state: SendState) {
when (state.lastChangedStateType) {
is FeeLayoutState -> handleFeeLayoutState(fg, state.feeLayoutState)
is AddressPayIDState -> handleAddressPayIdState(fg, state.addressPayIDState)
is AddressPayIdState -> handleAddressPayIdState(fg, state.addressPayIdState)
}
}
private fun handleAddressPayIdState(fg: Fragment, state: AddressPayIDState) {
fg.etAddressOrPayId.setText(state.value)
private fun handleAddressPayIdState(fg: Fragment, state: AddressPayIdState) {
fun parseError(context: Context, error: FailReason?): String? {
val resId = when (error) {
FailReason.IS_NOT_PAY_ID -> R.string.error_payid_verification_failed
FailReason.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_payid_unsupported_by_blockchain
FailReason.PAY_ID_NOT_REGISTERED -> R.string.error_payid_not_registere
FailReason.PAY_ID_REQUEST_FAILED -> R.string.error_payid_request_failed
FailReason.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_address_invalid_or_unsupported
FailReason.ADDRESS_SAME_AS_WALLET -> R.string.error_address_same_as_wallet
else -> null
}
return if (resId == null) null
else context.getString(resId)
}
val et = fg.etAddressOrPayId
val til = fg.tilAddressOrPayId
val parsedError = parseError(til.context, state.error)
til.error = parsedError
til.isErrorEnabled = parsedError != null
til.helperText = state.walletAddress
til.isHelperTextEnabled = state.isPayIdState() && parsedError == null
// prevent cycling
if (state.etFieldValue == null || et.text?.toString() == state.etFieldValue) return
// prevent cursor jumping while editing
if (et.isFocused) {
val prevSelection = et.selectionStart
et.setText(state.etFieldValue)
et.setSelection(prevSelection)
} else {
et.setText(state.etFieldValue)
}
}
private fun handleFeeLayoutState(fg: Fragment, layoutState: FeeLayoutState) {

View file

@ -1,13 +0,0 @@
package com.tangem.tap.features.send.ui.stateSubscribers
import androidx.fragment.app.Fragment
import com.tangem.tap.features.wallet.redux.WalletState
/**
[REDACTED_AUTHOR]
*/
class WalletStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<WalletState>(fragment) {
override fun updateWithNewState(fg: Fragment, state: WalletState) {
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.tap.network.payid
import com.squareup.moshi.JsonClass
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
/**
[REDACTED_AUTHOR]
*/
interface PayIdVerifyApi {
@GET("{user}")
suspend fun verifyAddress(
@Path("user") user: String,
@Header("Accept") acceptNetworkHeader: String,
@Header("PayID-Version") payIdVersion: String = "1.0"
): VerifyPayIdResponse
}
@JsonClass(generateAdapter = true)
data class VerifyPayIdResponse(
val addresses: List<PayIdAddress> = mutableListOf(),
val payId: String? = null,
) {
fun getAddress(): String? {
return if (addresses.isEmpty()) null
else addresses[0].addressDetails.address
}
}
@JsonClass(generateAdapter = true)
data class PayIdAddress(
var paymentNetwork: String,
var environment: String,
var addressDetailsType: String,
var addressDetails: PayIdAddressDetails
)
@JsonClass(generateAdapter = true)
data class PayIdAddressDetails(
var address: String,
var tag: String? = null
)

View file

@ -0,0 +1,21 @@
package com.tangem.tap.network.payid
import com.tangem.commands.common.network.Result
import com.tangem.commands.common.network.performRequest
import com.tangem.tap.network.createRetrofitInstance
/**
[REDACTED_AUTHOR]
*/
class PayIdVerifyService(
private val baseUrl: String
) {
private val api = createRetrofitInstance(baseUrl).create(PayIdVerifyApi::class.java)
suspend fun verifyAddress(user: String, network: String): Result<VerifyPayIdResponse> {
return performRequest { api.verifyAddress(user, createNetworkHeader(network)) }
}
private fun createNetworkHeader(network: String): String = "application/$network-mainnet+json"
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_longAnimTime"
android:fromYDelta="100%p"
android:toYDelta="0" />

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_longAnimTime"
android:fromYDelta="0"
android:toYDelta="100%p" />

View file

@ -4,7 +4,6 @@
android:id="@+id/flArrowUpDown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:background="@drawable/shape_ellipse"
android:backgroundTint="@android:color/transparent"
android:clickable="true"

View file

@ -31,10 +31,10 @@
android:layout_marginTop="14dp" />
<include
layout="@layout/layout_send_currency"
layout="@layout/layout_send_amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
android:layout_marginTop="4dp" />
<include
android:id="@+id/clNetworkFee"

View file

@ -2,17 +2,18 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAddressOrPayId"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/send_address_or_payid"
app:boxBackgroundColor="@android:color/transparent"
app:errorIconDrawable="@null"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -23,7 +24,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:paddingStart="0dp"
android:paddingEnd="96dp"
android:paddingEnd="98dp"
android:singleLine="true"
android:textSize="16sp"
tools:text="139mrsJgyWnJ **** y9BV" />
@ -45,7 +46,7 @@
layout="@layout/btn_qr_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/tilAddressOrPayId"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/tilAddressOrPayId" />

View file

@ -26,34 +26,48 @@
android:inputType="numberDecimal"
android:paddingStart="0dp"
android:paddingEnd="96dp"
android:text="0"
android:textSize="32sp"
tools:text="139" />
</com.google.android.material.textfield.TextInputLayout>
<include
android:id="@+id/flArrowUpDown"
layout="@layout/btn_arrow_up_down"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="@+id/tvCurrency"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
<TextSwitcher
android:id="@+id/tvCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:layout_weight="1"
android:focusableInTouchMode="true"
android:fontFamily="sans-serif-light"
android:textAllCaps="true"
android:textColor="@color/blue"
android:textSize="32sp"
android:inAnimation="@anim/slide_in_up"
android:outAnimation="@anim/slide_out_down"
app:layout_constraintBottom_toBottomOf="@+id/tilAmount"
app:layout_constraintEnd_toStartOf="@+id/flArrowUpDown"
tools:text="usd" />
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:drawablePadding="10dp"
android:fontFamily="sans-serif-light"
android:text="usd"
android:textAllCaps="true"
android:textColor="@color/blue"
android:textSize="32sp"
app:drawableEndCompat="@drawable/ic_arrows_up_down" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:drawablePadding="10dp"
android:fontFamily="sans-serif-light"
android:text="btc"
android:textAllCaps="true"
android:textColor="@color/blue"
android:textSize="32sp"
app:drawableEndCompat="@drawable/ic_arrows_up_down" />
</TextSwitcher>
<LinearLayout
android:id="@+id/balanceContainer"

View file

@ -35,6 +35,13 @@
<string name="error_payid_already_created">This PayID already exists. Try a different one.</string>
<string name="error_creating_payid">Error response while creating PayID.</string>
<string name="error_payid_verification_failed">PayID verification failed</string>
<string name="error_payid_unsupported_by_blockchain">PayID unsupported by blockchain</string>
<string name="error_payid_not_registere">PayID not registered</string>
<string name="error_payid_request_failed">PayID request failed</string>
<string name="error_address_invalid_or_unsupported">Address is invalid or unsupported by blockchain</string>
<string name="error_address_same_as_wallet">Address is the same as wallet address</string>
<string name="send_title">Send</string>
<string name="send_address_or_payid">Address or PayID</string>