@@ -37,6 +37,7 @@ import androidx.compose.material3.AlertDialog
3737import androidx.compose.material3.BottomSheetDefaults
3838import androidx.compose.material3.CircularProgressIndicator
3939import androidx.compose.material3.ExperimentalMaterial3Api
40+ import androidx.compose.material3.LinearProgressIndicator
4041import androidx.compose.material3.FilledTonalButton
4142import androidx.compose.material3.MaterialTheme
4243import androidx.compose.material3.ModalBottomSheet
@@ -65,7 +66,11 @@ import androidx.compose.ui.res.painterResource
6566import androidx.compose.ui.unit.dp
6667import androidx.compose.ui.window.Dialog
6768import androidx.fragment.app.FragmentActivity
69+ import androidx.lifecycle.lifecycleScope
70+ import kotlinx.coroutines.async
71+ import kotlinx.coroutines.coroutineScope
6872import kotlinx.coroutines.delay
73+ import kotlinx.coroutines.launch
6974import org.bitcoinppl.cove.flows.TapSignerFlow.TapSignerContainer
7075import org.bitcoinppl.cove.navigation.CoveNavDisplay
7176import org.bitcoinppl.cove.nfc.NfcScanSheet
@@ -75,7 +80,13 @@ import org.bitcoinppl.cove.ui.theme.CoveTheme
7580import org.bitcoinppl.cove.views.LockView
7681import org.bitcoinppl.cove.views.TermsAndConditionsSheet
7782import org.bitcoinppl.cove_core.bootstrap
83+ import org.bitcoinppl.cove_core.activeMigration
84+ import org.bitcoinppl.cove_core.bootstrapProgress
85+ import org.bitcoinppl.cove.utils.isMigrationInProgress
86+ import org.bitcoinppl.cove_core.cancelBootstrap
7887import org.bitcoinppl.cove_core.AfterPinAction
88+ import org.bitcoinppl.cove_core.AppInitException
89+ import org.bitcoinppl.cove_core.BootstrapStep
7990import org.bitcoinppl.cove_core.AlertDisplayType
8091import org.bitcoinppl.cove_core.AppAction
8192import org.bitcoinppl.cove_core.AppAlertState
@@ -168,26 +179,83 @@ class MainActivity : FragmentActivity() {
168179 BootstrapErrorView (errorMessage = bootstrapError!! )
169180 } else {
170181 var showSpinner by remember { mutableStateOf(false ) }
171- SplashLoadingView (showSpinner = showSpinner)
182+ var splashStatus by remember { mutableStateOf<String ?>(null ) }
183+ var encryptionProgress by remember { mutableStateOf<Float ?>(null ) }
184+
185+ SplashLoadingView (
186+ showSpinner = showSpinner,
187+ statusMessage = splashStatus,
188+ progress = encryptionProgress,
189+ )
172190
173191 LaunchedEffect (Unit ) {
174192 delay(SPINNER_DELAY_MS )
175193 showSpinner = true
176194 }
177195
178196 LaunchedEffect (Unit ) {
179- try {
180- val warning = bootstrap()
197+ fun completeBootstrap (warning : String? = null) {
198+ splashStatus = null
199+ encryptionProgress = null
181200 (application as CoveApplication ).onBootstrapComplete()
182201 val appInstance = AppManager .getInstance()
183- appInstance.rust.initData()
184202 appInstance.asyncRuntimeReady = true
185203 isBootstrapped = true
186204 bootstrapped = true
187205 bdkMigrationWarning = warning
206+
207+ // non-blocking — initData preloads caches and prices but is not
208+ // required for core functionality, failures are logged but not surfaced to the user
209+ this @MainActivity.lifecycleScope.launch {
210+ appInstance.rust.initData()
211+ Log .d(TAG , " [STARTUP] initData completed" )
212+ }
213+ }
214+
215+ val warning: String?
216+
217+ try {
218+ warning = runBootstrapWithWatchdog { status, progress ->
219+ splashStatus = status
220+ encryptionProgress = progress
221+ }
222+ } catch (e: BootstrapTimeoutException ) {
223+ val step = bootstrapProgress()
224+
225+ if (step == BootstrapStep .COMPLETE ) {
226+ // BDK migration retries every launch, so any lost warning will surface next time
227+ Log .w(TAG , " [STARTUP] bootstrap completed despite timeout — migration warning (if any) was lost and will retry on next launch" )
228+ completeBootstrap()
229+ } else {
230+ Log .e(TAG , " [STARTUP] bootstrap timed out, last step: $step " )
231+ bootstrapError =
232+ " App startup timed out. Please force-quit and try again.\n\n Please contact feedback@covebitcoinwallet.com"
233+ }
234+
235+ return @LaunchedEffect
236+ } catch (e: kotlinx.coroutines.CancellationException ) {
237+ throw e
188238 } catch (e: Exception ) {
189- bootstrapError = e.message ? : " Unknown error"
239+ val step = bootstrapProgress()
240+ if (step == BootstrapStep .COMPLETE ) {
241+ Log .w(TAG , " [STARTUP] bootstrap completed despite error — treating as success" , e)
242+ completeBootstrap()
243+ } else if (e is AppInitException .AlreadyCalled ) {
244+ Log .e(TAG , " [STARTUP] bootstrap already called at step: $step " , e)
245+ bootstrapError =
246+ " App initialization error. Please force-quit and restart."
247+ } else if (e is AppInitException .Cancelled ) {
248+ Log .e(TAG , " [STARTUP] bootstrap cancelled at step: $step " , e)
249+ bootstrapError =
250+ " App startup timed out. Please force-quit and try again.\n\n Please contact feedback@covebitcoinwallet.com"
251+ } else {
252+ Log .e(TAG , " [STARTUP] bootstrap failed at step: $step " , e)
253+ bootstrapError = e.message ? : " Unknown error"
254+ }
255+ return @LaunchedEffect
190256 }
257+
258+ completeBootstrap(warning)
191259 }
192260 }
193261 return @setContent
@@ -302,6 +370,53 @@ class MainActivity : FragmentActivity() {
302370 privacyCoverView = container
303371 }
304372
373+ private suspend fun runBootstrapWithWatchdog (
374+ onMigrationProgress : (status: String? , progress: Float? ) -> Unit ,
375+ ): String? = coroutineScope {
376+ val bootstrapDeferred = async { bootstrap() }
377+ launch { watchBootstrap(bootstrapDeferred, onMigrationProgress) }
378+ bootstrapDeferred.await()
379+ }
380+
381+ private suspend fun watchBootstrap (
382+ bootstrapDeferred : kotlinx.coroutines.Deferred <* >,
383+ onMigrationProgress : (status: String? , progress: Float? ) -> Unit ,
384+ ) {
385+ val startTime = System .currentTimeMillis()
386+ var migrationDetected = false
387+ var progressCleared = true
388+
389+ while (bootstrapDeferred.isActive) {
390+ delay(66 )
391+
392+ val step = bootstrapProgress()
393+ if (! migrationDetected && step.isMigrationInProgress) {
394+ migrationDetected = true
395+ }
396+
397+ val progress = activeMigration()?.progress()
398+ if (progress != null && progress.total > 0u ) {
399+ migrationDetected = true
400+ progressCleared = false
401+ onMigrationProgress(" Encrypting data..." , progress.current.toFloat() / progress.total.toFloat())
402+ } else if (! progressCleared) {
403+ progressCleared = true
404+ onMigrationProgress(null , null )
405+ }
406+
407+ val elapsed = System .currentTimeMillis() - startTime
408+ // longer timeout to accommodate low-end Android hardware
409+ val timeoutMs = if (migrationDetected) 60_000L else 20_000L
410+ if (elapsed >= timeoutMs && bootstrapDeferred.isActive) {
411+ Log .w(TAG , " [STARTUP] watchdog firing after ${elapsed} ms (timeout=${timeoutMs} ms, migration=$migrationDetected )" )
412+ cancelBootstrap()
413+ throw BootstrapTimeoutException ()
414+ }
415+ }
416+ }
417+
418+ private class BootstrapTimeoutException : Exception (" bootstrap timed out" )
419+
305420 companion object {
306421 /* * Delay before showing the loading spinner, in milliseconds.
307422 * Prevents a distracting spinner flash when bootstrap completes quickly */
@@ -849,7 +964,11 @@ private fun BootstrapErrorView(errorMessage: String) {
849964}
850965
851966@Composable
852- private fun SplashLoadingView (showSpinner : Boolean ) {
967+ private fun SplashLoadingView (
968+ showSpinner : Boolean ,
969+ statusMessage : String? = null,
970+ progress : Float? = null,
971+ ) {
853972 Box (
854973 modifier = Modifier .fillMaxSize().background(Color .Black ),
855974 contentAlignment = Alignment .Center ,
@@ -864,6 +983,25 @@ private fun SplashLoadingView(showSpinner: Boolean) {
864983 Spacer (modifier = Modifier .height(24 .dp))
865984 CircularProgressIndicator (color = Color .White )
866985 }
986+
987+ if (statusMessage != null ) {
988+ Spacer (modifier = Modifier .height(12 .dp))
989+ Text (
990+ statusMessage,
991+ style = MaterialTheme .typography.bodyMedium,
992+ color = Color .White .copy(alpha = 0.7f ),
993+ )
994+ }
995+
996+ if (progress != null ) {
997+ Spacer (modifier = Modifier .height(12 .dp))
998+ LinearProgressIndicator (
999+ progress = { progress },
1000+ modifier = Modifier .fillMaxWidth(0.6f ),
1001+ color = Color .White ,
1002+ trackColor = Color .White .copy(alpha = 0.2f ),
1003+ )
1004+ }
8671005 }
8681006 }
8691007}
0 commit comments