Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
29e683b
fix(sdk): keep the L1 funding gate on the height rule
HashEngineering Oct 20, 2026
4f85b93
fix(sync): release the replay wake lock while the replay cannot progress
HashEngineering Oct 20, 2026
92feae1
fix(sync): gate the bind-heal L1 start on the issuing service lifetime
HashEngineering Oct 20, 2026
96c95a1
test(sync): fix the race-dependent restart and cutover UI tests
HashEngineering Oct 20, 2026
ba72d27
docs: drop the obsolete correction request from the issues record
HashEngineering Oct 20, 2026
6138ffd
fix(sync): re-take the replay wake lock when the filter wedge clears
HashEngineering Oct 21, 2026
4637405
fix(sync): seed the NETWORK impediment from connectivity at service s…
HashEngineering Oct 21, 2026
505eb30
refactor(sync): one definition of the replay wake-lock hold rule
HashEngineering Oct 21, 2026
047acc3
fix(sync): an ended lifetime's kick cannot replace or cancel the curr…
HashEngineering Oct 21, 2026
da7e80f
fix(sync): seed connectivity after registering the network callback
HashEngineering Oct 21, 2026
0a9b03f
fix(sync): re-take the replay wake lock even when the observer misses…
HashEngineering Oct 21, 2026
3238b3a
fix(sync): give a restarted engine its own filter-stall window
HashEngineering Oct 23, 2026
c17f388
fix(sync): default-network impediment, guarded wake-lock rechecks, bi…
HashEngineering Oct 24, 2026
32a837b
fix(sdk): close the L1 funding gate while the engine waits for peers
HashEngineering Oct 24, 2026
b55f1ca
fix(sdk): close the L1 funding gate while the wallet cursor is unknown
HashEngineering Oct 24, 2026
5d6926a
fix(sync): ignore capability updates for a network that is not the de…
HashEngineering Oct 24, 2026
712e1a1
fix(sync): serialize the watchdog's event-driven clears with its stal…
HashEngineering Oct 24, 2026
0445d94
fix(sync): let only a network-callback verdict supersede the offline …
HashEngineering Oct 24, 2026
a9dd29c
fix(sync): read the replay wake lock's other holds live, not from the…
HashEngineering Oct 26, 2026
ddc76e2
Merge feat/kotlin-sdk-phase-1 (#1616) into fix/phase1-base-followups
HashEngineering Oct 27, 2026
5197263
test(sdk): an unknown wallet cursor is refused before a deferred buil…
HashEngineering Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions docs/kotlin-sdk-issues-to-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,6 @@ Requests:
to return. `wallet_checker.rs` already holds the fully-keyed `AccountType`, so either it
does the lookup itself, or it passes that keyed value down — but the fieldless helper cannot
be the place the decision is made.

(An earlier revision of this section asked for exactly that helper change. The note at the
top of this issue corrected the root cause; this bullet had not been brought in line, so the
issue as filed still points implementers at an API change that cannot fix the defect.
rust-dashcore#1032 needs a comment saying so.)
- Replace the hardcoded `20` with a named constant. Note `DEFAULT_CONTACT_GAP_LIMIT = 10`
exists in `rs-platform-wallet/src/wallet/identity/crypto/dip14.rs:254` (quoting DIP-15's
recommendation) but has **no consumer** — neither it nor `derive_contact_payment_address`
Expand Down
182 changes: 106 additions & 76 deletions wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions wallet/src/de/schildbach/wallet/service/NetworkAvailability.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright 2026 Dash Core Group.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package de.schildbach.wallet.service

import android.net.ConnectivityManager
import android.net.NetworkCapabilities

/**
* Whether connectivity AS IT STANDS NOW offers a network the blockchain
* service can sync over: the default network, with internet and validated —
* the same rule the service's default-network callback applies
* ([DefaultNetworkTracker]).
*
* The service seeds its NETWORK impediment from this once that callback is
* registered ([ImpedimentGate.seedOffline]). The callback reports nothing
* while there is no default network (no `onAvailable`, `onLost` or
* `onCapabilitiesChanged`; `onUnavailable` is for `requestNetwork`), so a
* service created while already offline used to read as online until some
* network came and went. Never throws: an unreadable state answers "usable",
* the old assumption, so this can only ever add an impediment the platform
* confirmed.
*/
fun hasUsableNetworkNow(connectivityManager: ConnectivityManager): Boolean = try {
val capabilities = connectivityManager.activeNetwork?.let(connectivityManager::getNetworkCapabilities)
capabilities != null &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} catch (t: Throwable) {
true
}

/**
* The NETWORK impediment from a DEFAULT-network callback
* (`registerDefaultNetworkCallback`): offline unless the default network — the
* route the service's own traffic takes — has internet and is validated.
*
* Review on #1632 (CodeRabbit, PastaClaw): the callback used to be registered
* for ANY network with internet + validated. A validated secondary network
* then cleared the impediment while the default route was unusable, and the
* replay kept its no-timeout wake lock offline. The reverse also happened:
* one matching network losing validation set the impediment while another
* usable one was still up. The default network is one network, so each event
* about it is the whole answer.
*
* Each method returns the verdict: true offline, false online, null when the
* event decides nothing (a loss of a network that is no longer the default —
* its successor's `onAvailable` may already have been delivered — or an
* `onAvailable` whose capabilities could not be read; `onCapabilitiesChanged`
* follows it). Called in delivery order, one at a time.
*/
internal class DefaultNetworkTracker<N : Any> {
private var current: N? = null

fun onAvailable(network: N, usable: Boolean?): Boolean? {
current = network
return usable?.not()
}

fun onCapabilitiesChanged(network: N, usable: Boolean): Boolean {
current = network
return !usable
Comment thread
HashEngineering marked this conversation as resolved.
Resolved
}

fun onLost(network: N): Boolean? {
if (network != current) return null
current = null
return true
}

fun clear() {
current = null
}
}

/**
* Serializes every change to the blockchain service's impediment set, and
* decides the one-off connectivity seed at service start.
*
* The set is a plain `EnumSet` changed from the network callback's coroutines
* (IO) and the keystore health observer (main); [update] and
* [networkCallbackUpdate] make each change atomic.
*
* The seed: reading connectivity BEFORE registering the callback left a gap —
* a network lost between the read and the registration was never reported
* (nothing matched any more), and the service read as online. So the read
* comes AFTER registration, where it must not overwrite what the callback has
* already said: every callback update bumps a counter, the token is taken
* just before registering, and [seedOffline] marks the service offline only if
* no callback update landed since. A default network that is present after
* the registration is reported by the callback (`onAvailable`, later
* `onLost`), so the seed only ever decides the "no default network" case the
* callback stays silent on.
*/
internal class ImpedimentGate {
private val lock = Any()
private var networkCallbackUpdates = 0L

/** A change that is not a network-callback update (the keystore health observer). */
fun <T> update(block: () -> T): T = synchronized(lock, block)

/** A network-callback update; it supersedes the start-up seed. */
fun <T> networkCallbackUpdate(block: () -> T): T = synchronized(lock) {
networkCallbackUpdates++
block()
Comment thread
HashEngineering marked this conversation as resolved.
Resolved
}

/** Taken just BEFORE `registerNetworkCallback`. */
fun seedToken(): Long = synchronized(lock) { networkCallbackUpdates }

/**
* After registration, with [usableNow] read after it too: mark the service
* offline ([markOffline], under the lock) unless a network is usable or a
* callback update has landed since [token]. A stale "not usable" read is
* harmless: a network that appeared before it is reported by the callback,
* and its update either landed first (the seed is skipped) or lands after
* (and clears the impediment).
*/
fun seedOffline(token: Long, usableNow: Boolean, markOffline: () -> Unit): Boolean = synchronized(lock) {
if (usableNow || networkCallbackUpdates != token) return false
markOffline()
true
}
}
167 changes: 167 additions & 0 deletions wallet/src/de/schildbach/wallet/service/ReplayWakeLock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright 2026 Dash Core Group.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package de.schildbach.wallet.service

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory

/** The slice of `PowerManager.WakeLock` the replay hold uses — a seam for host tests. */
interface ReplayWakeLockHandle {
val isHeld: Boolean
fun acquire()
fun release()
}

/**
* The SDK replay's own acquisition of the blockchain service's partial wake
* lock (no timeout).
*
* On the SDK path no peergroup start acquires the lock, so a replay that the
* idle rule keeps alive could still be dozed. The lock is held for the span
* the service is kept alive for, minus the stretches the replay cannot
* progress ([replayHoldsWakeLock]: offline, or wedged with the filter-stall
* watchdog's restarts spent).
*
* Who decides: everyone, the same way. The minute tick ([tick]) and every
* event that can change the answer ([recheck]: an impediment change, the
* filter wedge clearing via [observeWedgeClears]) read the decision NOW and
* acquire or release to match, under one monitor. ACTION_TIME_TICK is not
* delivered while the CPU sleeps, so a replay that can move again must not
* wait for the tick to get the lock back.
*
* The read and the change happen under the same monitor, and so does each
* later recheck. A recheck that read "due" just before a concurrent network
* loss can still acquire. But the loss's own recheck then runs after it, reads
* "not due", and releases. (An acquire-only re-check used to leave that lock
* held until the next tick, so the replay held the CPU while offline.)
*
* The tick also knows holds the events cannot see (an engine start, a durable
* save in flight). It records them, and a recheck honours the last recorded
* value. A stale one only keeps the lock, and a held lock keeps the CPU awake,
* so the next tick comes and corrects it.
*
* Tracked apart from `isHeld` because the lock is reference-counted and shared
* with the dashj path ([releaseForTeardown] releases only this owner's count).
*
* @param lock the service's wake lock; null before onCreate assigns it.
* @param sdkPath whether the SDK owns L1 (the dashj path manages its own hold).
* @param replayDue the replay half of the hold decision, read now.
*/
internal class ReplayWakeLock(
private val lock: () -> ReplayWakeLockHandle?,
private val sdkPath: () -> Boolean,
private val replayDue: () -> Boolean
) {
private val log = LoggerFactory.getLogger(ReplayWakeLock::class.java)
private val guard = Any()

@Volatile
var held: Boolean = false
private set

/**
* Set first thing in onDestroy: from then on the lock is only ever released.
* A network-callback coroutine launched just before the callback was
* unregistered can still run, and its re-acquire landing after the
* teardown's release would hold a no-timeout lock until the process died.
*/
@Volatile
private var retired = false

/** The tick's holds the events cannot read; written and read under [guard]. */
private var tickHolds = false

/**
* The minute tick. [otherHolds]: an engine start or a durable save needs
* the CPU (the replay's own part is read by [recheck]).
*/
fun tick(otherHolds: Boolean) {
synchronized(guard) { tickHolds = otherHolds }
recheck()
}

/** Hold the lock iff the replay (or the tick's last other holds) needs the CPU now. */
fun recheck() {
if (!sdkPath()) return
val lock = lock() ?: return
synchronized(guard) {
try {
val due = tickHolds || replayDue()
if (due && !held && !retired) {
log.info("replay or engine start in progress on the SDK path — acquiring the wake lock")
lock.acquire()
held = true
} else if (!due && held) {
log.info("replay / engine start complete or unable to progress — releasing the wake lock")
held = false
if (lock.isHeld) lock.release()
}
} catch (t: Throwable) {
log.warn("wake lock hold/release for the replay failed", t)
}
}
}

/**
* Re-check every time the filter wedge clears: the cursor moved, wallet
* events resumed, the scan caught up, or the engine stopped.
*
* [clears] COUNTS the clears rather than mirroring the flag. A collector
* that is slow to run can miss intermediate values of a StateFlow, and on a
* Boolean that loses events: clear → re-wedge → clear arrives as the
* "not wedged" it already saw, while the minute tick released the lock
* during the re-wedge — and no re-check came. A count it has not seen
* always means a clear happened, so it re-checks. The baseline is read when
* this is CALLED, not when the collector first runs, so a clear in between
* is not taken for the starting value. [recheck] reads the current
* state, so a clear that was followed by a new wedge acquires nothing.
*/
fun observeWedgeClears(scope: CoroutineScope, clears: StateFlow<Long>): Job {
val baseline = clears.value
return scope.launch {
clears.filter { it != baseline }
.collect {
log.info("the filter wedge cleared — re-checking the replay wake lock")
recheck()
}
}
}

/** onDestroy, first thing: no acquisition after this. */
fun retire() {
retired = true
}

/** onDestroy, cleanup: release this owner's count, if any. */
fun releaseForTeardown() {
val lock = lock() ?: return
synchronized(guard) {
if (held) {
held = false
if (lock.isHeld) {
log.debug("releasing the replay's wake-lock acquisition")
lock.release()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ fun engineStartHoldsService(
capMs: Long = ENGINE_START_KEEPALIVE_MS
): Boolean = startingSinceMs > 0L && nowMs - startingSinceMs < capMs

/**
* Whether an SDK replay should hold the partial wake lock. The idle guard
* keeps the service up for the whole replay, and that is still right when the
* replay cannot move: stopping it re-walks from the durable watermark on the
* next start. The CPU is another matter. The lock has no timeout, so a replay
* that cannot progress held it for as long as it stayed stuck: offline
* ([networkImpeded]: the engine waits for connections, as dashj did after
* `stopPeerGroup` released its own lock), or wedged with the filter-stall
* watchdog's restarts spent ([filterScanWedged]). Pure — host-testable.
*/
fun replayHoldsWakeLock(
replaying: Boolean,
networkImpeded: Boolean,
filterScanWedged: Boolean
): Boolean = replaying && !networkImpeded && !filterScanWedged

/**
* How long after the service is created the SDK engine start may still be
* pending — not yet begun, so [engineStartHoldsService] cannot see it — and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ class PlatformSynchronizationService @Inject constructor(
scope = syncScope,
bindEstablished = sdkWalletBinder.bindEstablished,
serviceTearingDown = { de.schildbach.wallet.service.BlockchainServiceImpl.isCleaningUpNow },
startL1 = { l1ShadowSyncService.startIfEnabled() }
stopGeneration = { l1ShadowSyncService.stopGeneration() },
startL1 = { generation -> l1ShadowSyncService.startIfEnabled(expectedExternalStopGeneration = generation) }
)
private var lastTopupUpdateTime = 0L
private var lastMetadataUpdateTime = 0L
Expand Down Expand Up @@ -547,6 +548,9 @@ class PlatformSynchronizationService @Inject constructor(
* [shutdown] also cancels the kick outright.
*/
private val serviceLifetime = AtomicLong(0)
// Written by the kick (init/resume), read and cancelled by shutdown and
// stopSdkEngines on other threads.
@Volatile
private var startupKickJob: Job? = null

private fun kickSdkEngines(startL1Engine: Boolean) {
Expand Down Expand Up @@ -591,7 +595,16 @@ class PlatformSynchronizationService @Inject constructor(
// Declined — most often "no wallet bound" after a failed startup
// bind pass. The unlock receiver and the retry ladder only bind;
// this is what starts L1 when they succeed (review, 2026-09-23).
if (started) bindHealL1Starter.cancel() else bindHealL1Starter.arm()
// A shutdown and even the next service start can run between the
// lifetime check above and this line (startIfEnabled() has
// returned, and arm()/cancel() do not suspend, so cancelling this
// kick does not stop it). Both calls therefore re-check the
// lifetime under the starter's monitor, and the waiter re-checks
// it after the heal: a kick of an ended lifetime can neither arm a
// waiter that outlives its service nor replace or cancel the
// waiter the current lifetime armed.
val stillCurrent = { serviceLifetime.get() == lifetime }
if (started) bindHealL1Starter.cancel(stillCurrent) else bindHealL1Starter.arm(stillCurrent)
}
// Phase 5d follow-up: the post-cutover UI data source (balance
// header / tx list / coins-received detection served from the
Expand Down
Loading
Loading