diff --git a/runtime/build.gradle b/runtime/build.gradle index 436183ea..4fd74285 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -1,11 +1,14 @@ apply from: '../scripts/secrets.gradle' apply plugin: 'kotlin-parcelize' +def chainsUrl = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json" +def bundledConfigDir = "$buildDir/generated/bundledConfig" + android { defaultConfig { - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "CHAINS_URL", "\"$chainsUrl\"" buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" @@ -27,7 +30,7 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "CHAINS_URL", "\"$chainsUrl\"" buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" @@ -66,4 +69,43 @@ dependencies { androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRulesDep androidTestImplementation androidJunitDep -} \ No newline at end of file +} + +// The chain list a first install starts from when none can be fetched (AssetBundledChains). It is the live +// list at build time, so every build carries the chains that were current when it was made. +def bundleChainsConfig = tasks.register("bundleChainsConfig") { + def target = file("$bundledConfigDir/bundled-config/chains.json") + outputs.file(target) + outputs.upToDateWhen { false } + + doLast { + def sources = [ + chainsUrl, + chainsUrl.replace("https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/", + "https://cdn.jsdelivr.net/gh/pezkuwichain/pezkuwi-wallet-utils@master/") + ] + def bytes = null + for (source in sources) { + try { + bytes = new URL(source).getBytes(connectTimeout: 20_000, readTimeout: 60_000) + break + } catch (IOException e) { + logger.warn("bundleChainsConfig: $source failed: $e") + } + } + if (bytes == null) throw new GradleException("bundleChainsConfig: no source answered") + + def chains = new groovy.json.JsonSlurper().parse(bytes) + if (!(chains instanceof List) || chains.isEmpty()) { + throw new GradleException("bundleChainsConfig: the chain list is not a non-empty JSON array") + } + + target.parentFile.mkdirs() + target.bytes = bytes + def sha256 = java.security.MessageDigest.getInstance("SHA-256").digest(bytes).encodeHex().toString() + logger.lifecycle("bundleChainsConfig: ${chains.size()} chains, sha256 $sha256") + } +} + +android.sourceSets.main.assets.srcDir(bundledConfigDir) +preBuild.dependsOn(bundleChainsConfig) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt index eff94b38..29ff4187 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.runtime.di +import android.content.Context import com.google.gson.Gson import dagger.Module import dagger.Provides @@ -15,6 +16,7 @@ import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.AssetBundledChains import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection @@ -56,8 +58,9 @@ class ChainRegistryModule { dao: ChainDao, chainAssetDao: ChainAssetDao, chainFetcher: ChainFetcher, - gson: Gson - ) = ChainSyncService(dao, chainFetcher, gson) + gson: Gson, + context: Context + ) = ChainSyncService(dao, chainFetcher, gson, AssetBundledChains(context, gson)) @Provides @ApplicationScope diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt new file mode 100644 index 00000000..476ed64c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote + +/** The chain list the APK was built with: what a first install starts from when no chain list can be fetched. */ +fun interface BundledChains { + + fun load(): List? + + companion object { + val None = BundledChains { null } + } +} + +class AssetBundledChains( + private val context: Context, + private val gson: Gson, +) : BundledChains { + + companion object { + // Written at build time by the `bundleChainsConfig` task in runtime/build.gradle. + const val ASSET_PATH = "bundled-config/chains.json" + } + + override fun load(): List? = runCatching { + context.assets.open(ASSET_PATH).bufferedReader().use { reader -> + gson.fromJson>(reader, object : TypeToken>() {}.type) + } + }.onFailure { + Log.e(LOG_TAG, "No usable bundled chain list", it) + }.getOrNull() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt index 5e571e0b..bab41c46 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -12,6 +12,7 @@ import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL import io.novafoundation.nova.core_db.model.chain.ChainLocal import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal @@ -19,17 +20,48 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainT import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ChainSyncService( private val chainDao: ChainDao, private val chainFetcher: ChainFetcher, - private val gson: Gson + private val gson: Gson, + private val bundledChains: BundledChains = BundledChains.None, ) { suspend fun syncUp() = withContext(Dispatchers.Default) { - val localChainsJoinedInfo = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM } + if (loadLocal().isEmpty()) { + // A first install has nothing to show until a chain list arrives. Try the network once (the + // config mirrors are behind it), and if that fails, start from the list the APK was built with + // so the wallet opens; the remote list replaces it below as soon as it can be fetched. + val firstTry = try { + chainFetcher.getChains() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(LOG_TAG, "First chain fetch failed, starting from the bundled chain list", e) + null + } + + if (firstTry != null) { + applyRemoteChains(firstTry, loadLocal()) + return@withContext + } + + bundledChains.load()?.let { applyRemoteChains(it, loadLocal()) } + } + + val remoteChains = retryUntilDone { chainFetcher.getChains() } + + applyRemoteChains(remoteChains, loadLocal()) + } + + private suspend fun loadLocal() = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM } + + private suspend fun applyRemoteChains(remoteChains: List, localChainsJoinedInfo: List) { val oldChains = localChainsJoinedInfo.map { it.chain } val oldAssets = localChainsJoinedInfo.flatMap { it.assets }.filter { it.source == AssetSourceLocal.DEFAULT } val oldNodes = localChainsJoinedInfo.flatMap { it.nodes }.filter { it.source != ChainNodeLocal.Source.CUSTOM } @@ -40,8 +72,6 @@ class ChainSyncService( val oldChainsById = oldChains.associateBy { it.id } val associatedOldAssets = oldAssets.associateBy { it.fullId() } - val remoteChains = retryUntilDone { chainFetcher.getChains() } - // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of // throwing. Applying that as a diff against a populated local DB would delete most or all of the @@ -54,7 +84,7 @@ class ChainSyncService( "Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." ) - return@withContext + return } // One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt index dc880dd6..bd4451cc 100644 --- a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -25,6 +25,7 @@ import io.novafoundation.nova.test_shared.emptyDiff import io.novafoundation.nova.test_shared.insertsElement import io.novafoundation.nova.test_shared.removesElement import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -131,6 +132,50 @@ class ChainSyncServiceTest { } } + @Test + fun `a first install starts from the bundled list when the fetch fails`() { + runBlocking { + val bundled = REMOTE_CHAIN.copy(chainId = "0xbb") + chainSyncService = ChainSyncService(dao, chainFetcher, gson) { listOf(bundled) } + localReturns(emptyList()) + `when`(chainFetcher.getChains()).thenThrow(RuntimeException("unreachable")).thenReturn(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = insertsChainWithId(bundled.chainId), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = insertsTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = insertsNodeSelectionPreferences(bundled.chainId) + ) + // ... and the fetched list still follows it. + verify(dao).applyDiff( + chainDiff = insertsChainWithId(REMOTE_CHAIN.chainId), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = insertsTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = insertsNodeSelectionPreferences(REMOTE_CHAIN.chainId) + ) + } + } + + @Test + fun `the bundled list is not read when the fetch works`() { + runBlocking { + var bundledReads = 0 + chainSyncService = ChainSyncService(dao, chainFetcher, gson) { bundledReads++; emptyList() } + localReturns(emptyList()) + remoteReturns(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + assertEquals(0, bundledReads) + } + } + @Test fun `should not insert the same chain`() { runBlocking {