diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml index 1d6c723d..d674561e 100644 --- a/.github/workflows/install/action.yml +++ b/.github/workflows/install/action.yml @@ -18,7 +18,27 @@ runs: - name: Install NDK run: | SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1) - echo "y" | sudo ${SDKMANAGER} --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT} + NDK_PACKAGE="ndk;26.1.10909125" + + # sdkmanager's download of the ~1GB NDK zip from Google's CDN occasionally comes back truncated/corrupted + # ("Error on ZipFile unknown archive") with no built-in retry, taking down the whole build for a purely + # transient network blip. Retry with cleanup between attempts: sdkmanager can otherwise resume from the + # same corrupted partial file instead of re-fetching, making a naive retry fail identically every time. + for attempt in 1 2 3; do + echo "NDK install attempt $attempt/3" + if echo "y" | sudo ${SDKMANAGER} --install "$NDK_PACKAGE" --sdk_root=${ANDROID_SDK_ROOT}; then + echo "NDK installed successfully" + exit 0 + fi + + echo "Attempt $attempt failed - clearing any partial/corrupted download before retrying" + sudo rm -rf "${ANDROID_SDK_ROOT}/ndk/26.1.10909125" + sudo find "${ANDROID_SDK_ROOT}" -maxdepth 1 -name "tmp*" -exec rm -rf {} + + sleep 10 + done + + echo "NDK install failed after 3 attempts" + exit 1 shell: bash - name: Set ndk.dir in local.properties diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt new file mode 100644 index 00000000..221f096c --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.balances + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertNotNull +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to + * end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This + * is meant to answer one question with hard evidence, not speculation: does the app's real, running background + * sync ever write an `assets` cache row for HEZ on the Pezkuwi Asset Hub chain, for a real, well-funded account? + * + * If this test fails, the failure message + logcat (tag "BalancesDiag", plus the standard per-updater error + * logs already wired into BalancesUpdateSystem/FullSyncPaymentUpdater) shows exactly which decision branch or + * exception is responsible - not another layer of inference from silence. + */ +class PezkuwiFullArchitectureBalancesTest { + + // Mainnet Founder account (SS58, generic substrate prefix) - verified live via @pezkuwi/api on 2026-07-09 + // to hold a substantial non-zero, non-frozen free HEZ balance on Pezkuwi Asset Hub (180,297.80 HEZ). + private val founderSubstrateAddress = "5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF" + private val pezkuwiAssetHubChainId = "e7c15092dcbe3f320260ddbbc685bfceed9125a3b3d8436db2766201dec3b949" + private val hezAssetId = 0 + + private val context = ApplicationProvider.getApplicationContext() + + private val dbApi = FeatureUtils.getFeature(context, DbApi::class.java) + private val metaAccountDao = dbApi.metaAccountDao() + private val assetDao: AssetDao = dbApi.provideAssetDao() + + @Test + fun testPezkuwiAssetHubHezBalanceActuallySyncs() = runBlocking { + val metaId = insertAndSelectFounderWatchAccount(metaAccountDao) + + val assetRow = withTimeoutOrNull(90.seconds) { + while (true) { + val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId) + if (asset != null) return@withTimeoutOrNull asset + + delay(2.seconds) + } + @Suppress("UNREACHABLE_CODE") + null + } + + assertNotNull( + "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + + "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " + + "tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.", + assetRow + ) + } + + private suspend fun insertAndSelectFounderWatchAccount(dao: MetaAccountDao): Long { + val accountId = founderSubstrateAddress.toAccountId() + + val metaAccount = MetaAccountLocal( + substratePublicKey = accountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = accountId, + ethereumPublicKey = null, + ethereumAddress = null, + name = "PezkuwiFullArchitectureBalancesTest", + parentMetaId = null, + isSelected = false, + position = 0, + type = MetaAccountLocal.Type.WATCH_ONLY, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = dao.insertMetaAccount(metaAccount) + dao.selectMetaAccount(metaId) + + return metaId + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt index 80265626..a6753875 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -45,6 +45,11 @@ class BalancesUpdateSystem( } private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { + Log.d( + "BalancesDiag", + "balancesSync(${chain.name}): hasAccountIn=${metaAccount.hasAccountIn(chain)} " + + "isDisabled=${chain.connectionState.isDisabled} canPerformFullSync=${chain.canPerformFullSync()}" + ) return when { !metaAccount.hasAccountIn(chain) -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow() @@ -89,6 +94,10 @@ class BalancesUpdateSystem( try { updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) } } catch (e: Exception) { + // Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend + // call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling + // requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard. + Log.e("BalancesDiag", "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) emptyFlow() } }