diff --git a/build-logic/plugins/src/main/kotlin/Jacoco.kt b/build-logic/plugins/src/main/kotlin/Jacoco.kt index 9c66a16..143aeb1 100644 --- a/build-logic/plugins/src/main/kotlin/Jacoco.kt +++ b/build-logic/plugins/src/main/kotlin/Jacoco.kt @@ -39,7 +39,8 @@ internal fun Project.configureJacoco( androidComponentsExtension.onVariants { variant -> val myObjFactory = project.objects val buildDir = layout.buildDirectory.get().asFile - val allJars: ListProperty = myObjFactory.listProperty(RegularFile::class.java) + val allJars: ListProperty = + myObjFactory.listProperty(RegularFile::class.java) val allDirectories: ListProperty = 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 -> diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerChainSubject.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerChainSubject.kt new file mode 100644 index 0000000..22a5e36 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerChainSubject.kt @@ -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, +) : Subject(metadata, actual) { + + companion object { + fun assertThat(ledgerChain: List): LedgerChainSubject { + return assertAbout(factory).that(ledgerChain) + } + + private val factory = Factory { metadata, actual: List -> + 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.ledgerTransitionsByHeight( + action: (step: Int, prev: Ledger, next: Ledger) -> Unit + ) { + this.sortedBy { it.checkedAt.height } + .zipWithNext() + .forEachIndexed { step, (prev, next) -> action(step, prev, next) } + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerSubject.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerSubject.kt new file mode 100644 index 0000000..07a40a6 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/LedgerSubject.kt @@ -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) + } + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletPersistenceTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletPersistenceTest.kt new file mode 100644 index 0000000..9f742c0 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletPersistenceTest.kt @@ -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, +) : 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) diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletRefreshTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletRefreshTest.kt new file mode 100644 index 0000000..883fe75 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletRefreshTest.kt @@ -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, +) : 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) diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletServiceRule.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletServiceRule.kt new file mode 100644 index 0000000..6a5c726 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletServiceRule.kt @@ -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) : 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) + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTest.kt deleted file mode 100644 index 62e9771..0000000 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTest.kt +++ /dev/null @@ -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) { - - @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) diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTestBase.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTestBase.kt new file mode 100644 index 0000000..79c1356 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/WalletTestBase.kt @@ -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) { + + @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) + } + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt index 4159ae1..2ef2297 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt @@ -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 @@ -12,15 +13,15 @@ class WalletServiceSandboxingTest { @Test fun inProcessWalletServiceIsNotIsolated() = runTest { - InProcessWalletService.connect(context).use { walletProvider -> - assertThat(walletProvider.isServiceIsolated()).isFalse() + InProcessWalletService.connect(context).use { walletProvider -> + assertThat(walletProvider.isServiceSandboxed()).isFalse() } } @Test fun sandboxedWalletServiceIsIsolated() = runTest { SandboxedWalletService.connect(context).use { walletProvider -> - assertThat(walletProvider.isServiceIsolated()).isTrue() + assertThat(walletProvider.isServiceSandboxed()).isTrue() } } } diff --git a/lib/android/src/main/aidl/im/molly/monero/internal/IWalletCallbacks.aidl b/lib/android/src/main/aidl/im/molly/monero/internal/IWalletCallbacks.aidl index 752dfec..a33eb7b 100644 --- a/lib/android/src/main/aidl/im/molly/monero/internal/IWalletCallbacks.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/internal/IWalletCallbacks.aidl @@ -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); } diff --git a/lib/android/src/main/aidl/im/molly/monero/internal/IWalletService.aidl b/lib/android/src/main/aidl/im/molly/monero/internal/IWalletService.aidl index 451a016..a44dbbe 100644 --- a/lib/android/src/main/aidl/im/molly/monero/internal/IWalletService.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/internal/IWalletService.aidl @@ -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(); } diff --git a/lib/android/src/main/cpp/common/debug.h b/lib/android/src/main/cpp/common/debug.h index 608cf2f..cbf567a 100644 --- a/lib/android/src/main/cpp/common/debug.h +++ b/lib/android/src/main/cpp/common/debug.h @@ -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__)) diff --git a/lib/android/src/main/cpp/wallet/wallet.cc b/lib/android/src/main/cpp/wallet/wallet.cc index 6fe4055..5e7e64b 100644 --- a/lib/android/src/main/cpp/wallet/wallet.cc +++ b/lib/android/src/main/cpp/wallet/wallet.cc @@ -84,6 +84,8 @@ void Wallet::restoreAccount(const std::vector& 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; } diff --git a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt index 31eb062..92569af 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -53,7 +53,7 @@ data class BlockchainTime( fun effectiveUnlockTime(targetHeight: Int, txTimeLock: UnlockTime?): UnlockTime { val spendableHeight = targetHeight + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE - 1 - val spendableTime = BlockchainTime( + val spendableTime = BlockchainTime( height = spendableHeight, timestamp = estimateTimestamp(spendableHeight), network = network, @@ -114,6 +114,15 @@ sealed interface UnlockTime : Comparable, 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, 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) diff --git a/lib/android/src/main/kotlin/im/molly/monero/ContextUtils.kt b/lib/android/src/main/kotlin/im/molly/monero/ContextUtils.kt index f73fec6..64d82c1 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/ContextUtils.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/ContextUtils.kt @@ -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 diff --git a/lib/android/src/main/kotlin/im/molly/monero/Logging.kt b/lib/android/src/main/kotlin/im/molly/monero/Logging.kt index 807c610..a522a53 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Logging.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Logging.kt @@ -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?) { diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt index 5acf59d..a73cf6a 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -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) = Unit + override fun onAccountNotFound(accountIndex: Int) = Unit + override fun onFeesReceived(fees: LongArray?) = Unit } diff --git a/lib/android/src/main/kotlin/im/molly/monero/TimeLocked.kt b/lib/android/src/main/kotlin/im/molly/monero/TimeLocked.kt index efa8204..e334595 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/TimeLocked.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/TimeLocked.kt @@ -1,16 +1,36 @@ package im.molly.monero -class TimeLocked(val value: T, val unlockTime: UnlockTime?) { +data class TimeLocked(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 = + TimeLocked(this, unlockTime) + +fun MoneroAmount.unlocked(): TimeLocked = + TimeLocked(this) diff --git a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt index 7f47661..1f5c724 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt @@ -35,4 +35,3 @@ sealed interface TxState { data object OffChain : TxState } - diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt index b8977c9..1d4b372 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt @@ -23,7 +23,7 @@ interface WalletProvider : Closeable { client: MoneroNodeClient? = null, ): MoneroWallet - fun isServiceIsolated(): Boolean + fun isServiceSandboxed(): Boolean fun disconnect() diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWallet.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWallet.kt index 73802d7..9dace12 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWallet.kt @@ -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) } } } diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWalletService.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWalletService.kt index d72c5f6..1dbe330 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWalletService.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/NativeWalletService.kt @@ -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,10 +18,9 @@ internal class NativeWalletService( init { NativeLoader.loadWalletLibrary(logger = logger) - } - - fun configureLoggingAdapter() { - setLoggingAdapter(this) + 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()) + } } diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt index 351852f..a3cd94d 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt @@ -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 + private val continuation: CancellableContinuation, ) : IWalletServiceCallbacks.Stub() { override fun onWalletResult(wallet: IWallet?) { if (wallet != null) { diff --git a/lib/android/src/main/kotlin/im/molly/monero/service/BaseWalletService.kt b/lib/android/src/main/kotlin/im/molly/monero/service/BaseWalletService.kt index 7ad0640..719d3c6 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/service/BaseWalletService.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/service/BaseWalletService.kt @@ -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 { diff --git a/lib/android/src/main/kotlin/im/molly/monero/service/InProcessWalletService.kt b/lib/android/src/main/kotlin/im/molly/monero/service/InProcessWalletService.kt index 721d0ca..d535027 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/service/InProcessWalletService.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/service/InProcessWalletService.kt @@ -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]. diff --git a/lib/android/src/main/kotlin/im/molly/monero/service/SandboxedWalletService.kt b/lib/android/src/main/kotlin/im/molly/monero/service/SandboxedWalletService.kt index b65c159..59f8c2c 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/service/SandboxedWalletService.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/service/SandboxedWalletService.kt @@ -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]. diff --git a/lib/android/src/test/kotlin/im/molly/monero/BalanceTest.kt b/lib/android/src/test/kotlin/im/molly/monero/BalanceTest.kt new file mode 100644 index 0000000..114e24c --- /dev/null +++ b/lib/android/src/test/kotlin/im/molly/monero/BalanceTest.kt @@ -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) + } +} diff --git a/lib/android/src/test/kotlin/im/molly/monero/TimeLockedTest.kt b/lib/android/src/test/kotlin/im/molly/monero/TimeLockedTest.kt new file mode 100644 index 0000000..a20c85d --- /dev/null +++ b/lib/android/src/test/kotlin/im/molly/monero/TimeLockedTest.kt @@ -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 { + locked.isLocked(Stagenet.genesisTime) + } + } + + @Test + fun `timeUntilUnlock throws on network mismatch`() { + assertFailsWith { + locked.timeUntilUnlock(Stagenet.genesisTime) + } + } +}