1
0
mirror of https://github.com/mollyim/monero-wallet-sdk.git synced 2025-04-24 01:19:25 -04:00

Compare commits

...

17 Commits

Author SHA1 Message Date
Oscar Mira
cb79e3421d
lib: add mnemonic tests 2025-01-30 23:20:07 +01:00
Oscar Mira
16ff7b06db
lib: deprecate parseHex and use hexToByteArray instead 2025-01-30 23:20:07 +01:00
Oscar Mira
d05a056698
build: add plugin for code coverage reporting 2025-01-30 14:01:50 +01:00
Oscar Mira
301f1efc1c
lib: compact TxInfo struct 2025-01-30 11:13:59 +01:00
Oscar Mira
a664ce1652
lib: fix instrumented tests 2025-01-29 18:06:44 +01:00
Oscar Mira
181b3dc442
build: convert modules to kts and update deps 2025-01-29 17:33:16 +01:00
Oscar Mira
9b71aae6c5
build: convert main .gradle to kts 2025-01-28 14:05:04 +01:00
Oscar Mira
9f54eadc61
build: migrate to version catalogs 2025-01-28 13:42:14 +01:00
Oscar Mira
8dbf0ffb6b
lib: rename function fullNode to localSyncWallet 2025-01-28 13:42:11 +01:00
Oscar Mira
874f777deb
demo: add lint config 2025-01-26 20:54:04 +01:00
Oscar Mira
87d70c1e4f
build: add test.yml workflow 2025-01-26 20:46:42 +01:00
Oscar Mira
194d6d184e
build: replace PCH with include directive to fix Android Studio bug 2025-01-23 00:35:52 +01:00
Oscar Mira
37b68d6062
build: fix boost download URL 2025-01-23 00:33:05 +01:00
Oscar Mira
6bf3174d52
build: upgrade to AGP 8.8.0 2025-01-23 00:32:43 +01:00
Oscar Mira
b3fb65b858
monero: rebase molly/release-v0.18 onto v0.18.3.3 2024-05-27 19:39:16 +02:00
Oscar Mira
30ef8b481b
lib: rename RemoteNodeClient to MoneroNodeClient and hide implementation 2024-05-15 19:27:43 +02:00
Oscar Mira
308031250b
build: disable jetifier 2024-05-14 09:29:24 +02:00
66 changed files with 1255 additions and 755 deletions

44
.github/workflows/test.yml vendored Normal file

@ -0,0 +1,44 @@
name: Test
on:
pull_request:
push:
branches:
- '**'
paths-ignore:
- 'README.md'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
permissions:
contents: read
jobs:
wrapper_validation:
name: Validate Gradle wrapper
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Run wrapper validation
uses: gradle/actions/wrapper-validation@v3
test:
name: Run tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- name: Run tests
run: ./gradlew build --no-daemon

@ -0,0 +1,28 @@
plugins {
`kotlin-dsl`
}
kotlin {
jvmToolchain(21)
}
dependencies {
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}
gradlePlugin {
plugins {
register("androidLibraryJacoco") {
id = libs.plugins.sdk.android.library.jacoco.get().pluginId
implementationClass = "AndroidLibraryJacocoPlugin"
}
}
}

@ -0,0 +1,14 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidLibraryJacocoPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "jacoco")
configureJacoco(extensions.getByType<LibraryAndroidComponentsExtension>())
}
}
}

@ -0,0 +1,92 @@
import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.variant.SourceDirectories
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.testing.jacoco.tasks.JacocoReport
import java.util.Locale
private val coverageExclusions = listOf(
// Android
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
)
private fun String.capitalize() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
/**
* Creates a new task that generates a combined coverage report with data from local and
* instrumented tests.
*
* `create{variant}CombinedCoverageReport`
*
* Note that coverage data must exist before running the task. This allows us to run device
* tests on CI using a different Github Action or an external device farm.
*/
internal fun Project.configureJacoco(
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
androidComponentsExtension.onVariants { variant ->
val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask =
tasks.register(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
classDirectories.setFrom(
allJars,
allDirectories.map { dirs ->
dirs.map { dir ->
myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)
}
},
)
reports {
html.required = true
xml.required = true
}
fun SourceDirectories.Flat?.toFilePaths(): Provider<List<String>> = this
?.all
?.map { directories -> directories.map { it.asFile.path } }
?: provider { emptyList() }
sourceDirectories.setFrom(
files(
variant.sources.java.toFilePaths(),
variant.sources.kotlin.toFilePaths()
),
)
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") },
)
}
variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
.use(reportTask)
.toGet(
ScopedArtifact.CLASSES,
{ _ -> allJars },
{ _ -> allDirectories },
)
}
}

@ -0,0 +1,28 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android(\\..*)?")
includeGroupByRegex("com\\.google(\\..*)?")
includeGroupByRegex("androidx?(\\..*)?")
}
}
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
include(":plugins")
rootProject.name = "build-logic"

@ -1,28 +0,0 @@
plugins {
id('com.android.application') version '8.1.2' apply false
id('com.android.library') version '8.1.2' apply false
id('org.jetbrains.kotlin.android') version '1.9.10' apply false
id('org.jetbrains.kotlin.plugin.parcelize') version '1.9.10' apply false
id('com.google.devtools.ksp') version '1.9.10-1.0.13' apply false
id('com.google.protobuf') version '0.9.4' apply false
}
wrapper {
distributionType = Wrapper.DistributionType.ALL
}
allprojects {
group = 'im.molly'
ext {
gitVersion = gitVersion()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
static def gitVersion() {
return 'git describe --tags --always --first-parent'.execute().text.trim()
}

19
build.gradle.kts Normal file

@ -0,0 +1,19 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.androidx.room) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.ksp) apply false
}
allprojects {
group = "im.molly.monero.sdk"
}
tasks {
wrapper {
distributionType = Wrapper.DistributionType.ALL
}
}

@ -1,94 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'com.google.devtools.ksp'
ext {
composeVersion = '1.6.2'
composeCompilerVersion = '1.5.3'
lifecycleVersion = '2.7.0'
navigationVersion = '2.7.7'
roomVersion = '2.6.1'
}
android {
namespace = "im.molly.monero.demo"
compileSdk 34
defaultConfig {
applicationId "im.molly.monero.demo"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion composeCompilerVersion
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation "androidx.core:core-ktx:1.9.0"
implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.material3:material3:1.2.0"
implementation "androidx.compose.ui:ui-tooling-preview:$composeVersion"
implementation "com.google.accompanist:accompanist-systemuicontroller:0.32.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
implementation "androidx.activity:activity-compose:1.8.2"
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.navigation:navigation-runtime-ktx:$navigationVersion"
implementation "androidx.navigation:navigation-compose:$navigationVersion"
ksp "androidx.room:room-compiler:$roomVersion"
implementation project(':lib')
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$composeVersion"
debugImplementation "androidx.compose.ui:ui-tooling:$composeVersion"
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
}

@ -0,0 +1,84 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.androidx.room)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "im.molly.monero.demo"
compileSdk = 35
defaultConfig {
applicationId = "im.molly.monero.demo"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
merges += "META-INF/LICENSE.md"
merges += "META-INF/LICENSE-notice.md"
}
}
room {
schemaDirectory("$projectDir/schemas")
}
}
dependencies {
implementation(project(":lib"))
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.room.ktx)
runtimeOnly(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
testImplementation(testLibs.junit)
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.junit)
androidTestImplementation(testLibs.androidx.test.runner)
}

4
demo/android/lint.xml Normal file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="UnusedMaterial3ScaffoldPaddingParameter" severity="ignore" />
</lint>

@ -7,12 +7,12 @@ import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
import androidx.core.view.WindowCompat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import im.molly.monero.demo.service.SyncService
import im.molly.monero.demo.ui.DemoApp
import im.molly.monero.demo.ui.theme.AppTheme
@ -27,15 +27,19 @@ class MainActivity : ComponentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val systemUiController = rememberSystemUiController()
val darkTheme = isSystemInDarkTheme()
DisposableEffect(systemUiController, darkTheme) {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = !darkTheme,
DisposableEffect(darkTheme) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
) { darkTheme },
navigationBarStyle = SystemBarStyle.auto(
lightScrim,
darkScrim,
) { darkTheme },
)
onDispose {}
}
@ -67,3 +71,7 @@ class MainActivity : ComponentActivity() {
}
}
}
private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)

@ -52,7 +52,7 @@ class MoneroSdkClient(private val context: Context) {
httpClient: OkHttpClient,
): MoneroWallet {
val dataStore = WalletDataStoreFile(filename)
val client = RemoteNodeClient.forNetwork(
val client = MoneroNodeClient.forNetwork(
network = network,
remoteNodes = remoteNodes,
loadBalancerRule = RoundRobinRule(),

@ -51,8 +51,8 @@ class WalletRepository(
fun getWalletIdList() = walletDataSource.readWalletIdList()
fun getRemoteClients(): Flow<List<RemoteNodeClient>> =
getWalletIdList().map { it.mapNotNull { walletId -> getWallet(walletId).remoteNodeClient } }
fun getMoneroNodeClients(): Flow<List<MoneroNodeClient>> =
getWalletIdList().map { it.mapNotNull { walletId -> getWallet(walletId).moneroNodeClient } }
fun getWalletConfig(walletId: Long) = walletDataSource.readWalletConfig(walletId)

@ -12,14 +12,12 @@ import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.demo.data.model.DefaultMoneroNetwork
import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.mnemonics.MoneroMnemonic
import im.molly.monero.util.parseHex
import im.molly.monero.util.toHex
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
class AddWalletViewModel(
private val remoteNodeRepository: RemoteNodeRepository = AppModule.remoteNodeRepository,
private val walletRepository: WalletRepository = AppModule.walletRepository,
@ -66,7 +64,7 @@ class AddWalletViewModel(
MoneroMnemonic.recoverEntropy(words)?.use { mnemonicCode ->
val secretKey = SecretKey(mnemonicCode.entropy)
viewModelState.update {
it.copy(secretSpendKeyHex = secretKey.bytes.toHex())
it.copy(secretSpendKeyHex = secretKey.bytes.toHexString())
}
secretKey.destroy()
return true
@ -85,7 +83,7 @@ class AddWalletViewModel(
fun validateSecretSpendKeyHex(): Boolean =
with(viewModelState.value) {
return secretSpendKeyHex.length == 64 && runCatching {
secretSpendKeyHex.parseHex()
secretSpendKeyHex.hexToByteArray()
}.isSuccess
}
@ -125,7 +123,7 @@ class AddWalletViewModel(
else -> RestorePoint.Genesis
}
SecretKey(state.secretSpendKeyHex.parseHex()).use { secretSpendKey ->
SecretKey(state.secretSpendKeyHex.hexToByteArray()).use { secretSpendKey ->
walletRepository.restoreWallet(
state.network,
state.walletName,

@ -82,7 +82,7 @@ class SettingsViewModel(
}
private suspend fun onProxyChanged(newProxy: Proxy) {
walletRepository.getRemoteClients().first().forEach { client ->
walletRepository.getMoneroNodeClients().first().forEach { client ->
val current = client.httpClient.proxy
if (current != newProxy) {
val builder = client.httpClient.newBuilder()

@ -1,4 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
android.native.buildOutput=verbose

48
gradle/libs.versions.toml Normal file

@ -0,0 +1,48 @@
[versions]
agp = "8.8.0"
androidx-activity = "1.10.0"
androidx-core-ktx = "1.15.0"
androidx-datastore = "1.1.2"
androidx-lifecycle = "2.8.7"
androidx-navigation = "2.8.5"
compose-bom = "2025.01.00"
kotlin = "2.1.0"
kotlinx-coroutines = '1.9.0'
ksp = "2.1.0-1.0.29"
okhttp = "4.10.0"
room = "2.6.1"
[libraries]
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidx-lifecycle" }
androidx-material3 = { module = "androidx.compose.material3:material3" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-ui = { module = "androidx.compose.ui:ui" }
androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
kotlin-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines"}
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
okhttp = { module = "com.squareup.okhttp3:okhttp" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
androidx-room = { id = "androidx.room", version.ref = "room" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
# Plugins defined by this project
sdk-android-library-jacoco = { id ="sdk.android.library.jacoco" }

@ -0,0 +1,18 @@
[versions]
androidx-test-core = "1.6.1"
androidx-test-junit = "1.2.1"
androidx-test-runner = "1.6.2"
androidx-test-truth = "1.6.0"
junit = "4.13.2"
mockk = "1.13.2"
truth = "1.1.3"
[libraries]
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-core" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-truth" }
junit = { module = "junit:junit", version.ref = "junit" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }

Binary file not shown.

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=47a5bfed9ef814f90f8debcbbb315e8e7c654109acd224595ea39fca95c5d4da
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

37
gradlew vendored

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -80,13 +82,11 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,22 +133,29 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -193,11 +200,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

23
gradlew.bat vendored

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@ -26,6 +28,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -42,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail

@ -1,142 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.plugin.parcelize'
apply plugin: 'com.google.protobuf'
//apply from: 'deploy.gradle' // FIXME
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.20.1'
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
kotlin {
option "lite"
}
}
}
}
}
android {
namespace = "im.molly.monero"
buildToolsVersion '33.0.1'
compileSdk 33
ndkVersion '23.1.7779620'
defaultConfig {
minSdk 26
targetSdk 33
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
consumerProguardFiles 'consumer-rules.pro'
externalNativeBuild {
cmake {
arguments "-DVENDOR_DIR=${rootDir}/vendor"
arguments "-DDOWNLOAD_CACHE=${buildDir}/downloads"
cppFlags ''
}
}
ndk {
// abiFilters /*'armeabi-v7a', 'arm64-v8a',*/ 'x86_64' // FIXME
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
buildFeatures {
aidl true
buildConfig true
}
// testOptions {
// managedDevices {
// devices {
// // run with ../gradlew nexusOneApi30DebugAndroidTest
// nexusOneApi30(ManagedVirtualDevice) {
// // A lower resolution device is used here for better emulator performance
// device = "Nexus One"
// apiLevel = 30
// // Also use the AOSP ATD image for better emulator performance
// systemImageSource = "aosp-atd"
// }
// }
// }
// }
}
dependencies {
api 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'androidx.core:core-ktx:1.9.0'
// implementation 'androidx.appcompat:appcompat:1.3.0'
// implementation 'com.google.android.material:material:1.4.0'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation 'com.google.protobuf:protobuf-kotlin-lite:3.20.1'
implementation 'androidx.datastore:datastore-core:1.0.0'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
testImplementation 'junit:junit:4.13.2'
testImplementation "com.google.truth:truth:1.1.3"
testImplementation "io.mockk:mockk:1.12.5"
// androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// To use the androidx.test.core APIs
androidTestImplementation "androidx.test:core:1.4.0"
// Kotlin extensions for androidx.test.core
androidTestImplementation "androidx.test:core-ktx:1.4.0"
// To use the androidx.test.espresso
// androidTestImplementation "androidx.test:espresso:espresso-core:3.4.0"
// To use the JUnit Extension APIs
androidTestImplementation "androidx.test.ext:junit:1.1.3"
// Kotlin extensions for androidx.test.ext.junit
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.3"
// To use the Truth Extension APIs
androidTestImplementation "androidx.test.ext:truth:1.4.0"
// To use the androidx.test.runner APIs
androidTestImplementation "androidx.test:runner:1.4.0"
// To use android test orchestrator
// androidTestUtil "androidx.test:orchestrator:1.4.1"
androidTestImplementation "androidx.test:rules:1.4.0"
androidTestImplementation "io.mockk:mockk-android:1.12.5"
}

@ -0,0 +1,120 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.sdk.android.library.jacoco)
}
kotlin {
jvmToolchain(21)
}
val vendorDir = File(rootDir, "vendor")
val downloadCacheDir = layout.buildDirectory.dir("downloads").get().asFile
android {
namespace = "im.molly.monero"
compileSdk = 35
ndkVersion = "23.1.7779620"
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testHandleProfiling = true
testFunctionalTest = true
consumerProguardFiles("consumer-rules.pro")
externalNativeBuild {
cmake {
arguments += "-DVENDOR_DIR=${vendorDir.path}"
arguments += "-DDOWNLOAD_CACHE=${downloadCacheDir.path}"
}
}
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
}
}
buildTypes {
getByName("debug") {
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
}
getByName("release") {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro",
)
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
aidl = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
merges += "META-INF/LICENSE.md"
merges += "META-INF/LICENSE-notice.md"
}
}
testOptions {
managedDevices {
localDevices {
create("pixel2api30") {
device = "Pixel 2"
apiLevel = 30
systemImageSource = "aosp-atd"
}
}
}
}
}
dependencies {
api(platform(libs.okhttp.bom))
api(libs.okhttp)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.kotlin.junit)
testImplementation(testLibs.junit)
testImplementation(testLibs.mockk)
testImplementation(testLibs.truth)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.junit)
androidTestImplementation(testLibs.androidx.test.truth)
androidTestImplementation(testLibs.androidx.test.rules)
androidTestImplementation(testLibs.androidx.test.runner)
androidTestImplementation(testLibs.mockk)
}

@ -1,7 +1,7 @@
package im.molly.monero
import android.os.Parcel
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import kotlin.random.Random
@ -12,13 +12,13 @@ class SecretKeyParcelableTest {
val secret = Random.nextBytes(32)
val originalKey = SecretKey(secret)
val parcel = Parcel.obtain()
val parcel = Parcel.obtain().apply {
originalKey.writeToParcel(this, 0)
setDataPosition(0)
}
originalKey.writeToParcel(parcel, 0)
val recreatedKey = SecretKey.CREATOR.createFromParcel(parcel)
parcel.setDataPosition(0)
val key = SecretKey.create(parcel)
Truth.assertThat(key == originalKey).isTrue()
assertThat(recreatedKey).isEqualTo(originalKey)
}
}

@ -2,63 +2,64 @@ package im.molly.monero
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class WalletNativeTest {
@LargeTest
@Test
fun keyGenerationIsDeterministic() {
fun keyGenerationIsDeterministic() = runTest {
assertThat(
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Mainnet.id,
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex()),
).primaryAccountAddress
).publicAddress
).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T")
assertThat(
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Testnet.id,
secretSpendKey = SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".parseHex()),
).primaryAccountAddress
).publicAddress
).isEqualTo("A1v6gVUcGgGE87c1uFRWB1KfPVik2qLLDJiZT3rhZ8qjF3BGA6oHzeDboD23dH8rFaFFcysyqwF6DBj8WUTBWwEhESB7nZz")
assertThat(
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Stagenet.id,
secretSpendKey = SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".parseHex()),
).primaryAccountAddress
).publicAddress
).isEqualTo("54kPaUhYgGNBT72N8Bv2DFMqstLGJCEcWg1EAjwpxABkKL3uBtBLAh4VAPKvhWBdaD4ZpiftA8YWFLAxnWL4aQ9TD4vhY4W")
}
@LargeTest
@Test
fun publicAddressesAreDistinct() {
fun publicAddressesAreDistinct() = runTest {
val publicAddress =
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Mainnet.id,
secretSpendKey = randomSecretKey(),
).primaryAccountAddress
).publicAddress
val anotherPublicAddress =
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Mainnet.id,
secretSpendKey = randomSecretKey(),
).primaryAccountAddress
).publicAddress
assertThat(publicAddress).isNotEqualTo(anotherPublicAddress)
}
@Test
fun atGenesisBalanceIsZero() {
fun atGenesisBalanceIsZero() = runTest {
with(
WalletNative.fullNode(
WalletNative.localSyncWallet(
networkId = MoneroNetwork.Mainnet.id,
secretSpendKey = randomSecretKey(),
).currentBalance
).getLedger()
) {
assertThat(totalAmount).isEqualTo(0.toAtomicUnits())
assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicUnits())
assertThat(transactions).isEmpty()
assertThat(isBalanceZero).isTrue()
}
}
}

@ -3,41 +3,65 @@ package im.molly.monero.mnemonics
import com.google.common.truth.Truth.assertThat
import im.molly.monero.parseHex
import org.junit.Test
import java.util.Locale
class MoneroMnemonicTest {
data class TestCase(val entropy: String, val words: String, val language: String)
data class TestCase(val key: String, val words: String, val language: String) {
val entropy = key.parseHex()
}
private val testVector = listOf(
private val testCases = listOf(
TestCase(
entropy = "3b094ca7218f175e91fa2402b4ae239a2fe8262792a3e718533a1a357a1e4109",
key = "3b094ca7218f175e91fa2402b4ae239a2fe8262792a3e718533a1a357a1e4109",
words = "tavern judge beyond bifocals deepest mural onward dummy eagle diode gained vacation rally cause firm idled jerseys moat vigilant upload bobsled jobs cunning doing jobs",
language = "en",
),
)
@Test
fun validateKnownMnemonics() {
testVector.forEach {
fun testKnownMnemonics() {
testCases.forEach {
validateMnemonicGeneration(it)
validateEntropyRecovery(it)
}
}
@Test(expected = IllegalArgumentException::class)
fun testEmptyEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(0))
}
@Test(expected = IllegalArgumentException::class)
fun testInvalidEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(2))
}
@Test(expected = IllegalArgumentException::class)
fun testEmptyWords() {
MoneroMnemonic.recoverEntropy("")
}
@Test(expected = IllegalArgumentException::class)
fun testInvalidLanguage() {
MoneroMnemonic.generateMnemonic(ByteArray(32), Locale("ZZ"))
}
private fun validateMnemonicGeneration(testCase: TestCase) {
val mnemonicCode = MoneroMnemonic.generateMnemonic(testCase.entropy.parseHex())
val mnemonicCode =
MoneroMnemonic.generateMnemonic(testCase.entropy, Locale(testCase.language))
assertMnemonicCode(mnemonicCode, testCase)
}
private fun validateEntropyRecovery(testCase: TestCase) {
val mnemonicCode = MoneroMnemonic.recoverEntropy(testCase.words)
assertThat(mnemonicCode).isNotNull()
assertMnemonicCode(mnemonicCode!!, testCase)
assertMnemonicCode(mnemonicCode, testCase)
}
private fun assertMnemonicCode(mnemonicCode: MnemonicCode, testCase: TestCase) {
with(mnemonicCode) {
assertThat(entropy).isEqualTo(testCase.entropy.parseHex())
private fun assertMnemonicCode(mnemonicCode: MnemonicCode?, testCase: TestCase) {
assertThat(mnemonicCode).isNotNull()
with(mnemonicCode!!) {
assertThat(entropy).isEqualTo(testCase.entropy)
assertThat(String(words)).isEqualTo(testCase.words)
assertThat(locale.language).isEqualTo(testCase.language)
}

@ -1,3 +0,0 @@
package im.molly.monero;
parcelable HttpResponse;

@ -1,6 +0,0 @@
package im.molly.monero;
oneway interface IHttpRequestCallback {
void onResponse(int code, String contentType, in ParcelFileDescriptor body);
void onFailure();
}

@ -1,8 +0,0 @@
package im.molly.monero;
import im.molly.monero.IHttpRequestCallback;
interface IRemoteNodeClient {
oneway void requestAsync(int requestId, String method, String path, String header, in byte[] bodyBytes, in IHttpRequestCallback callback);
oneway void cancelAsync(int requestId);
}

@ -1,8 +0,0 @@
package im.molly.monero;
import im.molly.monero.IRemoteNodeClient;
interface IWalletClient {
int getNetworkId();
IRemoteNodeClient getRemoteNodeClient();
}

@ -1,15 +1,15 @@
package im.molly.monero;
import im.molly.monero.IRemoteNodeClient;
import im.molly.monero.IStorageAdapter;
import im.molly.monero.IWalletServiceCallbacks;
import im.molly.monero.IWalletServiceListener;
import im.molly.monero.SecretKey;
import im.molly.monero.WalletConfig;
import im.molly.monero.internal.IHttpRpcClient;
interface IWalletService {
oneway void createWallet(in WalletConfig config, in IStorageAdapter storage, in IRemoteNodeClient client, in IWalletServiceCallbacks callback);
oneway void restoreWallet(in WalletConfig config, in IStorageAdapter storage, in IRemoteNodeClient client, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long restorePoint);
oneway void openWallet(in WalletConfig config, in IStorageAdapter storage, in IRemoteNodeClient client, in IWalletServiceCallbacks callback);
oneway void createWallet(in WalletConfig config, in IStorageAdapter storage, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback);
oneway void restoreWallet(in WalletConfig config, in IStorageAdapter storage, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long restorePoint);
oneway void openWallet(in WalletConfig config, in IStorageAdapter storage, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback);
void setListener(in IWalletServiceListener listener);
}

@ -0,0 +1,3 @@
package im.molly.monero.internal;
parcelable HttpRequest;

@ -0,0 +1,3 @@
package im.molly.monero.internal;
parcelable HttpResponse;

@ -0,0 +1,9 @@
package im.molly.monero.internal;
import im.molly.monero.internal.HttpResponse;
oneway interface IHttpRequestCallback {
void onResponse(in HttpResponse response);
void onError();
void onRequestCanceled();
}

@ -0,0 +1,9 @@
package im.molly.monero.internal;
import im.molly.monero.internal.HttpRequest;
import im.molly.monero.internal.IHttpRequestCallback;
interface IHttpRpcClient {
oneway void callAsync(in HttpRequest request, in IHttpRequestCallback callback, int callId);
oneway void cancelAsync(int callId);
}

@ -3,7 +3,7 @@ include(ExternalProject)
# The source code of Boost
# From https://www.boost.org/users/download/
set(BOOST_SOURCE
"https://boostorg.jfrog.io/artifactory/main/release/1.78.0/source/boost_1_78_0.tar.bz2"
"https://archives.boost.io/release/1.78.0/source/boost_1_78_0.tar.bz2"
CACHE STRING "Boost source file to use.")
set(BOOST_SHA256
"8681f175d4bdb26c52222665793eef08490d7758529330f98d3b29dd0735bccc"

@ -104,10 +104,6 @@ set(WALLET2_OVERRIDES
perf_timer_override.cc
)
set(WALLET2_PRECOMPILED_HEADERS
boringssl_compat.h
)
set(WALLET2_INCLUDES
contrib/epee/include
external
@ -135,11 +131,7 @@ add_library(
wallet2 STATIC ${WALLET2_SOURCES} ${WALLET2_OVERRIDES}
)
target_precompile_headers(
wallet2
PRIVATE
"${WALLET2_PRECOMPILED_HEADERS}"
)
target_compile_options(wallet2 PRIVATE -include "${CMAKE_CURRENT_LIST_DIR}/include/boringssl_compat.h")
target_include_directories(
wallet2

@ -24,7 +24,7 @@ jmethodID ParcelFd_detachFd;
ScopedJavaGlobalRef<jclass> StringClass;
void InitializeJniCache(JNIEnv* env) {
jclass httpResponse = GetClass(env, "im/molly/monero/HttpResponse");
jclass httpResponse = GetClass(env, "im/molly/monero/internal/HttpResponse");
jclass iTransferCallback = GetClass(env, "im/molly/monero/ITransferCallback");
jclass logger = GetClass(env, "im/molly/monero/Logger");
jclass txInfo = GetClass(env, "im/molly/monero/internal/TxInfo");
@ -55,7 +55,7 @@ void InitializeJniCache(JNIEnv* env) {
TxInfo_ctor = GetMethodId(
env, txInfo,
"<init>",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;JIIJJJJZZ)V");
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;JIJJJJBZZ)V");
WalletNative_createPendingTransfer = GetMethodId(
env, walletNative,
"createPendingTransfer",
@ -63,7 +63,7 @@ void InitializeJniCache(JNIEnv* env) {
WalletNative_callRemoteNode = GetMethodId(
env, walletNative,
"callRemoteNode",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B)Lim/molly/monero/HttpResponse;");
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B)Lim/molly/monero/internal/HttpResponse;");
WalletNative_onRefresh = GetMethodId(
env, walletNative,
"onRefresh", "(IJZ)V");

@ -72,6 +72,7 @@ void Wallet::restoreAccount(const std::vector<char>& secret_scalar, uint64_t res
std::lock_guard<std::mutex> lock(m_wallet_mutex);
auto& account = m_wallet.get_account();
GenerateAccountKeys(account, secret_scalar);
m_subaddresses[{0, 0}] = m_wallet.get_subaddress_as_str({0, 0});
if (restore_point < CRYPTONOTE_MAX_BLOCK_NUMBER) {
m_restore_height = restore_point;
m_last_block_timestamp = 0;
@ -168,7 +169,6 @@ std::string Wallet::addSubaddressInternal(const cryptonote::subaddress_index& in
std::unique_ptr<PendingTransfer> Wallet::createPayment(
const std::vector<std::string>& addresses,
const std::vector<uint64_t>& amounts,
uint64_t time_lock,
int priority,
uint32_t account_index,
const std::set<uint32_t>& subaddr_indexes) {
@ -196,7 +196,6 @@ std::unique_ptr<PendingTransfer> Wallet::createPayment(
auto ptxs = m_wallet.create_transactions_2(
dsts,
m_wallet.get_min_ring_size() - 1,
time_lock,
priority,
{}, /* extra */
account_index,
@ -812,11 +811,11 @@ ScopedJavaLocalRef<jobject> NativeToJavaTxInfo(JNIEnv* env,
: nullptr,
tx.m_amount,
static_cast<jint>(tx.m_height),
tx.m_state,
tx.m_unlock_time,
tx.m_timestamp,
tx.m_fee,
tx.m_change,
static_cast<jbyte>(tx.m_state),
tx.m_coinbase,
tx.m_type == TxInfo::INCOMING)
};
@ -847,7 +846,6 @@ Java_im_molly_monero_WalletNative_nativeCreatePayment(
jlong handle,
jobjectArray j_addresses,
jlongArray j_amounts,
jlong time_lock,
jint priority,
jint account_index,
jintArray j_subaddr_indexes,
@ -865,7 +863,7 @@ Java_im_molly_monero_WalletNative_nativeCreatePayment(
pending_transfer = wallet->createPayment(
addresses,
{amounts.begin(), amounts.end()},
time_lock, priority,
priority,
account_index,
{subaddr_indexes.begin(), subaddr_indexes.end()});
// } catch (error::daemon_busy& e) {

@ -41,10 +41,10 @@ struct TxInfo {
} m_type;
enum TxState {
OFF_CHAIN = 0,
PENDING = 1,
FAILED = 2,
ON_CHAIN = 3,
OFF_CHAIN = 1,
PENDING = 2,
FAILED = 3,
ON_CHAIN = 4,
} m_state;
TxInfo(crypto::hash tx_hash, TxType type) :
@ -101,7 +101,6 @@ class Wallet : i_wallet2_callback {
std::unique_ptr<PendingTransfer> createPayment(
const std::vector<std::string>& addresses,
const std::vector<uint64_t>& amounts,
uint64_t time_lock,
int priority,
uint32_t account_index,
const std::set<uint32_t>& subaddr_indexes);
@ -175,7 +174,7 @@ class Wallet : i_wallet2_callback {
handleNewBlock(height, block.timestamp);
}
void on_reorg(uint64_t height) override {
void on_reorg(uint64_t height, uint64_t blocks_detached, size_t transfers_detached) override {
handleReorgEvent(height);
}

@ -10,6 +10,9 @@ data class Ledger(
val transactions: Collection<Transaction>
get() = transactionById.values
val isBalanceZero: Boolean
get() = getBalance().totalAmount.isZero
fun getBalance(): Balance = enoteSet.calculateBalance()
fun getBalanceForAccount(accountIndex: Int): Balance =

@ -0,0 +1,58 @@
package im.molly.monero
import im.molly.monero.internal.IHttpRpcClient
import im.molly.monero.internal.RpcClient
import im.molly.monero.loadbalancer.LoadBalancer
import im.molly.monero.loadbalancer.Rule
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import okhttp3.OkHttpClient
class MoneroNodeClient private constructor(
val network: MoneroNetwork,
private val rpcClient: RpcClient,
private val scope: CoroutineScope,
) : AutoCloseable {
companion object {
/**
* Constructs a [MoneroNodeClient] to connect to the Monero [network].
*/
fun forNetwork(
network: MoneroNetwork,
remoteNodes: Flow<List<RemoteNode>>,
loadBalancerRule: Rule,
httpClient: OkHttpClient,
retryBackoff: BackoffPolicy = ExponentialBackoff.Default,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
): MoneroNodeClient {
val scope = CoroutineScope(ioDispatcher + SupervisorJob())
val loadBalancer = LoadBalancer(remoteNodes, scope)
val rpcClient = RpcClient(
loadBalancer = loadBalancer,
loadBalancerRule = loadBalancerRule,
retryBackoff = retryBackoff,
requestsScope = scope,
httpClient = httpClient,
)
return MoneroNodeClient(network, rpcClient, scope)
}
}
var httpClient: OkHttpClient
get() = rpcClient.httpClient
set(value) {
rpcClient.httpClient = value
}
internal val httpRpcClient: IHttpRpcClient
get() = rpcClient
override fun close() {
scope.cancel("MoneroNodeClient is closing: Cancelling all ongoing requests")
}
}

@ -1,7 +1,7 @@
package im.molly.monero
import im.molly.monero.internal.LedgerFactory
import im.molly.monero.internal.TxInfo
import im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
@ -19,7 +19,7 @@ import kotlin.time.Duration.Companion.seconds
class MoneroWallet internal constructor(
private val wallet: IWallet,
private val storageAdapter: StorageAdapter,
val remoteNodeClient: RemoteNodeClient?,
val moneroNodeClient: MoneroNodeClient?,
) : AutoCloseable {
private val logger = loggerFor<MoneroWallet>()
@ -86,7 +86,7 @@ class MoneroWallet internal constructor(
suspendCancellableCoroutine { continuation ->
wallet.getAddressesForAccount(accountIndex, object : BaseWalletCallbacks() {
override fun onSubAddressListReceived(subAddresses: Array<String>) {
val accounts = parseAndAggregateAddresses(subAddresses)
val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
continuation.resume(accounts.single()) {}
}
})
@ -96,23 +96,12 @@ class MoneroWallet internal constructor(
suspendCancellableCoroutine { continuation ->
wallet.getAllAddresses(object : BaseWalletCallbacks() {
override fun onSubAddressListReceived(subAddresses: Array<String>) {
val accounts = parseAndAggregateAddresses(subAddresses)
val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
continuation.resume(accounts) {}
}
})
}
private fun parseAndAggregateAddresses(subAddresses: Array<String>): List<WalletAccount> =
subAddresses.map { AccountAddress.parseWithIndexes(it) }
.groupBy { it.accountIndex }
.map { (index, addresses) ->
WalletAccount(
addresses = addresses,
accountIndex = index,
)
}
.sortedBy { it.accountIndex }
/**
* A [Flow] of ledger changes.
*/
@ -125,17 +114,11 @@ class MoneroWallet internal constructor(
subAddresses: Array<String>,
blockchainTime: BlockchainTime,
) {
val indexedAccounts = parseAndAggregateAddresses(subAddresses)
val (txById, enotes) = txHistory.consolidateTransactions(
accounts = indexedAccounts,
blockchainContext = blockchainTime,
)
val ledger = Ledger(
publicAddress = publicAddress,
indexedAccounts = indexedAccounts,
transactionById = txById,
enoteSet = enotes,
checkedAt = blockchainTime,
val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
val ledger = LedgerFactory.createFromTxHistory(
txHistory = txHistory,
accounts = accounts,
blockchainTime = blockchainTime,
)
sendLedger(ledger)
}
@ -145,7 +128,7 @@ class MoneroWallet internal constructor(
}
override fun onSubAddressListUpdated(subAddresses: Array<String>) {
val accountsUpdated = parseAndAggregateAddresses(subAddresses)
val accountsUpdated = parseAndAggregateAddresses(subAddresses.asIterable())
if (lastKnownLedger.indexedAccounts != accountsUpdated) {
sendLedger(lastKnownLedger.copy(indexedAccounts = accountsUpdated))
}

@ -1,224 +0,0 @@
package im.molly.monero
import android.net.Uri
import android.os.ParcelFileDescriptor
import im.molly.monero.loadbalancer.LoadBalancer
import im.molly.monero.loadbalancer.Rule
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
// TODO: Hide IRemoteNodeClient methods and rename to HttpRpcClient or MoneroNodeClient
class RemoteNodeClient private constructor(
val network: MoneroNetwork,
private val loadBalancer: LoadBalancer,
private val loadBalancerRule: Rule,
var httpClient: OkHttpClient,
private val retryBackoff: BackoffPolicy,
private val requestsScope: CoroutineScope,
) : IRemoteNodeClient.Stub(), AutoCloseable {
companion object {
/**
* Constructs a [RemoteNodeClient] to connect to the Monero [network].
*/
fun forNetwork(
network: MoneroNetwork,
remoteNodes: Flow<List<RemoteNode>>,
loadBalancerRule: Rule,
httpClient: OkHttpClient,
retryBackoff: BackoffPolicy = ExponentialBackoff.Default,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
): RemoteNodeClient {
val scope = CoroutineScope(ioDispatcher + SupervisorJob())
return RemoteNodeClient(
network,
LoadBalancer(remoteNodes, scope),
loadBalancerRule,
httpClient,
retryBackoff,
scope
)
}
}
private val logger = loggerFor<RemoteNodeClient>()
private val requestList = ConcurrentHashMap<Int, Job>()
override fun requestAsync(
requestId: Int,
method: String,
path: String,
header: String?,
body: ByteArray?,
callback: IHttpRequestCallback?,
) {
logger.d("HTTP: $method $path, header_len=${header?.length}, body_size=${body?.size}")
val requestJob = requestsScope.launch {
runCatching {
requestWithRetry(method, path, header, body)
}.onSuccess { response ->
val status = response.code
val responseBody = response.body
if (responseBody == null) {
callback?.onResponse(status, null, null)
} else {
responseBody.use { body ->
val contentType = body.contentType()?.toString()
val pipe = ParcelFileDescriptor.createPipe()
pipe[1].use { writeSide ->
callback?.onResponse(status, contentType, pipe[0])
FileOutputStream(writeSide.fileDescriptor).use { out ->
runCatching { body.byteStream().copyTo(out) }
}
}
}
}
// TODO: Log response times
}.onFailure { throwable ->
logger.e("HTTP: Request failed", throwable)
callback?.onFailure()
}
}.also {
requestList[requestId] = it
}
requestJob.invokeOnCompletion {
requestList.remove(requestId)
}
}
override fun cancelAsync(requestId: Int) {
requestList[requestId]?.cancel()
}
override fun close() {
requestsScope.cancel()
}
private suspend fun requestWithRetry(
method: String,
path: String,
header: String?,
body: ByteArray?,
): Response {
val headers = parseHttpHeader(header)
val contentType = headers["Content-Type"]?.toMediaType()
// TODO: Log unsupported headers
val requestBuilder = with(Request.Builder()) {
when {
method.equals("GET", ignoreCase = true) -> {}
method.equals("POST", ignoreCase = true) -> {
val content = body ?: ByteArray(0)
post(content.toRequestBody(contentType))
}
else -> throw IllegalArgumentException("Unsupported method")
}
url("http:$path")
// TODO: Add authentication
}
val attempts = mutableMapOf<Uri, Int>()
while (true) {
val selected = loadBalancerRule.chooseNode(loadBalancer)
if (selected == null) {
logger.i("No remote node available")
return Response.Builder()
.request(requestBuilder.build())
.protocol(Protocol.HTTP_1_1)
.code(499)
.message("No remote node available")
.build()
}
val uri = selected.uriForPath(path)
val retryCount = attempts[uri] ?: 0
delay(retryBackoff.waitTime(retryCount))
logger.d("HTTP: $method $uri")
val response = try {
val request = requestBuilder.url(uri.toString()).build()
httpClient.newCall(request).await()
} catch (e: IOException) {
logger.e("HTTP: Request failed", e)
// TODO: Notify loadBalancer
continue
} finally {
attempts[uri] = retryCount + 1
}
if (response.isSuccessful) {
// TODO: Notify loadBalancer
return response
}
}
}
private fun parseHttpHeader(header: String?): Headers =
with(Headers.Builder()) {
header?.splitToSequence("\r\n")
?.filter { line -> line.isNotEmpty() }
?.forEach { line -> add(line) }
build()
}
private suspend fun Call.await() = suspendCoroutine { continuation ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
})
}
// private val Response.roundTripMillis: Long
// get() = sentRequestAtMillis() - receivedResponseAtMillis()
}
@OptIn(ExperimentalCoroutinesApi::class)
internal suspend fun IRemoteNodeClient.request(request: HttpRequest): HttpResponse? =
suspendCancellableCoroutine { continuation ->
val requestId = request.hashCode()
val callback = object : IHttpRequestCallback.Stub() {
override fun onResponse(
code: Int,
contentType: String?,
body: ParcelFileDescriptor?,
) {
continuation.resume(HttpResponse(code, contentType, body)) {
body?.close()
}
}
override fun onFailure() {
continuation.resume(null) {}
}
}
with(request) {
requestAsync(requestId, method, path, header, bodyBytes, callback)
}
continuation.invokeOnCancellation {
cancelAsync(requestId)
}
}

@ -10,7 +10,6 @@ data class PaymentRequest(
val paymentDetails: List<PaymentDetail>,
val spendingAccountIndex: Int,
val feePriority: FeePriority? = null,
val timeLock: UnlockTime? = null,
) : TransferRequest
@Parcelize
@ -19,5 +18,4 @@ data class SweepRequest(
val splitCount: Int = 1,
val keyImageHashes: List<HashDigest>,
val feePriority: FeePriority? = null,
val timeLock: UnlockTime? = null,
) : TransferRequest

@ -13,3 +13,11 @@ fun Iterable<WalletAccount>.findAddressByIndex(
it.accountIndex == accountIndex && it.subAddressIndex == subAddressIndex
}
}
fun parseAndAggregateAddresses(addresses: Iterable<String>): List<WalletAccount> {
return addresses
.map { AccountAddress.parseWithIndexes(it) }
.groupBy { it.accountIndex }
.toSortedMap()
.map { (index, addresses) -> WalletAccount(addresses, index) }
}

@ -1,5 +1,7 @@
package im.molly.monero
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
@ -7,3 +9,16 @@ interface WalletDataStore {
suspend fun write(writer: (OutputStream) -> Unit)
suspend fun read(): InputStream
}
class InMemoryWalletDataStore : WalletDataStore {
private val data = ByteArrayOutputStream()
override suspend fun write(writer: (OutputStream) -> Unit) {
data.reset()
writer(data)
}
override suspend fun read(): InputStream {
return ByteArrayInputStream(data.toByteArray())
}
}

@ -2,12 +2,17 @@ package im.molly.monero
import android.os.ParcelFileDescriptor
import androidx.annotation.GuardedBy
import im.molly.monero.internal.HttpRequest
import im.molly.monero.internal.HttpResponse
import im.molly.monero.internal.IHttpRequestCallback
import im.molly.monero.internal.IHttpRpcClient
import im.molly.monero.internal.LedgerFactory
import im.molly.monero.internal.TxInfo
import kotlinx.coroutines.*
import java.io.Closeable
import java.time.Instant
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.coroutines.CoroutineContext
@ -15,17 +20,16 @@ import kotlin.coroutines.CoroutineContext
internal class WalletNative private constructor(
private val network: MoneroNetwork,
private val storageAdapter: IStorageAdapter,
private val remoteNodeClient: IRemoteNodeClient?,
private val rpcClient: IHttpRpcClient?,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
) : IWallet.Stub(), Closeable {
companion object {
// TODO: Find better name because this is a local synchronization wallet, not a full node wallet
suspend fun fullNode(
suspend fun localSyncWallet(
networkId: Int,
storageAdapter: IStorageAdapter,
remoteNodeClient: IRemoteNodeClient? = null,
storageAdapter: IStorageAdapter = StorageAdapter(InMemoryWalletDataStore()),
rpcClient: IHttpRpcClient? = null,
secretSpendKey: SecretKey? = null,
restorePoint: Long? = null,
coroutineContext: CoroutineContext = Dispatchers.Default + SupervisorJob(),
@ -33,7 +37,7 @@ internal class WalletNative private constructor(
) = WalletNative(
network = MoneroNetwork.fromId(networkId),
storageAdapter = storageAdapter,
remoteNodeClient = remoteNodeClient,
rpcClient = rpcClient,
scope = CoroutineScope(coroutineContext),
ioDispatcher = ioDispatcher,
).apply {
@ -102,7 +106,26 @@ internal class WalletNative private constructor(
}
}
override fun getPublicAddress() = nativeGetPublicAddress(handle)
override fun getPublicAddress(): String = nativeGetPublicAddress(handle)
fun getCurrentBlockchainTime(): BlockchainTime {
return network.blockchainTime(
nativeGetCurrentBlockchainHeight(handle),
nativeGetCurrentBlockchainTimestamp(handle),
)
}
fun getAllAccounts(): List<WalletAccount> {
return parseAndAggregateAddresses(getSubAddresses().asIterable())
}
fun getLedger(): Ledger {
return LedgerFactory.createFromTxHistory(
txHistory = getTxHistorySnapshot(),
accounts = getAllAccounts(),
blockchainTime = getCurrentBlockchainTime(),
)
}
private fun MoneroNetwork.blockchainTime(height: Int, epochSecond: Long): BlockchainTime {
// Block timestamp could be zero during a fast refresh.
@ -113,15 +136,6 @@ internal class WalletNative private constructor(
return BlockchainTime(height = height, timestamp = timestamp, network = this)
}
val currentBlockchainTime: BlockchainTime
get() = network.blockchainTime(
nativeGetCurrentBlockchainHeight(handle),
nativeGetCurrentBlockchainTimestamp(handle),
)
val currentBalance: Balance
get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance()
private fun getSubAddresses(accountIndex: Int? = null): Array<String> {
return nativeGetSubAddresses(accountIndex ?: -1, handle)
}
@ -152,7 +166,7 @@ internal class WalletNative private constructor(
nativeCancelRefresh(handle)
}
}
callback?.onRefreshResult(currentBlockchainTime, status)
callback?.onRefreshResult(getCurrentBlockchainTime(), status)
}
}
@ -189,7 +203,6 @@ internal class WalletNative private constructor(
handle = handle,
addresses = addresses.toTypedArray(),
amounts = amounts.toLongArray(),
timeLock = request.timeLock?.blockchainTime?.toLong() ?: 0,
priority = request.feePriority?.priority ?: 0,
accountIndex = request.spendingAccountIndex,
subAddressIndexes = IntArray(0),
@ -266,7 +279,7 @@ internal class WalletNative private constructor(
balanceListenersLock.withLock {
balanceListeners.add(listener)
listener.onBalanceChanged(txHistory, subAddresses, currentBlockchainTime)
listener.onBalanceChanged(txHistory, subAddresses, getCurrentBlockchainTime())
}
}
@ -381,8 +394,8 @@ internal class WalletNative private constructor(
*/
@CalledByNative
private fun callRemoteNode(
method: String?,
path: String?,
method: String,
path: String,
header: String?,
body: ByteArray?,
): HttpResponse? = runBlocking {
@ -390,8 +403,9 @@ internal class WalletNative private constructor(
if (!requestsAllowed) {
return@runBlocking null
}
val httpRequest = HttpRequest(method, path, header, body)
pendingRequest = async {
remoteNodeClient?.request(HttpRequest(method, path, header, body))
rpcClient?.newCall(httpRequest)
}
}
try {
@ -409,6 +423,33 @@ internal class WalletNative private constructor(
}
}
private val callCounter = AtomicInteger()
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun IHttpRpcClient.newCall(request: HttpRequest): HttpResponse? =
suspendCancellableCoroutine { continuation ->
val callback = object : IHttpRequestCallback.Stub() {
override fun onResponse(response: HttpResponse) {
continuation.resume(response) {
response.close()
}
}
override fun onError() {
continuation.resume(null) {}
}
override fun onRequestCanceled() {
continuation.resume(null) {}
}
}
val callId = callCounter.incrementAndGet()
callAsync(request, callback, callId)
continuation.invokeOnCancellation {
cancelAsync(callId)
}
}
override fun close() {
scope.cancel()
}
@ -437,7 +478,6 @@ internal class WalletNative private constructor(
handle: Long,
addresses: Array<String>,
amounts: LongArray,
timeLock: Long,
priority: Int,
accountIndex: Int,
subAddressIndexes: IntArray,

@ -51,13 +51,13 @@ class WalletProvider private constructor(
suspend fun createNewWallet(
network: MoneroNetwork,
dataStore: WalletDataStore? = null,
client: RemoteNodeClient? = null,
client: MoneroNodeClient? = null,
): MoneroWallet {
require(client == null || client.network == network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.createWallet(
buildConfig(network), storageAdapter, client,
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}
@ -67,7 +67,7 @@ class WalletProvider private constructor(
suspend fun restoreWallet(
network: MoneroNetwork,
dataStore: WalletDataStore? = null,
client: RemoteNodeClient? = null,
client: MoneroNodeClient? = null,
secretSpendKey: SecretKey,
restorePoint: RestorePoint,
): MoneroWallet {
@ -78,7 +78,7 @@ class WalletProvider private constructor(
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.restoreWallet(
buildConfig(network), storageAdapter, client,
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
secretSpendKey,
restorePoint.toLong(),
@ -90,13 +90,13 @@ class WalletProvider private constructor(
suspend fun openWallet(
network: MoneroNetwork,
dataStore: WalletDataStore,
client: RemoteNodeClient? = null,
client: MoneroNodeClient? = null,
): MoneroWallet {
require(client == null || client.network == network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.openWallet(
buildConfig(network), storageAdapter, client,
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}

@ -4,6 +4,7 @@ import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.molly.monero.internal.IHttpRpcClient
import kotlinx.coroutines.*
class WalletService : LifecycleService() {
@ -43,13 +44,13 @@ internal class WalletServiceImpl(
override fun createWallet(
config: WalletConfig,
storage: IStorageAdapter,
client: IRemoteNodeClient?,
rpcClient: IHttpRpcClient?,
callback: IWalletServiceCallbacks?,
) {
serviceScope.launch {
val secretSpendKey = randomSecretKey()
val wallet = secretSpendKey.use { secret ->
createOrRestoreWallet(config, storage, client, secret)
createOrRestoreWallet(config, storage, rpcClient, secret)
}
callback?.onWalletResult(wallet)
}
@ -58,14 +59,14 @@ internal class WalletServiceImpl(
override fun restoreWallet(
config: WalletConfig,
storage: IStorageAdapter,
client: IRemoteNodeClient?,
rpcClient: IHttpRpcClient?,
callback: IWalletServiceCallbacks?,
secretSpendKey: SecretKey,
restorePoint: Long,
) {
serviceScope.launch {
val wallet = secretSpendKey.use { secret ->
createOrRestoreWallet(config, storage, client, secret, restorePoint)
createOrRestoreWallet(config, storage, rpcClient, secret, restorePoint)
}
callback?.onWalletResult(wallet)
}
@ -74,14 +75,14 @@ internal class WalletServiceImpl(
override fun openWallet(
config: WalletConfig,
storage: IStorageAdapter,
client: IRemoteNodeClient?,
rpcClient: IHttpRpcClient?,
callback: IWalletServiceCallbacks?,
) {
serviceScope.launch {
val wallet = WalletNative.fullNode(
val wallet = WalletNative.localSyncWallet(
networkId = config.networkId,
storageAdapter = storage,
remoteNodeClient = client,
rpcClient = rpcClient,
coroutineContext = serviceScope.coroutineContext,
)
callback?.onWalletResult(wallet)
@ -91,14 +92,14 @@ internal class WalletServiceImpl(
private suspend fun createOrRestoreWallet(
config: WalletConfig,
storage: IStorageAdapter,
client: IRemoteNodeClient?,
rpcClient: IHttpRpcClient?,
secretSpendKey: SecretKey,
restorePoint: Long? = null,
): IWallet {
return WalletNative.fullNode(
return WalletNative.localSyncWallet(
networkId = config.networkId,
storageAdapter = storage,
remoteNodeClient = client,
rpcClient = rpcClient,
secretSpendKey = secretSpendKey,
restorePoint = restorePoint,
coroutineContext = serviceScope.coroutineContext,

@ -1,11 +1,19 @@
package im.molly.monero
package im.molly.monero.internal
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class HttpRequest(
val method: String?,
val path: String?,
val method: String,
val path: String,
val header: String?,
val bodyBytes: ByteArray?,
) {
) : Parcelable {
override fun toString(): String =
"HttpRequest(method=$method, path=$path, headers=${header?.length}, body=${bodyBytes?.size})"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@ -24,8 +32,8 @@ data class HttpRequest(
}
override fun hashCode(): Int {
var result = method?.hashCode() ?: 0
result = 31 * result + (path?.hashCode() ?: 0)
var result = method.hashCode()
result = 31 * result + path.hashCode()
result = 31 * result + (header?.hashCode() ?: 0)
result = 31 * result + (bodyBytes?.contentHashCode() ?: 0)
return result

@ -1,12 +1,15 @@
package im.molly.monero
package im.molly.monero.internal
import android.os.ParcelFileDescriptor
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class HttpResponse(
val code: Int,
val contentType: String? = null,
val body: ParcelFileDescriptor? = null,
) : AutoCloseable {
) : AutoCloseable, Parcelable {
override fun close() {
body?.close()
}

@ -0,0 +1,28 @@
package im.molly.monero.internal
import im.molly.monero.BlockchainTime
import im.molly.monero.Ledger
import im.molly.monero.WalletAccount
import im.molly.monero.findAddressByIndex
internal object LedgerFactory {
fun createFromTxHistory(
txHistory: List<TxInfo>,
accounts: List<WalletAccount>,
blockchainTime: BlockchainTime,
): Ledger {
val (txById, enotes) = txHistory.consolidateTransactions(
accounts = accounts,
blockchainContext = blockchainTime,
)
val publicAddress = accounts.findAddressByIndex(accountIndex = 0)
checkNotNull(publicAddress)
return Ledger(
publicAddress = publicAddress,
indexedAccounts = accounts,
transactionById = txById,
enoteSet = enotes,
checkedAt = blockchainTime,
)
}
}

@ -0,0 +1,186 @@
package im.molly.monero.internal
import android.net.Uri
import android.os.ParcelFileDescriptor
import im.molly.monero.BackoffPolicy
import im.molly.monero.loadbalancer.LoadBalancer
import im.molly.monero.loadbalancer.Rule
import im.molly.monero.loggerFor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class RpcClient internal constructor(
private val loadBalancer: LoadBalancer,
private val loadBalancerRule: Rule,
private val retryBackoff: BackoffPolicy,
private val requestsScope: CoroutineScope,
var httpClient: OkHttpClient,
) : IHttpRpcClient.Stub() {
private val logger = loggerFor<RpcClient>()
private val activeRequests = ConcurrentHashMap<Int, Job>()
override fun callAsync(request: HttpRequest, callback: IHttpRequestCallback, callId: Int) {
logger.d("[$callId] Dispatching $request")
val requestJob = requestsScope.launch {
runCatching {
requestWithRetry(request, callId)
}.onSuccess { response ->
val status = response.code
val responseBody = response.body
if (responseBody == null) {
callback.onResponse(
HttpResponse(code = status, contentType = null, body = null)
)
} else {
responseBody.use { body ->
val contentType = body.contentType()?.toString()
val pipe = ParcelFileDescriptor.createPipe()
pipe[0].use { readSize ->
pipe[1].use { writeSide ->
val httpResponse = HttpResponse(
code = status,
contentType = contentType,
body = readSize,
)
callback.onResponse(httpResponse)
FileOutputStream(writeSide.fileDescriptor).use { out ->
runCatching { body.byteStream().copyTo(out) }
}
}
}
}
}
// TODO: Log response times
}.onFailure { throwable ->
when (throwable) {
is CancellationException -> callback.onRequestCanceled()
else -> {
logger.e("[$callId] Failed to dispatch $request", throwable)
callback.onError()
}
}
}
}.also { job ->
val oldJob = activeRequests.put(callId, job)
check(oldJob == null)
}
requestJob.invokeOnCompletion {
activeRequests.remove(callId)
}
}
override fun cancelAsync(requestId: Int) {
activeRequests[requestId]?.cancel()
}
private suspend fun requestWithRetry(request: HttpRequest, callId: Int): Response {
val headers = parseHttpHeader(request.header)
val contentType = headers["Content-Type"]?.toMediaType()
// TODO: Log unsupported headers
val requestBuilder = createRequestBuilder(request, contentType)
val attempts = mutableMapOf<Uri, Int>()
while (true) {
val selected = loadBalancerRule.chooseNode(loadBalancer)
if (selected == null) {
val errorMsg = "No remote node available"
logger.i("[$callId] $errorMsg")
return Response.Builder()
.request(requestBuilder.build())
.protocol(Protocol.HTTP_1_1)
.code(499)
.message(errorMsg)
.build()
}
val uri = selected.uriForPath(request.path)
val retryCount = attempts[uri] ?: 0
delay(retryBackoff.waitTime(retryCount))
logger.d("[$callId] HTTP: ${request.method} $uri")
try {
val response =
httpClient.newCall(requestBuilder.url(uri.toString()).build()).await()
// TODO: Notify loadBalancer
if (response.isSuccessful) {
return response
}
} catch (e: IOException) {
logger.w("[$callId] HTTP: Request failed with ${e::class.simpleName}: ${e.message}")
// TODO: Notify loadBalancer
} finally {
attempts[uri] = retryCount + 1
}
}
}
private fun parseHttpHeader(header: String?): Headers {
return with(Headers.Builder()) {
header?.splitToSequence("\r\n")
?.filter { line -> line.isNotEmpty() }
?.forEach { line -> add(line) }
build()
}
}
private fun createRequestBuilder(
request: HttpRequest,
contentType: MediaType?,
): Request.Builder {
return with(Request.Builder()) {
when {
request.method.equals("GET", ignoreCase = true) -> {}
request.method.equals("POST", ignoreCase = true) -> {
val content = request.bodyBytes ?: ByteArray(0)
post(content.toRequestBody(contentType))
}
else -> throw IllegalArgumentException("Unsupported method")
}
url("http:${request.path}")
// TODO: Add authentication
}
}
private suspend fun Call.await() = suspendCoroutine { continuation ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
})
}
// private val Response.roundTripMillis: Long
// get() = sentRequestAtMillis() - receivedResponseAtMillis()
}

@ -37,20 +37,20 @@ internal data class TxInfo @CalledByNative constructor(
val recipient: String?,
val amount: Long,
val height: Int,
val state: Int,
val unlockTime: Long,
val timestamp: Long,
val fee: Long,
val change: Long,
val state: Byte,
val coinbase: Boolean,
val incoming: Boolean,
) : Parcelable {
companion object State {
const val OFF_CHAIN: Int = 0
const val PENDING: Int = 1
const val FAILED: Int = 2
const val ON_CHAIN: Int = 3
const val OFF_CHAIN: Byte = 1
const val PENDING: Byte = 2
const val FAILED: Byte = 3
const val ON_CHAIN: Byte = 4
}
init {

@ -12,12 +12,15 @@ class MnemonicCode private constructor(
val locale: Locale,
) : Destroyable, Closeable, Iterable<CharArray> {
constructor(entropy: ByteArray, words: CharBuffer, locale: Locale) : this(
constructor(entropy: ByteArray, words: CharBuffer, locale: Locale = Locale.ENGLISH) : this(
entropy.clone(),
words.array().copyOfRange(words.position(), words.remaining()),
locale,
)
internal val isNonZero
get() = !MessageDigest.isEqual(_entropy, ByteArray(_entropy.size))
val entropy: ByteArray
get() = checkNotDestroyed { _entropy.clone() }
@ -66,9 +69,9 @@ class MnemonicCode private constructor(
protected fun finalize() = destroy()
override fun equals(other: Any?): Boolean =
this === other || (other is MnemonicCode && MessageDigest.isEqual(entropy, other.entropy))
this === other || (other is MnemonicCode && MessageDigest.isEqual(_entropy, other._entropy))
override fun hashCode(): Int = entropy.contentHashCode()
override fun hashCode(): Int = _entropy.contentHashCode()
private inline fun <T> checkNotDestroyed(block: () -> T): T {
check(!destroyed) { "MnemonicCode has already been destroyed" }

@ -7,7 +7,6 @@ import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
import java.util.Locale
object MoneroMnemonic {
init {
NativeLoader.loadMnemonicsLibrary()

@ -1,11 +0,0 @@
package im.molly.monero.util
fun CharSequence.parseHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return ByteArray(length / 2) {
Integer.parseInt(substring(it * 2, (it + 1) * 2), 16).toByte()
}
}
fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) }

@ -1,9 +1,9 @@
package im.molly.monero
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertThrows
import org.junit.Test
import kotlin.random.Random
import kotlin.test.assertFailsWith
class SecretKeyTest {
@ -14,20 +14,29 @@ class SecretKeyTest {
if (size == 32) {
assertThat(SecretKey(secret).bytes).hasLength(size)
} else {
assertThrows(RuntimeException::class.java) { SecretKey(secret) }
assertFailsWith<IllegalArgumentException> { SecretKey(secret) }
}
}
}
@Test
fun `secret key copies buffer`() {
val secretBytes = Random.nextBytes(32)
val key = SecretKey(secretBytes)
assertThat(key.bytes).isEqualTo(secretBytes)
secretBytes.fill(0)
assertThat(key.bytes).isNotEqualTo(secretBytes)
}
@Test
fun `secret keys cannot be zero`() {
assertThrows(RuntimeException::class.java) { SecretKey(ByteArray(32)).bytes }
assertFailsWith<IllegalStateException> { SecretKey(ByteArray(32)).bytes }
}
@Test
fun `when key is destroyed secret is zeroed`() {
val secretBytes = Random.nextBytes(32)
val key = SecretKey(secretBytes)
assertThat(key.destroyed).isFalse()
@ -37,20 +46,20 @@ class SecretKeyTest {
assertThat(key.destroyed).isTrue()
assertThat(key.isNonZero).isFalse()
assertThrows(RuntimeException::class.java) { key.bytes }
assertFailsWith<IllegalStateException> { key.bytes }
}
@Test
fun `two keys with same secret are the same`() {
fun `two keys with same secret are equal`() {
val secret = Random.nextBytes(32)
val key = SecretKey(secret)
val sameKey = SecretKey(secret)
val anotherKey = randomSecretKey()
val differentKey = randomSecretKey()
assertThat(key).isEqualTo(sameKey)
assertThat(sameKey).isNotEqualTo(anotherKey)
assertThat(anotherKey).isNotEqualTo(key)
assertThat(sameKey).isNotEqualTo(differentKey)
assertThat(differentKey).isNotEqualTo(key)
}
@Test
@ -64,7 +73,6 @@ class SecretKeyTest {
@Test
fun `keys are not equal to their destroyed versions`() {
val secret = Random.nextBytes(32)
val key = SecretKey(secret)
val destroyed = SecretKey(secret).also { it.destroy() }
@ -73,9 +81,9 @@ class SecretKeyTest {
@Test
fun `destroyed keys are equal`() {
val destroyed = randomSecretKey().also { it.destroy() }
val anotherDestroyed = randomSecretKey().also { it.destroy() }
val destroyed1 = randomSecretKey().also { it.destroy() }
val destroyed2 = randomSecretKey().also { it.destroy() }
assertThat(destroyed).isEqualTo(anotherDestroyed)
assertThat(destroyed1).isEqualTo(destroyed2)
}
}

@ -0,0 +1,105 @@
package im.molly.monero.mnemonic
import com.google.common.truth.Truth.assertThat
import im.molly.monero.mnemonics.MnemonicCode
import org.junit.Test
import java.nio.CharBuffer
import java.util.Locale
import kotlin.random.Random
import kotlin.test.assertFailsWith
class MnemonicCodeTest {
private fun randomEntropy(size: Int = 32): ByteArray = Random.nextBytes(size)
private fun charBufferOf(str: String): CharBuffer = CharBuffer.wrap(str.toCharArray())
@Test
fun `mnemonic copies entropy and words`() {
val entropy = randomEntropy()
val words = charBufferOf("arbre soleil maison")
val locale = Locale.FRANCE
val mnemonic = MnemonicCode(entropy, words, locale)
assertThat(mnemonic.entropy).isEqualTo(entropy)
assertThat(mnemonic.words).isEqualTo(words.array())
assertThat(mnemonic.locale).isEqualTo(locale)
entropy.fill(0)
words.put("modified".toCharArray())
assertThat(mnemonic.entropy).isNotEqualTo(entropy)
assertThat(mnemonic.words).isNotEqualTo(words.array())
}
@Test
fun `destroyed mnemonic code zeroes entropy and words`() {
val entropy = randomEntropy()
val words = charBufferOf("test mnemonic")
val mnemonic = MnemonicCode(entropy, words)
mnemonic.destroy()
assertThat(mnemonic.destroyed).isTrue()
assertThat(mnemonic.isNonZero).isFalse()
assertFailsWith<IllegalStateException> { mnemonic.words }
assertFailsWith<IllegalStateException> { mnemonic.entropy }
}
@Test
fun `two mnemonics with same entropy are equal`() {
val entropy = randomEntropy()
val words = charBufferOf("test mnemonic")
val locale = Locale.ENGLISH
val mnemonic = MnemonicCode(entropy, words, locale)
val sameMnemonic = MnemonicCode(entropy, words, locale)
val differentMnemonic = MnemonicCode(randomEntropy(), words, locale)
assertThat(mnemonic).isEqualTo(sameMnemonic)
assertThat(differentMnemonic).isNotEqualTo(mnemonic)
}
@Test
fun `iterator correctly iterates words`() {
val words = charBufferOf("word1 word2 word3")
val mnemonic = MnemonicCode(randomEntropy(), words)
val iteratedWords = mnemonic.map { String(it) }
assertThat(iteratedWords).containsExactly("word1", "word2", "word3").inOrder()
}
@Test
fun `calling next on iterator without checking hasNext throws exception`() {
val words = charBufferOf("test mnemonic")
val mnemonic = MnemonicCode(randomEntropy(), words)
val iterator = mnemonic.iterator()
iterator.next()
iterator.next()
assertFailsWith<NoSuchElementException> { iterator.next() }
}
@Test
fun `mnemonics are not equal to their destroyed versions`() {
val entropy = randomEntropy()
val words = charBufferOf("test mnemonic")
val mnemonic = MnemonicCode(entropy, words)
val destroyed = MnemonicCode(entropy, words).also { it.destroy() }
assertThat(mnemonic).isNotEqualTo(destroyed)
}
@Test
fun `destroyed mnemonics are equal`() {
val destroyed1 = MnemonicCode(randomEntropy(), charBufferOf("word1")).also { it.destroy() }
val destroyed2 = MnemonicCode(randomEntropy(), charBufferOf("word2")).also { it.destroy() }
assertThat(destroyed1).isEqualTo(destroyed2)
}
}

@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import org.junit.Assert
import org.junit.Test
@OptIn(ExperimentalStdlibApi::class)
class Base58Test {
// Test cases from monero unit_tests/base58.cpp
@ -134,7 +135,7 @@ class Base58Test {
@Test
fun `decode valid base58 strings`() {
base58ToHex.forEach { (input, expected) ->
assertThat(input.decodeBase58()).isEqualTo(expected.parseHex())
assertThat(input.decodeBase58()).isEqualTo(expected.hexToByteArray())
}
}

@ -1,23 +0,0 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
includeProject(':lib', 'lib/android')
includeProject(':demo', 'demo/android')
def includeProject(String name, String filePath) {
include(name)
project(name).projectDir = file(filePath)
}

44
settings.gradle.kts Normal file

@ -0,0 +1,44 @@
pluginManagement {
includeBuild("build-logic")
repositories {
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
google {
content {
includeGroupByRegex("com\\.android(\\..*)?")
includeGroupByRegex("com\\.google(\\..*)?")
includeGroupByRegex("androidx?(\\..*)?")
}
}
mavenCentral()
}
versionCatalogs {
// "libs" is predefined by Gradle
create("testLibs") {
from(files("gradle/test-libs.versions.toml"))
}
}
}
check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) {
"""
This project requires JDK 21+ but it is currently using JDK ${JavaVersion.current()}.
Java Home: [${System.getProperty("java.home")}]
https://developer.android.com/build/jdks#jdk-config-in-studio
""".trimIndent()
}
includeProject("lib", "lib/android")
includeProject("demo", "demo/android")
fun includeProject(projectName: String, projectRoot: String) {
val projectId = ":$projectName"
include(projectId)
project(projectId).projectDir = file(projectRoot)
}

2
vendor/monero vendored

@ -1 +1 @@
Subproject commit 36d6a9c4c68a9c787a14d419edc05384fe1c506d
Subproject commit 64eebdb3ace488435a95acd6eb4e48f908a47c2c