diff --git a/app/build.gradle b/app/build.gradle index 48aa374b87..09362acc3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index b2947e0c13..9b24821ade 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -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 -> diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Int.kt b/app/src/main/java/com/tangem/tap/common/extensions/Int.kt new file mode 100644 index 0000000000..555dc252bd --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/extensions/Int.kt @@ -0,0 +1,3 @@ +package com.tangem.tap.common.extensions + +fun Int.isEven() = this and 1 == 0 \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/qrCodeScan/ScanQrCodeActivity.kt b/app/src/main/java/com/tangem/tap/common/qrCodeScan/ScanQrCodeActivity.kt index b350c9ebe4..da69eed9ae 100644 --- a/app/src/main/java/com/tangem/tap/common/qrCodeScan/ScanQrCodeActivity.kt +++ b/app/src/main/java/com/tangem/tap/common/qrCodeScan/ScanQrCodeActivity.kt @@ -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() } diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt index 9e7276f9bd..15ea778fed 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt @@ -9,7 +9,7 @@ import timber.log.Timber val logMiddleware: Middleware = { dispatch, appState -> { nextDispatch -> { action -> - Timber.d("$action") + Timber.d("Dispatch action: $action") nextDispatch(action) } } diff --git a/app/src/main/java/com/tangem/tap/common/text/Truncate.kt b/app/src/main/java/com/tangem/tap/common/text/Truncate.kt new file mode 100644 index 0000000000..3ed50ca1d2 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/text/Truncate.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/PayIdManager.kt b/app/src/main/java/com/tangem/tap/domain/PayIdManager.kt index 235f0ea37a..ddf6c99669 100644 --- a/app/src/main/java/com/tangem/tap/domain/PayIdManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/PayIdManager.kt @@ -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 = 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 { diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/SendMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/SendMiddleware.kt index f734d0816c..9f07a09224 100644 --- a/app/src/main/java/com/tangem/tap/features/send/redux/SendMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/send/redux/SendMiddleware.kt @@ -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], "") } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/SendReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/SendReducer.kt index 67a2f55308..0ec3afea23 100644 --- a/app/src/main/java/com/tangem/tap/features/send/redux/SendReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/send/redux/SendReducer.kt @@ -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) diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt b/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt index 6e0075913b..d6f5950ea7 100644 --- a/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt +++ b/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/SendState.kt b/app/src/main/java/com/tangem/tap/features/send/redux/SendState.kt index 70036db005..4fde717e85 100644 --- a/app/src/main/java/com/tangem/tap/features/send/redux/SendState.kt +++ b/app/src/main/java/com/tangem/tap/features/send/redux/SendState.kt @@ -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, diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt b/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt index 1879d895a8..ec3e9b7b42 100644 --- a/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt @@ -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 = callbackFlow { + val watcher = addTextChangedListener { editable -> offer(editable?.toString() ?: "") } + awaitClose { removeTextChangedListener(watcher) } +} + diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt b/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt index d86fe32991..1027616f81 100644 --- a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt +++ b/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt @@ -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 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) { diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/WalletStateSubscriber.kt b/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/WalletStateSubscriber.kt deleted file mode 100644 index 980f39ba73..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/WalletStateSubscriber.kt +++ /dev/null @@ -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(fragment) { - override fun updateWithNewState(fg: Fragment, state: WalletState) { - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyApi.kt b/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyApi.kt new file mode 100644 index 0000000000..e026dd8b11 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyApi.kt @@ -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 = 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 +) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyService.kt b/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyService.kt new file mode 100644 index 0000000000..109dc5e97f --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/payid/PayIdVerifyService.kt @@ -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 { + return performRequest { api.verifyAddress(user, createNetworkHeader(network)) } + } + + private fun createNetworkHeader(network: String): String = "application/$network-mainnet+json" +} \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_up.xml b/app/src/main/res/anim/slide_in_up.xml new file mode 100644 index 0000000000..a821c2d9c6 --- /dev/null +++ b/app/src/main/res/anim/slide_in_up.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/anim/slide_out_down.xml b/app/src/main/res/anim/slide_out_down.xml new file mode 100644 index 0000000000..f6095c0a17 --- /dev/null +++ b/app/src/main/res/anim/slide_out_down.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/layout/btn_arrow_up_down.xml b/app/src/main/res/layout/btn_arrow_up_down.xml index 1aedcead47..6f9c5f9bc0 100644 --- a/app/src/main/res/layout/btn_arrow_up_down.xml +++ b/app/src/main/res/layout/btn_arrow_up_down.xml @@ -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" diff --git a/app/src/main/res/layout/fragment_send.xml b/app/src/main/res/layout/fragment_send.xml index 0768fc3cf5..fa601d85e6 100644 --- a/app/src/main/res/layout/fragment_send.xml +++ b/app/src/main/res/layout/fragment_send.xml @@ -31,10 +31,10 @@ android:layout_marginTop="14dp" /> + android:layout_marginTop="4dp" /> + android:layout_height="wrap_content" + android:paddingStart="16dp" + android:paddingEnd="16dp"> @@ -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" /> diff --git a/app/src/main/res/layout/layout_send_currency.xml b/app/src/main/res/layout/layout_send_amount.xml similarity index 71% rename from app/src/main/res/layout/layout_send_currency.xml rename to app/src/main/res/layout/layout_send_amount.xml index a59a0d0ecb..92b912df35 100644 --- a/app/src/main/res/layout/layout_send_currency.xml +++ b/app/src/main/res/layout/layout_send_amount.xml @@ -26,34 +26,48 @@ android:inputType="numberDecimal" android:paddingStart="0dp" android:paddingEnd="96dp" + android:text="0" android:textSize="32sp" tools:text="139" /> - - - + app:layout_constraintEnd_toEndOf="parent"> + + + + + + This PayID already exists. Try a different one. Error response while creating PayID. + PayID verification failed + PayID unsupported by blockchain + PayID not registered + PayID request failed + Address is invalid or unsupported by blockchain + Address is the same as wallet address + Send Address or PayID