feat(dapps): show the bundled DApps catalog until one can be fetched

The DApps catalog lived only in memory and every read waited on first(), so
with the catalog URL unreachable the DApps screen waited forever.

feature-dapp-impl now bundles dapps_full.json at build time. On the first
sync the repository tries the network once and, if that fails, publishes the
bundled catalog so every read returns; the fetched catalog replaces it as
soon as it arrives.

The download task moves to scripts/bundled-config.gradle and serves both
the chain list and the DApps catalog. runtime and feature-dapp-impl unit
tests return defaults for android.util.Log, which the fallback calls.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-31 00:43:06 +04:00
1 parent ec9a2cf307
commit 5c66cd7f1d
7 files changed
+216 -43

No files matched your search

+16
View File
@@ -1,6 +1,7 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: "../scripts/secrets.gradle"
apply from: "../scripts/bundled-config.gradle"
android {
@@ -21,6 +22,14 @@ android {
}
namespace 'io.novafoundation.nova.feature_dapp_impl'
testOptions {
unitTests {
// android.util.Log.* throws "Method ... not mocked" in plain JVM unit tests; the first-install
// fallback logs the failed fetch it recovers from.
returnDefaultValues = true
}
}
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
@@ -96,3 +105,10 @@ dependencies {
testImplementation jUnitDep
testImplementation mockitoDep
}
// The DApps catalog a first install shows when none can be fetched (AssetBundledDappCatalog).
bundleConfigAsset(
"bundleDappsConfig",
"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/dapps/dapps_full.json",
"bundled-config/dapps.json"
) { it instanceof Map && it.dapps instanceof List && !it.dapps.isEmpty() }
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataResponse
/** The DApps catalog the APK was built with: what is shown until a catalog can be fetched. */
fun interface BundledDappCatalog {
fun load(): DappMetadataResponse?
companion object {
val None = BundledDappCatalog { null }
}
}
class AssetBundledDappCatalog(
private val context: Context,
private val gson: Gson,
) : BundledDappCatalog {
companion object {
// Written at build time by the `bundleDappsConfig` task in feature-dapp-impl/build.gradle.
const val ASSET_PATH = "bundled-config/dapps.json"
}
override fun load(): DappMetadataResponse? = runCatching {
context.assets.open(ASSET_PATH).bufferedReader().use { gson.fromJson(it, DappMetadataResponse::class.java) }
}.onFailure {
Log.e(LOG_TAG, "No usable bundled DApps catalog", it)
}.getOrNull()
}
@@ -1,6 +1,8 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import android.util.Log
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
@@ -11,6 +13,8 @@ import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepos
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapDAppMetadataResponseToDAppMetadatas
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataRemote
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataResponse
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -19,7 +23,8 @@ import kotlinx.coroutines.flow.first
class RealDAppMetadataRepository(
private val dappMetadataApi: DappMetadataApi,
private val remoteApiUrl: String,
private val browserHostSettingsDao: BrowserHostSettingsDao
private val browserHostSettingsDao: BrowserHostSettingsDao,
private val bundledCatalog: BundledDappCatalog = BundledDappCatalog.None,
) : DAppMetadataRepository {
private val dappMetadatasFlow = MutableSharedFlow<DappCatalog>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -29,7 +34,30 @@ class RealDAppMetadataRepository(
}
override suspend fun syncDAppMetadatas() {
val response = retryUntilDone { dappMetadataApi.getParachainMetadata(remoteApiUrl) }
if (dappMetadatasFlow.replayCache.isEmpty()) {
// Every read below waits for a first catalog. Try the network once, and if that fails show the
// catalog the APK was built with, so the DApps screen opens; the fetched one replaces it below.
val firstTry = try {
dappMetadataApi.getParachainMetadata(remoteApiUrl)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(LOG_TAG, "First DApps catalog fetch failed, showing the bundled catalog", e)
null
}
if (firstTry != null) {
applyCatalog(firstTry)
return
}
bundledCatalog.load()?.let { dappMetadatasFlow.emit(mapDAppMetadataResponseToDAppMetadatas(it)) }
}
applyCatalog(retryUntilDone { dappMetadataApi.getParachainMetadata(remoteApiUrl) })
}
private suspend fun applyCatalog(response: DappMetadataResponse) {
val dappMetadatas = mapDAppMetadataResponseToDAppMetadatas(response)
syncHostSettings(response.dapps)
dappMetadatasFlow.emit(dappMetadatas)
@@ -1,5 +1,7 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import android.content.Context
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
@@ -8,6 +10,7 @@ import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.BuildConfig
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi
import io.novafoundation.nova.feature_dapp_impl.data.repository.AssetBundledDappCatalog
import io.novafoundation.nova.feature_dapp_impl.data.repository.RealDAppMetadataRepository
@Module
@@ -23,10 +26,13 @@ class DappMetadataModule {
@FeatureScope
fun provideDRepository(
api: DappMetadataApi,
dappHostSettingsDao: BrowserHostSettingsDao
dappHostSettingsDao: BrowserHostSettingsDao,
context: Context,
gson: Gson
): DAppMetadataRepository = RealDAppMetadataRepository(
dappMetadataApi = api,
remoteApiUrl = BuildConfig.DAPP_METADATAS_URL,
browserHostSettingsDao = dappHostSettingsDao
browserHostSettingsDao = dappHostSettingsDao,
bundledCatalog = AssetBundledDappCatalog(context, gson)
)
}
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataRemote
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataResponse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.junit.MockitoJUnitRunner
import java.io.IOException
@RunWith(MockitoJUnitRunner::class)
class RealDAppMetadataRepositoryTest {
@Mock
lateinit var hostSettingsDao: BrowserHostSettingsDao
private val bundled = catalogOf("https://bundled.example")
private val remote = catalogOf("https://remote.example")
@Test
fun `a first install shows the bundled catalog until one is fetched`() = runBlocking {
`when`(hostSettingsDao.getBrowserAllHostSettings()).thenReturn(emptyList())
val network = CompletableDeferred<DappMetadataResponse>()
var calls = 0
val api = object : DappMetadataApi {
override suspend fun getParachainMetadata(url: String): DappMetadataResponse {
calls++
if (calls == 1) throw IOException("unreachable")
return network.await()
}
}
val repository = RealDAppMetadataRepository(api, "url", hostSettingsDao) { bundled }
val sync = launch { repository.syncDAppMetadatas() }
while (!repository.isDAppsSynced()) yield()
assertEquals("https://bundled.example", repository.getDAppCatalog().dApps.single().url)
network.complete(remote)
sync.join()
assertEquals("https://remote.example", repository.getDAppCatalog().dApps.single().url)
}
@Test
fun `the bundled catalog is not read when the fetch works`() = runBlocking {
`when`(hostSettingsDao.getBrowserAllHostSettings()).thenReturn(emptyList())
var bundledReads = 0
val api = object : DappMetadataApi {
override suspend fun getParachainMetadata(url: String) = remote
}
val repository = RealDAppMetadataRepository(api, "url", hostSettingsDao) { bundledReads++; bundled }
repository.syncDAppMetadatas()
assertEquals(0, bundledReads)
assertEquals("https://remote.example", repository.getDAppCatalog().dApps.single().url)
}
private fun catalogOf(url: String) = DappMetadataResponse(
popular = emptyList(),
categories = emptyList(),
dapps = listOf(DappMetadataRemote(name = "d", icon = "i", url = url, categories = emptyList(), desktopOnly = null))
)
}
+11 -39
View File
@@ -1,8 +1,8 @@
apply from: '../scripts/secrets.gradle'
apply plugin: 'kotlin-parcelize'
apply from: '../scripts/bundled-config.gradle'
def chainsUrl = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json"
def bundledConfigDir = "$buildDir/generated/bundledConfig"
android {
@@ -37,6 +37,14 @@ android {
}
}
namespace 'io.novafoundation.nova.runtime'
testOptions {
unitTests {
// android.util.Log.* throws "Method ... not mocked" in plain JVM unit tests; the first-install
// fallback logs the failed fetch it recovers from.
returnDefaultValues = true
}
}
}
dependencies {
@@ -71,41 +79,5 @@ dependencies {
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)
// The chain list a first install starts from when none can be fetched (AssetBundledChains).
bundleConfigAsset("bundleChainsConfig", chainsUrl, "bundled-config/chains.json") { it instanceof List && !it.isEmpty() }
+44
View File
@@ -0,0 +1,44 @@
// Config files the APK is built with, so a first install can open when none can be fetched
// (GitHub blocked, offline). Each is the live file at build time, fetched from GitHub or its jsDelivr
// mirror, checked by the caller's `isUsable`, and placed in the module's assets under `assetPath`.
//
// apply from: '../scripts/bundled-config.gradle'
// bundleConfigAsset("bundleChainsConfig", chainsUrl, "bundled-config/chains.json") { it instanceof List && !it.isEmpty() }
ext.bundleConfigAsset = { String taskName, String url, String assetPath, Closure isUsable ->
def outputDir = "$buildDir/generated/bundledConfig"
def target = file("$outputDir/$assetPath")
def task = tasks.register(taskName) {
outputs.file(target)
outputs.upToDateWhen { false }
doLast {
def sources = [
url,
url.replaceFirst('^https://raw\\.githubusercontent\\.com/([^/]+)/([^/]+)/([^/]+)/', 'https://cdn.jsdelivr.net/gh/$1/$2@$3/')
]
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("$taskName: $source failed: $e")
}
}
if (bytes == null) throw new GradleException("$taskName: no source answered for $url")
def parsed = new groovy.json.JsonSlurper().parse(bytes)
if (!isUsable(parsed)) throw new GradleException("$taskName: $url is not a usable config file")
target.parentFile.mkdirs()
target.bytes = bytes
def sha256 = java.security.MessageDigest.getInstance("SHA-256").digest(bytes).encodeHex().toString()
logger.lifecycle("$taskName: $assetPath ${bytes.length} bytes, sha256 $sha256")
}
}
android.sourceSets.main.assets.srcDir(outputDir)
preBuild.dependsOn(task)
}