Skip to content

Commit b5581f1

Browse files
committed
refactor: optimize sync handling and PTR UX #511
1 parent 76d713f commit b5581f1

File tree

7 files changed

+118
-51
lines changed

7 files changed

+118
-51
lines changed

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import kotlinx.coroutines.flow.asStateFlow
2121
import kotlinx.coroutines.flow.first
2222
import kotlinx.coroutines.flow.update
2323
import kotlinx.coroutines.launch
24+
import kotlinx.coroutines.sync.Mutex
2425
import kotlinx.coroutines.tasks.await
2526
import kotlinx.coroutines.withContext
26-
import kotlinx.coroutines.withTimeout
2727
import kotlinx.coroutines.withTimeoutOrNull
2828
import org.lightningdevkit.ldknode.Address
2929
import org.lightningdevkit.ldknode.BalanceDetails
@@ -61,9 +61,13 @@ import to.bitkit.services.NodeEventHandler
6161
import to.bitkit.utils.AppError
6262
import to.bitkit.utils.Logger
6363
import to.bitkit.utils.ServiceError
64+
import to.bitkit.utils.errLogOf
65+
import to.bitkit.utils.measured
6466
import java.util.concurrent.ConcurrentHashMap
67+
import java.util.concurrent.atomic.AtomicBoolean
6568
import javax.inject.Inject
6669
import javax.inject.Singleton
70+
import kotlin.coroutines.cancellation.CancellationException
6771
import kotlin.time.Duration
6872
import kotlin.time.Duration.Companion.minutes
6973
import kotlin.time.Duration.Companion.seconds
@@ -96,6 +100,9 @@ class LightningRepo @Inject constructor(
96100

97101
private val channelCache = ConcurrentHashMap<String, ChannelDetails>()
98102

103+
private val syncMutex = Mutex()
104+
private val syncPending = AtomicBoolean(false)
105+
99106
/**
100107
* Executes the provided operation only if the node is running.
101108
* If the node is not running, waits for it to be running for a specified timeout.
@@ -152,6 +159,9 @@ class LightningRepo @Inject constructor(
152159
): Result<T> {
153160
return try {
154161
operation()
162+
} catch (e: CancellationException) {
163+
// Cancellation is expected during pull-to-refresh, rethrow per Kotlin best practices
164+
throw e
155165
} catch (e: Throwable) {
156166
Logger.error("$operationName error", e, context = TAG)
157167
Result.failure(e)
@@ -300,24 +310,39 @@ class LightningRepo @Inject constructor(
300310
}
301311

302312
suspend fun sync(): Result<Unit> = executeWhenNodeRunning("Sync") {
303-
syncState()
304-
if (_lightningState.value.isSyncingWallet) {
305-
Logger.verbose("Sync already in progress, waiting for existing sync.", context = TAG)
313+
// If sync is in progress, mark pending and skip
314+
if (!syncMutex.tryLock()) {
315+
syncPending.set(true)
316+
Logger.verbose("Sync in progress, pending sync marked", context = TAG)
317+
return@executeWhenNodeRunning Result.success(Unit)
306318
}
307319

308-
withTimeout(SYNC_TIMEOUT_MS) {
309-
_lightningState.first { !it.isSyncingWallet }
320+
Logger.debug("Sync started", context = TAG)
321+
try {
322+
measured("Sync") {
323+
do {
324+
syncPending.set(false)
325+
_lightningState.update { it.copy(isSyncingWallet = true) }
326+
lightningService.sync()
327+
refreshChannelCache()
328+
syncState()
329+
if (syncPending.get()) delay(SYNC_LOOP_DEBOUNCE_MS)
330+
} while (syncPending.getAndSet(false))
331+
}
332+
} finally {
333+
_lightningState.update { it.copy(isSyncingWallet = false) }
334+
syncMutex.unlock()
310335
}
311-
312-
_lightningState.update { it.copy(isSyncingWallet = true) }
313-
lightningService.sync()
314-
refreshChannelCache()
315-
syncState()
316-
_lightningState.update { it.copy(isSyncingWallet = false) }
336+
Logger.debug("Sync completed", context = TAG)
317337

318338
Result.success(Unit)
319339
}
320340

341+
/** Clear pending sync flag. Called when manual pull-to-refresh takes priority. */
342+
fun clearPendingSync() {
343+
syncPending.set(false)
344+
}
345+
321346
private suspend fun refreshChannelCache() = withContext(bgDispatcher) {
322347
val channels = lightningService.channels ?: return@withContext
323348
channels.forEach { channel ->
@@ -756,9 +781,11 @@ class LightningRepo @Inject constructor(
756781
utxosToSpend = utxosToSpend,
757782
)
758783
Result.success(fee)
759-
} catch (_: Throwable) {
784+
} catch (e: CancellationException) {
785+
throw e
786+
} catch (e: Throwable) {
760787
val fallbackFee = 1000uL
761-
Logger.warn("Error calculating fee, using fallback of $fallbackFee", context = TAG)
788+
Logger.warn("Error calculating fee, using fallback of $fallbackFee ${errLogOf(e)}", context = TAG)
762789
Result.success(fallbackFee)
763790
}
764791
}
@@ -772,7 +799,9 @@ class LightningRepo @Inject constructor(
772799
val satsPerVByte = fees.getSatsPerVByteFor(speed)
773800
satsPerVByte.toULong()
774801
}.onFailure { e ->
775-
Logger.error("Error getFeeRateForSpeed. speed:$speed", e, context = TAG)
802+
if (e !is CancellationException) {
803+
Logger.error("Error getFeeRateForSpeed. speed:$speed", e, context = TAG)
804+
}
776805
}
777806
}
778807

@@ -989,8 +1018,8 @@ class LightningRepo @Inject constructor(
9891018

9901019
companion object {
9911020
private const val TAG = "LightningRepo"
992-
private const val SYNC_TIMEOUT_MS = 20_000L
9931021
private const val CHANNEL_ID_PREVIEW_LENGTH = 10
1022+
private const val SYNC_LOOP_DEBOUNCE_MS = 500L
9941023
}
9951024
}
9961025

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import com.synonym.bitkitcore.Scanner
66
import com.synonym.bitkitcore.decode
77
import kotlinx.coroutines.CoroutineDispatcher
88
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Job
910
import kotlinx.coroutines.SupervisorJob
1011
import kotlinx.coroutines.TimeoutCancellationException
12+
import kotlinx.coroutines.delay
1113
import kotlinx.coroutines.flow.MutableStateFlow
1214
import kotlinx.coroutines.flow.asStateFlow
1315
import kotlinx.coroutines.flow.first
@@ -33,8 +35,10 @@ import to.bitkit.usecases.WipeWalletUseCase
3335
import to.bitkit.utils.Bip21Utils
3436
import to.bitkit.utils.Logger
3537
import to.bitkit.utils.ServiceError
38+
import to.bitkit.utils.errLogOf
3639
import javax.inject.Inject
3740
import javax.inject.Singleton
41+
import kotlin.coroutines.cancellation.CancellationException
3842

3943
@Suppress("LongParameterList")
4044
@Singleton
@@ -57,6 +61,8 @@ class WalletRepo @Inject constructor(
5761
private val _balanceState = MutableStateFlow(BalanceState())
5862
val balanceState = _balanceState.asStateFlow()
5963

64+
private var eventSyncJob: Job? = null
65+
6066
init {
6167
repoScope.launch {
6268
lightningRepo.nodeEvents.collect { event ->
@@ -185,11 +191,28 @@ class WalletRepo @Inject constructor(
185191
deriveBalanceStateUseCase().onSuccess { balanceState ->
186192
runCatching { cacheStore.cacheBalance(balanceState) }
187193
_balanceState.update { balanceState }
188-
}.onFailure {
189-
Logger.warn("Could not sync balances", context = TAG)
194+
}.onFailure { e ->
195+
if (e !is CancellationException) {
196+
Logger.warn("Could not sync balances ${errLogOf(e)}", context = TAG)
197+
}
198+
}
199+
}
200+
201+
/** Debounce syncs for [Event.SyncCompleted]. Rapid consecutive events are coalesced. */
202+
fun debounceSyncByEvent() {
203+
eventSyncJob?.cancel()
204+
eventSyncJob = repoScope.launch {
205+
delay(EVENT_SYNC_DEBOUNCE_MS)
206+
syncNodeAndWallet()
190207
}
191208
}
192209

210+
/** Cancels any pending sync for [Event.SyncCompleted]. Called when manual pull-to-refresh takes priority. */
211+
fun cancelSyncByEvent() {
212+
eventSyncJob?.cancel()
213+
eventSyncJob = null
214+
}
215+
193216
suspend fun refreshBip21ForEvent(event: Event) = withContext(bgDispatcher) {
194217
when (event) {
195218
is Event.ChannelReady -> {
@@ -574,6 +597,7 @@ class WalletRepo @Inject constructor(
574597

575598
private companion object {
576599
const val TAG = "WalletRepo"
600+
const val EVENT_SYNC_DEBOUNCE_MS = 500L
577601
}
578602
}
579603

app/src/main/java/to/bitkit/ui/components/ToastView.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,6 @@ private fun ToastViewPreview() {
312312
ScreenColumn(
313313
verticalArrangement = Arrangement.spacedBy(16.dp),
314314
) {
315-
316315
ToastView(
317316
toast = Toast(
318317
type = Toast.ToastType.WARNING,

app/src/main/java/to/bitkit/ui/shared/modifiers/ClickableAlpha.kt

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,28 +61,31 @@ private class ClickableAlphaNode(
6161
private val animatable = Animatable(1f)
6262

6363
init {
64-
delegate(SuspendingPointerInputModifierNode {
65-
detectTapGestures(
66-
onPress = {
67-
coroutineScope.launch { animatable.animateTo(pressedAlpha) }
68-
val released = tryAwaitRelease()
69-
if (!released) {
70-
coroutineScope.launch { animatable.animateTo(1f) }
64+
delegate(
65+
SuspendingPointerInputModifierNode {
66+
detectTapGestures(
67+
onPress = {
68+
coroutineScope.launch { animatable.animateTo(pressedAlpha) }
69+
val released = tryAwaitRelease()
70+
if (!released) {
71+
coroutineScope.launch { animatable.animateTo(1f) }
72+
}
73+
},
74+
onTap = {
75+
onClick()
76+
coroutineScope.launch {
77+
animatable.animateTo(pressedAlpha)
78+
animatable.animateTo(1f)
79+
}
7180
}
72-
},
73-
onTap = {
74-
onClick()
75-
coroutineScope.launch {
76-
animatable.animateTo(pressedAlpha)
77-
animatable.animateTo(1f)
78-
}
79-
}
80-
)
81-
})
81+
)
82+
}
83+
)
8284
}
8385

8486
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
8587
val placeable = measurable.measure(constraints)
88+
8689
return layout(placeable.width, placeable.height) {
8790
placeable.placeWithLayer(0, 0) {
8891
this.alpha = animatable.value
@@ -92,6 +95,9 @@ private class ClickableAlphaNode(
9295

9396
override fun SemanticsPropertyReceiver.applySemantics() {
9497
role = Role.Button
95-
onClick(action = { onClick(); true })
98+
onClick {
99+
onClick()
100+
true
101+
}
96102
}
97103
}

app/src/main/java/to/bitkit/utils/Logger.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ class LoggerImpl(
142142
path: String = getCallerPath(),
143143
line: Int = getCallerLine(),
144144
) {
145-
val errMsg = e?.let { "[${e::class.simpleName}='${e.message}']" }.orEmpty()
145+
val errMsg = e?.let { errLogOf(it) }.orEmpty()
146146
val message = formatLog(LogLevel.WARN, "$msg $errMsg", context, path, line)
147147
if (compact) Log.w(tag, message) else Log.w(tag, message, e)
148148
saver.save(message)
@@ -155,7 +155,7 @@ class LoggerImpl(
155155
path: String = getCallerPath(),
156156
line: Int = getCallerLine(),
157157
) {
158-
val errMsg = e?.let { "[${e::class.simpleName}='${e.message}']" }.orEmpty()
158+
val errMsg = e?.let { errLogOf(it) }.orEmpty()
159159
val message = formatLog(LogLevel.ERROR, "$msg $errMsg", context, path, line)
160160
if (compact) Log.e(tag, message) else Log.e(tag, message, e)
161161
saver.save(message)
@@ -339,3 +339,5 @@ val jsonLogger = Json(json) {
339339
inline fun <reified T> jsonLogOf(value: T): String = with(jsonLogger) {
340340
encodeToString(serializersModule.serializer(), value)
341341
}
342+
343+
fun errLogOf(e: Throwable): String = "[${e::class.simpleName}='${e.message}']"

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -269,28 +269,22 @@ class AppViewModel @Inject constructor(
269269
}
270270
}
271271

272-
private suspend fun handleBalanceChanged() {
273-
walletRepo.syncBalances()
274-
}
272+
private suspend fun handleBalanceChanged() = walletRepo.syncBalances()
275273

276274
private suspend fun handleChannelReady(event: Event.ChannelReady) {
277275
transferRepo.syncTransferStates()
278276
walletRepo.syncBalances()
279277
notifyChannelReady(event)
280278
}
281279

282-
private suspend fun handleChannelPending() {
283-
transferRepo.syncTransferStates()
284-
}
280+
private suspend fun handleChannelPending() = transferRepo.syncTransferStates()
285281

286282
private suspend fun handleChannelClosed() {
287283
transferRepo.syncTransferStates()
288284
walletRepo.syncBalances()
289285
}
290286

291-
private suspend fun handleSyncCompleted() {
292-
walletRepo.syncNodeAndWallet()
293-
}
287+
private fun handleSyncCompleted() = walletRepo.debounceSyncByEvent()
294288

295289
private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) {
296290
activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details)

app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
77
import androidx.lifecycle.viewModelScope
88
import dagger.hilt.android.lifecycle.HiltViewModel
99
import kotlinx.coroutines.CoroutineDispatcher
10+
import kotlinx.coroutines.Job
1011
import kotlinx.coroutines.TimeoutCancellationException
1112
import kotlinx.coroutines.delay
1213
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -34,6 +35,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus
3435
import to.bitkit.utils.Logger
3536
import to.bitkit.utils.ServiceError
3637
import javax.inject.Inject
38+
import kotlin.coroutines.cancellation.CancellationException
3739
import kotlin.time.Duration.Companion.milliseconds
3840

3941
@HiltViewModel
@@ -68,6 +70,8 @@ class WalletViewModel @Inject constructor(
6870
val walletEffect = _walletEffect.asSharedFlow()
6971
private fun walletEffect(effect: WalletViewModelEffects) = viewModelScope.launch { _walletEffect.emit(effect) }
7072

73+
private var syncJob: Job? = null
74+
7175
init {
7276
if (walletExists) {
7377
walletRepo.loadFromCache()
@@ -171,6 +175,7 @@ class WalletViewModel @Inject constructor(
171175
fun refreshState() = viewModelScope.launch {
172176
walletRepo.syncNodeAndWallet()
173177
.onFailure { error ->
178+
if (error is CancellationException) return@onFailure
174179
Logger.error("Failed to refresh state: ${error.message}", error)
175180
if (error !is TimeoutCancellationException) {
176181
ToastEventBus.send(error)
@@ -179,10 +184,18 @@ class WalletViewModel @Inject constructor(
179184
}
180185

181186
fun onPullToRefresh() {
182-
viewModelScope.launch {
187+
// Cancel any existing sync, manual or event triggered
188+
syncJob?.cancel()
189+
walletRepo.cancelSyncByEvent()
190+
lightningRepo.clearPendingSync()
191+
192+
syncJob = viewModelScope.launch {
183193
_uiState.update { it.copy(isRefreshing = true) }
184-
refreshState().join()
185-
_uiState.update { it.copy(isRefreshing = false) }
194+
try {
195+
walletRepo.syncNodeAndWallet()
196+
} finally {
197+
_uiState.update { it.copy(isRefreshing = false) }
198+
}
186199
}
187200
}
188201

0 commit comments

Comments
 (0)