diff --git a/feature-dapp-impl/build.gradle b/feature-dapp-impl/build.gradle index 030dda39..1eeb7e1e 100644 --- a/feature-dapp-impl/build.gradle +++ b/feature-dapp-impl/build.gradle @@ -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() } diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/BundledDappCatalog.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/BundledDappCatalog.kt new file mode 100644 index 00000000..5fa785a0 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/BundledDappCatalog.kt @@ -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() +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt index 8c999fc9..5ca08c1c 100644 --- a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt @@ -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(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) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt index 941da9c0..78e34f59 100644 --- a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt @@ -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) ) } diff --git a/feature-dapp-impl/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepositoryTest.kt b/feature-dapp-impl/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepositoryTest.kt new file mode 100644 index 00000000..16cb4edb --- /dev/null +++ b/feature-dapp-impl/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepositoryTest.kt @@ -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() + 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)) + ) +} diff --git a/runtime/build.gradle b/runtime/build.gradle index 4fd74285..ca53ccd1 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -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() } diff --git a/scripts/bundled-config.gradle b/scripts/bundled-config.gradle new file mode 100644 index 00000000..0a158c2f --- /dev/null +++ b/scripts/bundled-config.gradle @@ -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) +}