diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72424b337b..3362aa5bbb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(projects.domain.walletConnect) implementation(projects.common) + implementation(projects.common.routing) implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.navigation) diff --git a/common/routing/.gitignore b/common/routing/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/common/routing/.gitignore @@ -0,0 +1 @@ +/build diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts new file mode 100644 index 0000000000..980934bf31 --- /dev/null +++ b/common/routing/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.common.routing" +} + +dependencies { + /* Core */ + implementation(projects.core.decompose) + + /* Domain */ + implementation(projects.domain.qrScanning.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) + + /* Libs - Other */ + implementation(deps.androidx.core.ktx) + implementation(deps.kotlin.serialization) + implementation(deps.timber) +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt new file mode 100644 index 0000000000..2a337dbeaa --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -0,0 +1,164 @@ +package com.tangem.common.routing + +import android.os.Bundle +import com.tangem.common.routing.bundle.RouteBundleParams +import com.tangem.common.routing.bundle.bundle +import com.tangem.common.routing.entity.SerializableIntent +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +sealed class AppRoute(val path: String) : Route, RouteBundleParams { + + override fun getBundle(): Bundle = bundle(serializer()) + + @Serializable + data object Initial : AppRoute(path = "/initial") + + @Serializable + data object Home : AppRoute(path = "/home") + + @Serializable + data class Welcome( + val intent: SerializableIntent? = null, + ) : AppRoute(path = "/welcome") { + + companion object { + const val INITIAL_INTENT_KEY = "intent" + } + } + + @Serializable + data object Disclaimer : AppRoute(path = "/disclaimer") + + @Serializable + data object OnboardingNote : AppRoute(path = "/onboarding/note") + + @Serializable + data class OnboardingWallet( + val canSkipBackup: Boolean = true, + ) : AppRoute(path = "/onboarding/wallet${if (canSkipBackup) "/skippable" else ""}") { + + companion object { + const val CAN_SKIP_BACKUP_KEY = "canSkipBackup" + } + } + + @Serializable + data object OnboardingTwins : AppRoute(path = "/onboarding/twins") + + @Serializable + data object OnboardingOther : AppRoute(path = "/onboarding/other") + + @Serializable + data object Wallet : AppRoute(path = "/wallet") + + @Serializable + data class CurrencyDetails( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + ) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}") { + + companion object { + const val USER_WALLET_ID_KEY = "userWalletId" + const val CRYPTO_CURRENCY_KEY = "currency" + } + } + + @Serializable + data class Send( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val transactionId: String? = null, + val amount: String? = null, + val tag: String? = null, + val destinationAddress: String? = null, + ) : AppRoute( + path = "/send/${userWalletId.stringValue}/${currency.id.value}?" + + "&$transactionId" + + "&$amount" + + "&$tag" + + "&$destinationAddress", + ) { + companion object { + const val USER_WALLET_ID_KEY = "userWalletId" + const val CRYPTO_CURRENCY_KEY = "currency" + const val TRANSACTION_ID_KEY = "transactionId" + const val AMOUNT_KEY = "amount" + const val TAG_KEY = "tag" + const val DESTINATION_ADDRESS_KEY = "destinationAddress" + } + } + + @Serializable + data class Details( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/details/${userWalletId.stringValue}") + + @Serializable + data object DetailsSecurity : AppRoute(path = "/details/security") + + @Serializable + data object CardSettings : AppRoute(path = "/card_settings") + + @Serializable + data object AppSettings : AppRoute(path = "/app_settings") + + @Serializable + data object ResetToFactory : AppRoute(path = "/reset_to_factory") + + @Serializable + data object AccessCodeRecovery : AppRoute(path = "/access_code_recovery") + + @Serializable + data object ManageTokens : AppRoute(path = "/manage_tokens") + + @Serializable + data object AddCustomToken : AppRoute(path = "/add_custom_token") + + @Serializable + data object WalletConnectSessions : AppRoute(path = "/wallet_connect_sessions") + + @Serializable + data class QrScanning( + val source: SourceType, + val networkName: String? = null, + ) : AppRoute(path = "/qr_scanning") { + + companion object { + const val SOURCE_KEY = "source" + const val NETWORK_KEY = "networkName" + } + } + + @Serializable + data object ReferralProgram : AppRoute(path = "/referral_program") + + @Serializable + data class Swap( + val currency: CryptoCurrency, + ) : AppRoute(path = "/swap") { + + companion object { + const val CURRENCY_BUNDLE_KEY = "currency" + } + } + + @Serializable + data object TesterMenu : AppRoute(path = "/tester_menu") + + @Serializable + data object SaveWallet : AppRoute(path = "/save_wallet") + + @Serializable + data object AppCurrencySelector : AppRoute(path = "/app_currency_selector") + + @Serializable + data object ModalNotification : AppRoute(path = "/modal_notification") + + @Serializable + data object Staking : AppRoute(path = "/staking") +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt new file mode 100644 index 0000000000..9e81df0ec5 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt @@ -0,0 +1,42 @@ +package com.tangem.common.routing + +import kotlin.reflect.KClass + +/** + * Interface for a router in the application. + * It provides methods for navigating through the application. + * + * Same as [com.tangem.core.decompose.navigation.Router] but without Decompose dependency. + * + * ***Must be removed after Decompose migration.*** + */ +interface AppRouter { + + /** + * The current navigation stack. + */ + val backStack: List + + /** + * Pushes a new route to the navigation stack. + * + * @param route The route to push. + * @param onComplete The callback to be invoked when the operation is complete. + */ + fun push(route: AppRoute, onComplete: (isSuccess: Boolean) -> Unit = {}) + + /** + * Pops the top route from the navigation stack. + * + * @param onComplete The callback to be invoked when the operation is complete. + */ + fun pop(onComplete: (isSuccess: Boolean) -> Unit = {}) + + /** + * Pops routes from the navigation stack until the specified route class is found. + * + * @param routeClass The route class to pop to. + * @param onComplete The callback to be invoked when the operation is complete. + */ + fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit = {}) +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleDecoder.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleDecoder.kt new file mode 100644 index 0000000000..6a51004489 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleDecoder.kt @@ -0,0 +1,108 @@ +package com.tangem.common.routing.bundle + +import android.os.Bundle +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.SerializersModule + +@ExperimentalSerializationApi +internal class BundleDecoder( + private val bundle: Bundle, + private val elementsCount: Int = -1, + private val isInitializer: Boolean = true, + override val serializersModule: SerializersModule, +) : AbstractDecoder() { + + private var index = -1 + private var elementKey: String? = null + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + if (++index >= elementsCount) { + return CompositeDecoder.DECODE_DONE + } + + elementKey = descriptor.getElementName(index) + return index + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + val b = if (isInitializer) { + bundle + } else { + requireNotNull(bundle.getBundle(elementKey)) { + "Bundle is missing for key $elementKey while decoding" + } + } + + val count = when (descriptor.kind) { + StructureKind.MAP, + StructureKind.LIST, + -> b.getInt("\$size") + else -> descriptor.elementsCount + } + + return BundleDecoder( + bundle = b, + elementsCount = count, + isInitializer = false, + serializersModule = serializersModule, + ) + } + + override fun endStructure(descriptor: SerialDescriptor) { + /* no-op */ + } + + override fun decodeBoolean(): Boolean { + return bundle.getBoolean(elementKey) + } + + override fun decodeByte(): Byte { + return bundle.getByte(elementKey) + } + + override fun decodeChar(): Char { + return bundle.getChar(elementKey) + } + + override fun decodeDouble(): Double { + return bundle.getDouble(elementKey) + } + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + return bundle.getInt(elementKey) + } + + override fun decodeFloat(): Float { + return bundle.getFloat(elementKey) + } + + override fun decodeInt(): Int { + return bundle.getInt(elementKey) + } + + override fun decodeLong(): Long { + return bundle.getLong(elementKey) + } + + override fun decodeNotNullMark(): Boolean { + return bundle.containsKey(elementKey) + } + + override fun decodeNull(): Nothing? { + return null + } + + override fun decodeShort(): Short { + return bundle.getShort(elementKey) + } + + override fun decodeString(): String { + return requireNotNull(bundle.getString(elementKey)) { + "String is missing for key $elementKey while decoding" + } + } +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleEncoder.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleEncoder.kt new file mode 100644 index 0000000000..e0be7b8aa3 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleEncoder.kt @@ -0,0 +1,107 @@ +package com.tangem.common.routing.bundle + +import android.os.Bundle +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.AbstractEncoder +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.modules.SerializersModule + +@ExperimentalSerializationApi +internal class BundleEncoder( + private val bundle: Bundle, + private val parentBundle: Bundle? = null, + private val keyInParent: String? = null, + private val isInitializer: Boolean = true, + override val serializersModule: SerializersModule, +) : AbstractEncoder() { + + private var elementKey: String? = null + + override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + elementKey = descriptor.getElementName(index) + return super.encodeElement(descriptor, index) + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { + return if (isInitializer) { + BundleEncoder( + bundle = bundle, + parentBundle = null, + keyInParent = elementKey, + isInitializer = false, + serializersModule = serializersModule, + ) + } else { + BundleEncoder( + bundle = Bundle(), + parentBundle = bundle, + keyInParent = elementKey, + isInitializer = false, + serializersModule = serializersModule, + ) + } + } + + override fun endStructure(descriptor: SerialDescriptor) { + if (descriptor.kind in arrayOf(StructureKind.LIST, StructureKind.MAP)) { + val size = elementKey?.toIntOrNull()?.let { it + 1 } ?: 0 + bundle.putInt("\$size", size) + } + + if (keyInParent.isNullOrBlank()) { + return + } + + parentBundle?.putBundle(keyInParent, bundle) + } + + override fun encodeBoolean(value: Boolean) { + bundle.putBoolean(elementKey, value) + } + + override fun encodeByte(value: Byte) { + bundle.putByte(elementKey, value) + } + + override fun encodeChar(value: Char) { + bundle.putChar(elementKey, value) + } + + override fun encodeDouble(value: Double) { + bundle.putDouble(elementKey, value) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + bundle.putInt(elementKey, index) + } + + override fun encodeFloat(value: Float) { + bundle.putFloat(elementKey, value) + } + + override fun encodeInt(value: Int) { + bundle.putInt(elementKey, value) + } + + override fun encodeLong(value: Long) { + bundle.putLong(elementKey, value) + } + + override fun encodeNull() { + /* no-op */ + } + + override fun encodeShort(value: Short) { + bundle.putShort(elementKey, value) + } + + override fun encodeString(value: String) { + bundle.putString(elementKey, value) + } + + override fun encodeNotNullMark() { + /* no-op */ + } +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleUtils.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleUtils.kt new file mode 100644 index 0000000000..4c638fde65 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/BundleUtils.kt @@ -0,0 +1,60 @@ +package com.tangem.common.routing.bundle + +import android.os.Bundle +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +val defaultSerializersModule: SerializersModule = EmptySerializersModule() + +/** + * Deserialize this bundle into an object of type [T]. + * + * @receiver [Bundle] to deserialize. + * @param deserializer [DeserializationStrategy] of the [T] class. + * + * @return Object of type T deserialized from bundle. + */ +@OptIn(ExperimentalSerializationApi::class) +fun Bundle.unbundle( + deserializer: DeserializationStrategy, + serializersModule: SerializersModule = defaultSerializersModule, +): T { + val decoder = BundleDecoder( + bundle = this, + elementsCount = -1, + isInitializer = true, + serializersModule = serializersModule, + ) + + return deserializer.deserialize(decoder) +} + +/** + * Serialize [T] into a bundle. + * + * @receiver Object to serialize. + * @param serializer [SerializationStrategy] of the [T] class. + * + * @return bundle serialized from value + */ +@OptIn(ExperimentalSerializationApi::class) +fun T.bundle( + serializer: SerializationStrategy, + serializersModule: SerializersModule = defaultSerializersModule, +): Bundle { + val bundle = Bundle(serializer.descriptor.elementsCount) + val encoder = BundleEncoder( + bundle = bundle, + parentBundle = null, + keyInParent = null, + isInitializer = true, + serializersModule = serializersModule, + ) + + serializer.serialize(encoder, value = this) + + return bundle +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/RouteBundleParams.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/RouteBundleParams.kt new file mode 100644 index 0000000000..12ae0795d3 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/bundle/RouteBundleParams.kt @@ -0,0 +1,8 @@ +package com.tangem.common.routing.bundle + +import android.os.Bundle + +interface RouteBundleParams { + + fun getBundle(): Bundle +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt new file mode 100644 index 0000000000..fcac1b0422 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableBundle.kt @@ -0,0 +1,24 @@ +package com.tangem.common.routing.entity + +import android.os.Bundle +import kotlinx.serialization.Serializable + +@Serializable +data class SerializableBundle( + val map: Map, +) { + + constructor(bundle: Bundle) : this( + map = bundle.keySet().mapNotNull { key -> + bundle.getString(key)?.let { key to it } + }.toMap(), + ) + + fun toBundle(): Bundle { + return Bundle().apply { + map.forEach { (key, value) -> + putString(key, value) + } + } + } +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt new file mode 100644 index 0000000000..38ef6f2096 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/SerializableIntent.kt @@ -0,0 +1,51 @@ +package com.tangem.common.routing.entity + +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import kotlinx.serialization.Serializable + +@Serializable +data class SerializableIntent( + val action: String?, + val dataString: String?, + val categories: Set?, + val type: String?, + val packageValue: String?, + val component: String?, + val flags: Int, + val extras: SerializableBundle?, +) { + + constructor(intent: Intent) : this( + action = intent.action, + dataString = intent.dataString, + categories = intent.categories, + type = intent.type, + packageValue = intent.`package`, + component = intent.component?.flattenToString(), + flags = intent.flags, + extras = intent.extras?.let(::SerializableBundle), + ) + + fun toIntent(): Intent { + val intent = Intent() + + intent.action = action + intent.setDataAndType( + dataString?.let { Uri.parse(it) }, + type, + ) + categories?.let { categories -> + for (category in categories) { + intent.addCategory(category) + } + } + intent.`package` = packageValue + intent.component = component?.let { ComponentName.unflattenFromString(it) } + intent.flags = flags + extras?.let { intent.putExtras(it.toBundle()) } + + return intent + } +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/utils/AppRouterExt.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/AppRouterExt.kt new file mode 100644 index 0000000000..1bc85f941f --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/AppRouterExt.kt @@ -0,0 +1,16 @@ +package com.tangem.common.routing.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter + +/** + * Pops routes from the navigation stack until the specified route [R] is found. + * + * ***Must be removed after Decompose migration.*** + * + * @param R The route to pop to. + * @param onComplete The callback to be invoked when the operation is complete. + */ +inline fun AppRouter.popTo(noinline onComplete: (isSuccess: Boolean) -> Unit = {}) { + popTo(R::class, onComplete) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 914e2ac058..40841c6327 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -113,6 +113,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":common") include(":common:ui-charts") +include(":common:routing") // region Core modules include(":core:analytics")