mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-04 01:41:12 +00:00
feat(runtime): start a first install from the chain list the APK was built with
A first install has no chains until chains.json is fetched, and the fetch retries forever: with GitHub and its mirrors unreachable the wallet stays empty. The runtime module now bundles the live chains.json at build time (bundleChainsConfig, run before preBuild, falling back to jsDelivr and refusing anything that is not a non-empty JSON array). When the local database is empty, ChainSyncService tries the network once and, if that fails, applies the bundled list so the wallet opens; the fetched list then replaces it through the usual diff as soon as it arrives.
This commit is contained in:
1 parent
0b1319c866
commit
ec9a2cf307
5 files changed
+167
-10
No files matched your search
+45
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -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
|
||||
|
||||
+37
@@ -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<ChainRemote>?
|
||||
|
||||
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<ChainRemote>? = runCatching {
|
||||
context.assets.open(ASSET_PATH).bufferedReader().use { reader ->
|
||||
gson.fromJson<List<ChainRemote>>(reader, object : TypeToken<List<ChainRemote>>() {}.type)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(LOG_TAG, "No usable bundled chain list", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
+35
-5
@@ -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<ChainRemote>, localChainsJoinedInfo: List<JoinedChainInfo>) {
|
||||
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
|
||||
|
||||
+45
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user