lib: add more unit and E2E tests

This commit is contained in:
Oscar Mira 2025-05-26 19:41:53 +02:00
parent 40ca4b4237
commit 0d7f98b622
No known key found for this signature in database
GPG key ID: B371B98C5DC32237
28 changed files with 592 additions and 155 deletions

View file

@ -39,7 +39,8 @@ internal fun Project.configureJacoco(
androidComponentsExtension.onVariants { variant ->
val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allJars: ListProperty<RegularFile> =
myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask =
@ -47,7 +48,6 @@ internal fun Project.configureJacoco(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
classDirectories.setFrom(
allJars,
allDirectories.map { dirs ->

View file

@ -0,0 +1,84 @@
package im.molly.monero
import com.google.common.truth.FailureMetadata
import com.google.common.truth.Subject
import com.google.common.truth.Truth.assertAbout
class LedgerChainSubject private constructor(
metadata: FailureMetadata,
private val actual: List<Ledger>,
) : Subject(metadata, actual) {
companion object {
fun assertThat(ledgerChain: List<Ledger>): LedgerChainSubject {
return assertAbout(factory).that(ledgerChain)
}
private val factory = Factory { metadata, actual: List<Ledger> ->
LedgerChainSubject(metadata, actual)
}
}
fun hasValidWalletHistory() {
isNotEmpty()
hasStableOrGrowingAccountSets()
hasStableOrGrowingTransactionSets()
hasStableOrGrowingKeyImageSets()
allPublicAddressesMatch()
}
fun isNotEmpty() {
check("ledgers").that(actual).isNotEmpty()
}
fun hasStableOrGrowingAccountSets() {
actual.ledgerTransitionsByHeight { step, prev, next ->
val prevAccByIndex = prev.indexedAccounts.associateBy { it.accountIndex }
val nextAccByIndex = next.indexedAccounts.associateBy { it.accountIndex }
prevAccByIndex.forEach { (accIndex, prevAccount) ->
val nextAccount = nextAccByIndex[accIndex]
val subjectPath = "Ledger[$step${step + 1}].indexedAccounts[$accIndex]"
check(subjectPath).that(nextAccount).isNotNull()
if (nextAccount != null) {
val missingAddresses =
prevAccount.addresses.toSet() - nextAccount.addresses.toSet()
check("missing addresses in $subjectPath").that(missingAddresses).isEmpty()
}
}
}
}
fun hasStableOrGrowingTransactionSets() {
actual.ledgerTransitionsByHeight { step, prev, next ->
val missingTxIds = prev.transactionById.keys - next.transactionById.keys
val subjectPath = "Ledger[$step${step + 1}].transactionById"
check("$subjectPath: missing transaction set").that(missingTxIds).isEmpty()
}
}
fun hasStableOrGrowingKeyImageSets() {
actual.ledgerTransitionsByHeight { step, prev, next ->
val missingKI = prev.keyImages - next.keyImages
val subjectPath = "Ledger[$step${step + 1}].keyImages"
check("$subjectPath: missing key images: $missingKI").that(missingKI).isEmpty()
}
}
fun allPublicAddressesMatch() {
val addresses = actual.map { it.publicAddress }.distinct()
check("publicAddresses").that(addresses).hasSize(1)
}
private fun List<Ledger>.ledgerTransitionsByHeight(
action: (step: Int, prev: Ledger, next: Ledger) -> Unit
) {
this.sortedBy { it.checkedAt.height }
.zipWithNext()
.forEachIndexed { step, (prev, next) -> action(step, prev, next) }
}
}

View file

@ -0,0 +1,41 @@
package im.molly.monero
import com.google.common.truth.FailureMetadata
import com.google.common.truth.Subject
import com.google.common.truth.Truth.assertAbout
import java.math.BigDecimal
class LedgerSubject private constructor(
metadata: FailureMetadata,
private val actual: Ledger,
) : Subject(metadata, actual) {
companion object {
fun assertThat(ledgerChain: Ledger): LedgerSubject {
return assertAbout(factory).that(ledgerChain)
}
private val factory = Factory { metadata, actual: Ledger ->
LedgerSubject(metadata, actual)
}
}
fun isConsistent() {
balanceIsNonNegative()
}
fun balanceIsNonNegative() {
actual.indexedAccounts.forEach { account ->
val accountIndex = account.accountIndex
val balance = actual.getBalanceForAccount(accountIndex)
val pending = balance.pendingAmount.xmr
val confirmed = balance.confirmedAmount.xmr
check("indexedAccounts[$accountIndex].pendingAmount.xmr").that(pending)
.isAtLeast(BigDecimal.ZERO)
check("indexedAccounts[$accountIndex].confirmedAmount.xmr").that(confirmed)
.isAtLeast(BigDecimal.ZERO)
}
}
}

View file

@ -0,0 +1,104 @@
package im.molly.monero.e2etest
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertThat
import im.molly.monero.InMemoryWalletDataStore
import im.molly.monero.Mainnet
import im.molly.monero.MoneroWalletSubject
import im.molly.monero.RestorePoint
import im.molly.monero.exceptions.NoSuchAccountException
import im.molly.monero.mnemonics.MoneroMnemonic
import im.molly.monero.mnemonics.toSecretKey
import im.molly.monero.service.BaseWalletService
import im.molly.monero.service.InProcessWalletService
import im.molly.monero.service.SandboxedWalletService
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
abstract class WalletPersistenceTest(
serviceClass: Class<out BaseWalletService>,
) : WalletTestBase(serviceClass) {
@Test
fun restoredWalletHasExpectedAddress() = runTest {
val key =
MoneroMnemonic.recoverEntropy(
"velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted"
)?.toSecretKey()
val wallet = walletProvider.restoreWallet(
network = Mainnet,
secretSpendKey = key ?: error("recoverEntropy failed"),
restorePoint = RestorePoint.creationTime(Instant.now()),
)
assertThat(wallet.publicAddress.address)
.isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
}
@Test
fun saveToMultipleStores() = runTest {
val defaultStore = InMemoryWalletDataStore()
val wallet = wallet(defaultStore)
wallet.save()
val newStore = InMemoryWalletDataStore()
wallet.save(newStore)
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
}
@Test
fun getAllAccountsReturnsAll() = runTest {
val wallet = wallet().apply {
repeat(2) { createAccount() }
createSubAddressForAccount(1)
}
val allAccounts = wallet.getAllAccounts()
assertThat(allAccounts).hasSize(3)
assertThat(allAccounts).containsExactlyElementsIn(
(0..2).map { wallet.getAccount(it) }
)
val addresses = allAccounts.map { it.addresses }
assertThat(addresses[0]).hasSize(1)
assertThat(addresses[1]).hasSize(2)
assertThat(addresses[2]).hasSize(1)
allAccounts.forEach { acc ->
val primary = acc.addresses[0]
assertThat(primary.isPrimaryAddress).isTrue()
assertThat(primary.subAddressIndex).isEqualTo(0)
if (acc.accountIndex == 1) {
val sub = acc.addresses[1]
assertThat(sub.isPrimaryAddress).isFalse()
assertThat(sub.subAddressIndex).isEqualTo(1)
}
}
withReopenedWallet(wallet) { original, reopened ->
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
}
}
@Test(expected = NoSuchAccountException::class)
fun getAccountThrowsForMissingAccount() = runTest {
wallet().getAccount(accountIndex = 42)
}
@Test(expected = NoSuchAccountException::class)
fun createSubAddressForAccountThrowsForMissingAccount() = runTest {
wallet().createSubAddressForAccount(accountIndex = 42)
}
@Test(expected = NoSuchAccountException::class)
fun findUnusedSubAddressThrowsForMissingAccount() = runTest {
wallet().findUnusedSubAddress(accountIndex = 42)
}
}
@LargeTest
class WalletPersistenceInProcessTest : WalletPersistenceTest(InProcessWalletService::class.java)
@LargeTest
class WalletPersistenceSandboxedTest : WalletPersistenceTest(SandboxedWalletService::class.java)

View file

@ -0,0 +1,66 @@
package im.molly.monero.e2etest
import androidx.test.filters.LargeTest
import im.molly.monero.LedgerChainSubject
import im.molly.monero.Mainnet
import im.molly.monero.MoneroWalletSubject
import im.molly.monero.RemoteNode
import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey
import im.molly.monero.service.BaseWalletService
import im.molly.monero.service.InProcessWalletService
import im.molly.monero.service.SandboxedWalletService
import im.molly.monero.singleNodeClient
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalStdlibApi::class)
abstract class WalletRefreshTest(
serviceClass: Class<out BaseWalletService>,
) : WalletTestBase(serviceClass) {
@Test
fun restoredWalletEmitsExpectedLedgerOnRefresh(): Unit = runBlocking {
val key =
SecretKey("148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e".hexToByteArray())
val node = RemoteNode("http://node.monerodevs.org:18089", Mainnet)
val restorePoint = RestorePoint.blockHeight(2861767)
val wallet = walletProvider.restoreWallet(
network = Mainnet,
client = node.singleNodeClient(),
secretSpendKey = key,
restorePoint = restorePoint,
)
val refreshJob = launch {
wallet.awaitRefresh()
}
val ledgers = withTimeout(5.minutes) {
wallet.ledger()
.takeWhile { it.checkedAt.height < 2862121 }
.toList()
}
refreshJob.cancelAndJoin()
LedgerChainSubject.assertThat(ledgers).hasValidWalletHistory()
withReopenedWallet(wallet) { original, reopened ->
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
}
}
}
@LargeTest
class WalletRefreshInProcessTest : WalletRefreshTest(InProcessWalletService::class.java)
@LargeTest
class WalletRefreshSandboxedTest : WalletRefreshTest(SandboxedWalletService::class.java)

View file

@ -0,0 +1,41 @@
package im.molly.monero.e2etest
import android.content.Context
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import im.molly.monero.WalletProvider
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.WalletServiceClient
import im.molly.monero.service.BaseWalletService
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
class WalletServiceRule(private val serviceClass: Class<out BaseWalletService>) : TestRule {
val walletProvider: WalletProvider
get() = _walletProvider ?: error("WalletService not bound yet")
private var _walletProvider: WalletProvider? = null
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
private val delegate = ServiceTestRule()
override fun apply(base: Statement, description: Description): Statement {
return delegate.apply(object : Statement() {
override fun evaluate() {
val binder = delegate.bindService(Intent(context, serviceClass))
val walletService = IWalletService.Stub.asInterface(binder)
_walletProvider = WalletServiceClient.withBoundService(context, walletService)
try {
walletProvider.use { base.evaluate() }
} finally {
delegate.unbindService()
}
}
}, description)
}
}

View file

@ -1,114 +0,0 @@
package im.molly.monero.e2etest
import android.content.Context
import android.content.Intent
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import com.google.common.truth.Truth.assertThat
import im.molly.monero.InMemoryWalletDataStore
import im.molly.monero.MoneroNetwork
import im.molly.monero.MoneroWallet
import im.molly.monero.MoneroWalletSubject
import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey
import im.molly.monero.WalletProvider
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.WalletServiceClient
import im.molly.monero.mnemonics.MoneroMnemonic
import im.molly.monero.mnemonics.toSecretKey
import im.molly.monero.service.BaseWalletService
import im.molly.monero.service.InProcessWalletService
import im.molly.monero.service.SandboxedWalletService
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.time.Instant
@LargeTest
abstract class MoneroWalletTest(private val serviceClass: Class<out BaseWalletService>) {
@get:Rule
val walletServiceRule = ServiceTestRule()
private lateinit var walletProvider: WalletProvider
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
private fun bindService(): IWalletService {
val binder = walletServiceRule.bindService(Intent(context, serviceClass))
return IWalletService.Stub.asInterface(binder)
}
private fun unbindService() {
walletServiceRule.unbindService()
}
@Before
fun setUp() {
val walletService = bindService()
walletProvider = WalletServiceClient.withBoundService(context, walletService)
}
@After
fun tearDown() {
walletProvider.disconnect()
unbindService()
}
@Test
fun restoredWalletHasExpectedAddress() = runTest {
val key =
MoneroMnemonic.recoverEntropy(
"velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted"
)?.toSecretKey()
val wallet = walletProvider.restoreWallet(
network = MoneroNetwork.Mainnet,
secretSpendKey = key ?: error("recoverEntropy failed"),
restorePoint = RestorePoint.creationTime(Instant.now()),
)
assertThat(wallet.publicAddress.address)
.isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
}
@Test
fun saveToMultipleStores() = runTest {
val defaultStore = InMemoryWalletDataStore()
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet, defaultStore)
wallet.save()
val newStore = InMemoryWalletDataStore()
wallet.save(newStore)
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
}
@Test
fun createAccountAndSave() = runTest {
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet)
val newAccount = wallet.createAccount()
withReopenedWallet(wallet) { original, reopened ->
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
}
}
private suspend fun withReopenedWallet(
wallet: MoneroWallet,
action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit,
) {
walletProvider.openWallet(
network = wallet.network,
dataStore = InMemoryWalletDataStore().apply {
wallet.save(targetStore = this)
},
).use { reopened ->
action(wallet, reopened)
}
}
}
class MoneroWalletInProcessTest : MoneroWalletTest(InProcessWalletService::class.java)
class MoneroWalletSandboxedTest : MoneroWalletTest(SandboxedWalletService::class.java)

View file

@ -0,0 +1,70 @@
package im.molly.monero.e2etest
import android.content.Context
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import im.molly.monero.InMemoryWalletDataStore
import im.molly.monero.Mainnet
import im.molly.monero.MoneroWallet
import im.molly.monero.WalletDataStore
import im.molly.monero.WalletProvider
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.WalletServiceClient
import im.molly.monero.service.BaseWalletService
import org.junit.After
import org.junit.Before
import org.junit.Rule
abstract class WalletTestBase(private val serviceClass: Class<out BaseWalletService>) {
@get:Rule
val walletServiceRule = ServiceTestRule()
protected lateinit var walletProvider: WalletProvider
private set
protected val context: Context by lazy {
InstrumentationRegistry.getInstrumentation().context
}
private fun bindService(): IWalletService {
val binder = walletServiceRule.bindService(Intent(context, serviceClass))
return IWalletService.Stub.asInterface(binder)
}
private fun unbindService() {
walletServiceRule.unbindService()
}
@Before
fun setUpBase() {
val walletService = bindService()
walletProvider = WalletServiceClient.withBoundService(context, walletService)
}
@After
fun tearDownBase() {
runCatching {
walletProvider.disconnect()
}
unbindService()
}
protected suspend fun wallet(defaultStore: WalletDataStore? = null) =
walletProvider.createNewWallet(Mainnet, defaultStore)
protected suspend fun withReopenedWallet(
wallet: MoneroWallet,
action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit,
) {
walletProvider.openWallet(
network = wallet.network,
dataStore = InMemoryWalletDataStore().apply {
wallet.save(targetStore = this)
},
).use { reopened ->
action(wallet, reopened)
}
}
}

View file

@ -3,6 +3,7 @@ package im.molly.monero.service
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import im.molly.monero.isIsolatedProcess
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -13,14 +14,14 @@ class WalletServiceSandboxingTest {
@Test
fun inProcessWalletServiceIsNotIsolated() = runTest {
InProcessWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceIsolated()).isFalse()
assertThat(walletProvider.isServiceSandboxed()).isFalse()
}
}
@Test
fun sandboxedWalletServiceIsIsolated() = runTest {
SandboxedWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceIsolated()).isTrue()
assertThat(walletProvider.isServiceSandboxed()).isTrue()
}
}
}

View file

@ -7,5 +7,6 @@ oneway interface IWalletCallbacks {
void onCommitResult(boolean success);
void onSubAddressReady(String subAddress);
void onSubAddressListReceived(in String[] subAddresses);
void onAccountNotFound(int accountIndex);
void onFeesReceived(in long[] fees);
}

View file

@ -11,4 +11,5 @@ interface IWalletService {
oneway void restoreWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long restorePoint);
oneway void openWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in ParcelFileDescriptor inputFd);
void setListener(in IWalletServiceListener listener);
boolean isServiceIsolated();
}

View file

@ -10,6 +10,7 @@
#endif
// Low-level debug macros. Log messages are not scrubbed.
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))

View file

@ -84,6 +84,8 @@ void Wallet::restoreAccount(const std::vector<char>& secret_scalar, uint64_t res
m_last_block_timestamp = account.get_createtime();
}
m_last_block_height = (m_restore_height == 0) ? 1 : m_restore_height;
LOGD("Restoring account: restore_point=%" PRIu64 ", computed restore_height=%" PRIu64,
restore_point, m_restore_height);
m_wallet.rescan_blockchain(true, false, false);
m_account_ready = true;
}

View file

@ -114,6 +114,15 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
@Parcelize
data class Block(override val blockchainTime: BlockchainTime) : UnlockTime {
constructor(height: Int, network: MoneroNetwork) : this(
BlockchainTime(
height = height,
timestamp = network.estimateTimestamp(height),
network = network,
)
)
override operator fun compareTo(other: BlockchainTime): Int {
return blockchainTime.height.compareTo(other.height)
}
@ -121,8 +130,21 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
@Parcelize
data class Timestamp(override val blockchainTime: BlockchainTime) : UnlockTime {
constructor(timestamp: Instant, network: MoneroNetwork) : this(
BlockchainTime(
height = network.estimateHeight(timestamp),
timestamp = timestamp,
network = network,
)
)
override operator fun compareTo(other: BlockchainTime): Int {
return blockchainTime.timestamp.compareTo(other.timestamp)
}
}
}
fun MoneroNetwork.unlockAtBlock(height: Int) = UnlockTime.Block(height, this)
fun MoneroNetwork.unlockAtTime(timestamp: Instant) = UnlockTime.Timestamp(timestamp, this)

View file

@ -4,11 +4,11 @@ import android.app.ActivityManager
import android.app.Application
import android.content.Context.ACTIVITY_SERVICE
import android.os.Build
import android.os.Process.isIsolated
import android.os.Process
fun Application.isIsolatedProcess(): Boolean {
return if (Build.VERSION.SDK_INT >= 28) {
isIsolated()
Process.isIsolated()
} else {
try {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager

View file

@ -6,7 +6,7 @@ import im.molly.monero.internal.Logger
/**
* Adapter to output logs to the host application.
*
* Priority values matches Android framework log priority levels.
* Priority values matches Android framework [Log] priority levels.
*/
interface LogAdapter {
fun isLoggable(priority: Int, tag: String): Boolean = true
@ -18,7 +18,7 @@ interface LogAdapter {
*/
class DebugLogAdapter : LogAdapter {
override fun isLoggable(priority: Int, tag: String): Boolean {
return priority == Log.ASSERT || BuildConfig.DEBUG
return BuildConfig.DEBUG || (priority == Log.ASSERT)
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {

View file

@ -77,6 +77,10 @@ class MoneroWallet internal constructor(
override fun onSubAddressReady(subAddress: String) {
continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {}
}
override fun onAccountNotFound(accountIndex: Int) {
continuation.resumeWithException(NoSuchAccountException(accountIndex))
}
})
}
@ -102,6 +106,10 @@ class MoneroWallet internal constructor(
val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
continuation.resume(accounts.single()) {}
}
override fun onAccountNotFound(accountIndex: Int) {
continuation.resumeWithException(NoSuchAccountException(accountIndex))
}
})
}
@ -282,6 +290,8 @@ private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() {
override fun onSubAddressListReceived(subAddresses: Array<String>) = Unit
override fun onAccountNotFound(accountIndex: Int) = Unit
override fun onFeesReceived(fees: LongArray?) = Unit
}

View file

@ -1,16 +1,36 @@
package im.molly.monero
class TimeLocked<T>(val value: T, val unlockTime: UnlockTime?) {
data class TimeLocked<T>(val value: T, val unlockTime: UnlockTime? = null) {
fun isLocked(currentTime: BlockchainTime): Boolean {
return unlockTime != null && unlockTime > currentTime
val unlock = unlockTime ?: return false
requireSameNetworkAs(currentTime)
return unlock > currentTime
}
fun isUnlocked(currentTime: BlockchainTime) = !isLocked(currentTime)
fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan {
if (unlockTime == null || isUnlocked(currentTime)) {
return BlockchainTimeSpan.ZERO
if (unlockTime == null) return BlockchainTimeSpan.ZERO
requireSameNetworkAs(currentTime)
return if (unlockTime > currentTime) {
unlockTime.blockchainTime - currentTime
} else {
BlockchainTimeSpan.ZERO
}
}
private fun requireSameNetworkAs(other: BlockchainTime) {
val expected = unlockTime?.blockchainTime?.network
require(expected == other.network) {
"BlockchainTime network mismatch: expected $expected, got ${other.network}"
}
return unlockTime.blockchainTime - currentTime
}
}
fun MoneroAmount.lockedUntil(unlockTime: UnlockTime): TimeLocked<MoneroAmount> =
TimeLocked(this, unlockTime)
fun MoneroAmount.unlocked(): TimeLocked<MoneroAmount> =
TimeLocked(this)

View file

@ -35,4 +35,3 @@ sealed interface TxState {
data object OffChain : TxState
}

View file

@ -23,7 +23,7 @@ interface WalletProvider : Closeable {
client: MoneroNodeClient? = null,
): MoneroWallet
fun isServiceIsolated(): Boolean
fun isServiceSandboxed(): Boolean
fun disconnect()

View file

@ -260,7 +260,7 @@ internal class NativeWallet private constructor(
override fun addDetachedSubAddress(
accountIndex: Int,
subAddressIndex: Int,
callback: IWalletCallbacks?,
callback: IWalletCallbacks,
) {
scope.launch(ioDispatcher) {
val subAddress = nativeAddDetachedSubAddress(handle, accountIndex, subAddressIndex)
@ -268,20 +268,20 @@ internal class NativeWallet private constructor(
}
}
override fun createAccount(callback: IWalletCallbacks?) {
override fun createAccount(callback: IWalletCallbacks) {
scope.launch(ioDispatcher) {
val primaryAddress = nativeCreateSubAddressAccount(handle)
notifyAddressCreation(primaryAddress, callback)
}
}
override fun createSubAddressForAccount(accountIndex: Int, callback: IWalletCallbacks?) {
override fun createSubAddressForAccount(accountIndex: Int, callback: IWalletCallbacks) {
scope.launch(ioDispatcher) {
val subAddress = nativeCreateSubAddress(handle, accountIndex)
if (subAddress != null) {
notifyAddressCreation(subAddress, callback)
} else {
TODO()
callback.onAccountNotFound(accountIndex)
}
}
}
@ -310,7 +310,7 @@ internal class NativeWallet private constructor(
}
}
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) {
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks) {
balanceListenersLock.withLock {
if (balanceListeners.isNotEmpty()) {
val subAddresses = getSubAddresses()
@ -319,7 +319,7 @@ internal class NativeWallet private constructor(
}
}
}
callback?.onSubAddressReady(subAddress)
callback.onSubAddressReady(subAddress)
}
override fun getAddressesForAccount(accountIndex: Int, callback: IWalletCallbacks) {
@ -328,7 +328,7 @@ internal class NativeWallet private constructor(
if (accountSubAddresses.isNotEmpty()) {
callback.onSubAddressListReceived(accountSubAddresses)
} else {
TODO()
callback.onAccountNotFound(accountIndex)
}
}
}

View file

@ -1,13 +1,16 @@
package im.molly.monero.internal
import android.app.Service
import android.os.ParcelFileDescriptor
import im.molly.monero.LogAdapter
import im.molly.monero.SecretKey
import im.molly.monero.isIsolatedProcess
import im.molly.monero.randomSecretKey
import im.molly.monero.setLoggingAdapter
import kotlinx.coroutines.CoroutineScope
internal class NativeWalletService(
private val service: Service,
private val serviceScope: CoroutineScope,
) : IWalletService.Stub(), LogAdapter {
@ -15,11 +18,10 @@ internal class NativeWalletService(
init {
NativeLoader.loadWalletLibrary(logger = logger)
}
fun configureLoggingAdapter() {
if (isServiceIsolated) {
setLoggingAdapter(this)
}
}
private var listener: IWalletServiceListener? = null
@ -27,9 +29,7 @@ internal class NativeWalletService(
listener = l
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}
override fun isServiceIsolated(): Boolean = service.application.isIsolatedProcess()
override fun createWallet(
config: WalletConfig,
@ -87,4 +87,8 @@ internal class NativeWalletService(
coroutineContext = serviceScope.coroutineContext,
)
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}
}

View file

@ -143,7 +143,8 @@ internal class WalletServiceClient(
}
}
override fun isServiceIsolated(): Boolean = service.isRemote()
override fun isServiceSandboxed(): Boolean =
service.isRemote() && service.isServiceIsolated
override fun disconnect() {
context.unbindService(serviceConnection ?: return)
@ -151,7 +152,7 @@ internal class WalletServiceClient(
@OptIn(ExperimentalCoroutinesApi::class)
private class WalletResultCallback(
private val continuation: CancellableContinuation<IWallet>
private val continuation: CancellableContinuation<IWallet>,
) : IWalletServiceCallbacks.Stub() {
override fun onWalletResult(wallet: IWallet?) {
if (wallet != null) {

View file

@ -7,13 +7,9 @@ import androidx.lifecycle.lifecycleScope
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.NativeWalletService
abstract class BaseWalletService(private val isolated: Boolean) : LifecycleService() {
abstract class BaseWalletService : LifecycleService() {
private val service: IWalletService by lazy {
NativeWalletService(lifecycleScope).apply {
if (isolated) {
configureLoggingAdapter()
}
}
NativeWalletService(this, lifecycleScope)
}
override fun onBind(intent: Intent): IBinder {

View file

@ -7,7 +7,7 @@ import im.molly.monero.internal.WalletServiceClient
/**
* Provides wallet services using an in-process bound service.
*/
class InProcessWalletService : BaseWalletService(isolated = false) {
class InProcessWalletService : BaseWalletService() {
companion object {
/**
* Connects to the in-process wallet service and returns a connected [WalletProvider].

View file

@ -7,7 +7,7 @@ import im.molly.monero.internal.WalletServiceClient
/**
* Provides wallet services using a sandboxed process.
*/
class SandboxedWalletService : BaseWalletService(isolated = true) {
class SandboxedWalletService : BaseWalletService() {
companion object {
/**
* Connects to the sandboxed wallet service and returns a connected [WalletProvider].

View file

@ -0,0 +1,63 @@
package im.molly.monero
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import java.time.Instant
class BalanceTest {
@Test
fun `confirmed and total amounts are sum of time-locked values`() {
val balance = Balance(
lockableAmounts = listOf(1.xmr, 2.xmr, 3.xmr).map { it.unlocked() },
)
assertThat(balance.confirmedAmount).isEqualTo(6.xmr)
assertThat(balance.totalAmount).isEqualTo(6.xmr)
}
@Test
fun `total amount includes pending`() {
val balance = Balance(
lockableAmounts = listOf(2.xmr.unlocked()),
pendingAmount = 4.xmr,
)
assertThat(balance.totalAmount).isEqualTo(6.xmr)
}
@Test
fun `empty balance returns zero amounts`() {
assertThat(Balance.EMPTY.totalAmount).isEqualTo(0.xmr)
}
@Test
fun `excludes locked amounts from unlocked sum`() {
val current = BlockchainTime(100, Instant.now(), Mainnet)
val allUnlocked = Balance(
listOf(
TimeLocked(2.xmr, Mainnet.unlockAtBlock(50)),
TimeLocked(3.xmr, Mainnet.unlockAtBlock(50))
)
)
assertThat(allUnlocked.unlockedAmountAt(current)).isEqualTo(5.xmr)
val allLocked = Balance(
listOf(
TimeLocked(2.xmr, Mainnet.unlockAtBlock(150)),
TimeLocked(3.xmr, Mainnet.unlockAtBlock(200))
)
)
assertThat(allLocked.unlockedAmountAt(current)).isEqualTo(0.xmr)
val partial = Balance(
listOf(
TimeLocked(1.xmr, Mainnet.unlockAtBlock(50)),
TimeLocked(2.xmr, Mainnet.unlockAtBlock(100)),
TimeLocked(5.xmr, Mainnet.unlockAtBlock(150)),
)
)
assertThat(partial.unlockedAmountAt(current)).isEqualTo(3.xmr)
}
}

View file

@ -0,0 +1,24 @@
package im.molly.monero
import kotlin.test.Test
import kotlin.test.assertFailsWith
class TimeLockedTest {
private val mainnetUnlockTime = UnlockTime.Block(100, Mainnet)
private val locked = 1.xmr.lockedUntil(mainnetUnlockTime)
@Test
fun `isLocked throws on network mismatch`() {
assertFailsWith<IllegalArgumentException> {
locked.isLocked(Stagenet.genesisTime)
}
}
@Test
fun `timeUntilUnlock throws on network mismatch`() {
assertFailsWith<IllegalArgumentException> {
locked.timeUntilUnlock(Stagenet.genesisTime)
}
}
}