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 -> androidComponentsExtension.onVariants { variant ->
val myObjFactory = project.objects val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile 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> = val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java) myObjFactory.listProperty(Directory::class.java)
val reportTask = val reportTask =
@ -47,7 +48,6 @@ internal fun Project.configureJacoco(
"create${variant.name.capitalize()}CombinedCoverageReport", "create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class, JacocoReport::class,
) { ) {
classDirectories.setFrom( classDirectories.setFrom(
allJars, allJars,
allDirectories.map { dirs -> 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 android.content.Context
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.molly.monero.isIsolatedProcess
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -13,14 +14,14 @@ class WalletServiceSandboxingTest {
@Test @Test
fun inProcessWalletServiceIsNotIsolated() = runTest { fun inProcessWalletServiceIsNotIsolated() = runTest {
InProcessWalletService.connect(context).use { walletProvider -> InProcessWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceIsolated()).isFalse() assertThat(walletProvider.isServiceSandboxed()).isFalse()
} }
} }
@Test @Test
fun sandboxedWalletServiceIsIsolated() = runTest { fun sandboxedWalletServiceIsIsolated() = runTest {
SandboxedWalletService.connect(context).use { walletProvider -> 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 onCommitResult(boolean success);
void onSubAddressReady(String subAddress); void onSubAddressReady(String subAddress);
void onSubAddressListReceived(in String[] subAddresses); void onSubAddressListReceived(in String[] subAddresses);
void onAccountNotFound(int accountIndex);
void onFeesReceived(in long[] fees); 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 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); oneway void openWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in ParcelFileDescriptor inputFd);
void setListener(in IWalletServiceListener listener); void setListener(in IWalletServiceListener listener);
boolean isServiceIsolated();
} }

View file

@ -10,6 +10,7 @@
#endif #endif
// Low-level debug macros. Log messages are not scrubbed. // 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 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 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__)) #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_timestamp = account.get_createtime();
} }
m_last_block_height = (m_restore_height == 0) ? 1 : m_restore_height; 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_wallet.rescan_blockchain(true, false, false);
m_account_ready = true; m_account_ready = true;
} }

View file

@ -114,6 +114,15 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
@Parcelize @Parcelize
data class Block(override val blockchainTime: BlockchainTime) : UnlockTime { 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 { override operator fun compareTo(other: BlockchainTime): Int {
return blockchainTime.height.compareTo(other.height) return blockchainTime.height.compareTo(other.height)
} }
@ -121,8 +130,21 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
@Parcelize @Parcelize
data class Timestamp(override val blockchainTime: BlockchainTime) : UnlockTime { 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 { override operator fun compareTo(other: BlockchainTime): Int {
return blockchainTime.timestamp.compareTo(other.timestamp) 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.app.Application
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
import android.os.Build import android.os.Build
import android.os.Process.isIsolated import android.os.Process
fun Application.isIsolatedProcess(): Boolean { fun Application.isIsolatedProcess(): Boolean {
return if (Build.VERSION.SDK_INT >= 28) { return if (Build.VERSION.SDK_INT >= 28) {
isIsolated() Process.isIsolated()
} else { } else {
try { try {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager 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. * 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 { interface LogAdapter {
fun isLoggable(priority: Int, tag: String): Boolean = true fun isLoggable(priority: Int, tag: String): Boolean = true
@ -18,7 +18,7 @@ interface LogAdapter {
*/ */
class DebugLogAdapter : LogAdapter { class DebugLogAdapter : LogAdapter {
override fun isLoggable(priority: Int, tag: String): Boolean { 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?) { 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) { override fun onSubAddressReady(subAddress: String) {
continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {} 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()) val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
continuation.resume(accounts.single()) {} 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 onSubAddressListReceived(subAddresses: Array<String>) = Unit
override fun onAccountNotFound(accountIndex: Int) = Unit
override fun onFeesReceived(fees: LongArray?) = Unit override fun onFeesReceived(fees: LongArray?) = Unit
} }

View file

@ -1,16 +1,36 @@
package im.molly.monero 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 { 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 isUnlocked(currentTime: BlockchainTime) = !isLocked(currentTime)
fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan { fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan {
if (unlockTime == null || isUnlocked(currentTime)) { if (unlockTime == null) return BlockchainTimeSpan.ZERO
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 data object OffChain : TxState
} }

View file

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

View file

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

View file

@ -1,13 +1,16 @@
package im.molly.monero.internal package im.molly.monero.internal
import android.app.Service
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import im.molly.monero.LogAdapter import im.molly.monero.LogAdapter
import im.molly.monero.SecretKey import im.molly.monero.SecretKey
import im.molly.monero.isIsolatedProcess
import im.molly.monero.randomSecretKey import im.molly.monero.randomSecretKey
import im.molly.monero.setLoggingAdapter import im.molly.monero.setLoggingAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
internal class NativeWalletService( internal class NativeWalletService(
private val service: Service,
private val serviceScope: CoroutineScope, private val serviceScope: CoroutineScope,
) : IWalletService.Stub(), LogAdapter { ) : IWalletService.Stub(), LogAdapter {
@ -15,11 +18,10 @@ internal class NativeWalletService(
init { init {
NativeLoader.loadWalletLibrary(logger = logger) NativeLoader.loadWalletLibrary(logger = logger)
} if (isServiceIsolated) {
fun configureLoggingAdapter() {
setLoggingAdapter(this) setLoggingAdapter(this)
} }
}
private var listener: IWalletServiceListener? = null private var listener: IWalletServiceListener? = null
@ -27,9 +29,7 @@ internal class NativeWalletService(
listener = l listener = l
} }
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) { override fun isServiceIsolated(): Boolean = service.application.isIsolatedProcess()
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}
override fun createWallet( override fun createWallet(
config: WalletConfig, config: WalletConfig,
@ -87,4 +87,8 @@ internal class NativeWalletService(
coroutineContext = serviceScope.coroutineContext, 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() { override fun disconnect() {
context.unbindService(serviceConnection ?: return) context.unbindService(serviceConnection ?: return)
@ -151,7 +152,7 @@ internal class WalletServiceClient(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private class WalletResultCallback( private class WalletResultCallback(
private val continuation: CancellableContinuation<IWallet> private val continuation: CancellableContinuation<IWallet>,
) : IWalletServiceCallbacks.Stub() { ) : IWalletServiceCallbacks.Stub() {
override fun onWalletResult(wallet: IWallet?) { override fun onWalletResult(wallet: IWallet?) {
if (wallet != null) { if (wallet != null) {

View file

@ -7,13 +7,9 @@ import androidx.lifecycle.lifecycleScope
import im.molly.monero.internal.IWalletService import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.NativeWalletService import im.molly.monero.internal.NativeWalletService
abstract class BaseWalletService(private val isolated: Boolean) : LifecycleService() { abstract class BaseWalletService : LifecycleService() {
private val service: IWalletService by lazy { private val service: IWalletService by lazy {
NativeWalletService(lifecycleScope).apply { NativeWalletService(this, lifecycleScope)
if (isolated) {
configureLoggingAdapter()
}
}
} }
override fun onBind(intent: Intent): IBinder { 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. * Provides wallet services using an in-process bound service.
*/ */
class InProcessWalletService : BaseWalletService(isolated = false) { class InProcessWalletService : BaseWalletService() {
companion object { companion object {
/** /**
* Connects to the in-process wallet service and returns a connected [WalletProvider]. * 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. * Provides wallet services using a sandboxed process.
*/ */
class SandboxedWalletService : BaseWalletService(isolated = true) { class SandboxedWalletService : BaseWalletService() {
companion object { companion object {
/** /**
* Connects to the sandboxed wallet service and returns a connected [WalletProvider]. * 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)
}
}
}