Compare commits

...

51 commits

Author SHA1 Message Date
Oscar Mira
4f99e95dee
lib: add export methods for wallet secret keys 2025-06-05 21:38:14 +02:00
Oscar Mira
0088b1b562
lib: add view/spend public keys to PublicAddress 2025-06-05 21:37:36 +02:00
Oscar Mira
48fc04a490
lib: prepare 1.0.1-SNAPSHOT 2025-05-31 11:55:58 +02:00
Oscar Mira
328a23edea
lib: bump version to 1.0.0 2025-05-31 11:23:52 +02:00
Oscar Mira
88c689b58d
ci: unify test and snapshot workflows into build.yml 2025-05-31 10:29:59 +02:00
Oscar Mira
dc3f362ec7
ci: fix snapshot version check 2025-05-31 01:08:28 +02:00
Oscar Mira
f38b35e792
ci: enable KVM on runner to fix emulator startup 2025-05-30 20:33:43 +02:00
Oscar Mira
dd9c2ca7f3
ci: add disk-cleanup action 2025-05-30 20:33:43 +02:00
Oscar Mira
cbe714ea35
ci: add workflow to publish snapshots to Maven Central 2025-05-30 20:33:43 +02:00
Oscar Mira
607dd440b8
ci: enable instrumented tests 2025-05-30 18:11:40 +02:00
Oscar Mira
a25334a18c
ci: skip signing for snapshots 2025-05-30 16:15:37 +02:00
Oscar Mira
ac021c2567
ci: use setup-gradle action in test workflow 2025-05-30 16:15:37 +02:00
Oscar Mira
8c2a27964b
doc: add README.md 2025-05-30 16:15:24 +02:00
Oscar Mira
b895d2c01c
lib: prepare for publishing 2025-05-28 22:20:28 +02:00
Oscar Mira
0ad0ade8e3
lib: rename package to im.molly.monero.sdk
Renamed package name from im.molly.monero to im.molly.monero.sdk to
align with project modularization and avoid namespace confusion.
2025-05-28 22:20:28 +02:00
Oscar Mira
0d7f98b622
lib: add more unit and E2E tests 2025-05-28 22:20:28 +02:00
Oscar Mira
40ca4b4237
monero: update submodule 2025-05-28 22:20:27 +02:00
Oscar Mira
59d2c7e402
lib: add keyImages property to Ledger 2025-05-28 22:20:27 +02:00
Oscar Mira
c030b6565c
lib: refactor MoneroNodeClient and RemoteNode
- Renamed MoneroNodeClient.forNetwork() to create()
- Added RemoteNode.singleNodeClient() extension
- Replaced uri with url:String in RemoteNode
- Added FirstRule load balancing strategy
2025-05-28 22:20:22 +02:00
Oscar Mira
0152cadc8f
lib: replace referential equality in Enote 2025-05-28 22:20:22 +02:00
Oscar Mira
a02241128a
lib: rename timeLockedAmounts to lockableAmounts 2025-05-28 22:20:22 +02:00
Oscar Mira
e9cae0b359
lib: add top-level network aliases 2025-05-28 22:20:00 +02:00
Oscar Mira
aeebd6ff32
lib: move more internal classes to package 2025-05-26 19:45:04 +02:00
Oscar Mira
adfe3c8d7a
lib: expand test coverage 2025-05-26 19:45:04 +02:00
Oscar Mira
44824c4991
lib: update libsodium to 1.0.20 2025-05-26 19:45:04 +02:00
Oscar Mira
c8331ea58b
build: upgrade to AGP 8.10.0 2025-05-26 19:44:58 +02:00
Oscar Mira
524314f391
lib: use large heap 2025-05-01 20:55:41 +02:00
Oscar Mira
d2dd6171fe
lib: improve DataStore API 2025-05-01 20:55:41 +02:00
Oscar Mira
e9d08f95d1
lib: move native classes to internal package 2025-04-20 01:35:37 +02:00
Oscar Mira
21e99c74c0
lib: add more unit tests 2025-04-19 22:04:22 +02:00
Oscar Mira
dcc3552daa
build: downgrade JDK to 17 2025-04-19 22:04:22 +02:00
Oscar Mira
c15ba3fb22
lib: add in-process wallet provider 2025-04-19 22:04:22 +02:00
Oscar Mira
5f6f67c7fc
lib: send balance updates in batches 2025-02-27 23:08:11 +01:00
Oscar Mira
f97854e424
lib: optimize hex string parceling 2025-02-26 12:40:42 +01:00
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
197 changed files with 3834 additions and 1749 deletions

37
.github/actions/disk-cleanup/action.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Disk cleanup
description: "Free up disk space by removing unused pre-installed software."
runs:
using: composite
steps:
- name: Free up disk space
shell: bash
run: |
echo "Disk space before cleanup:"
df -h /
echo "Removing unused software..."
sudo rm -rf \
/opt/az \
/opt/google/chrome \
/opt/hostedtoolcache/CodeQL \
/opt/microsoft \
/opt/pipx \
/usr/lib/dotnet \
/usr/lib/firefox \
/usr/lib/google-cloud-sdk \
/usr/lib/mono \
/usr/local/.ghcup \
/usr/local/aws-cli \
/usr/local/julia* \
/usr/local/share/chromium \
/usr/local/share/powershell \
/usr/local/share/vcpkg \
/usr/local/aws-sam-cli \
/usr/share/az_* \
/usr/share/dotnet \
/usr/share/man \
/usr/share/miniconda \
/usr/share/swift \
echo "Disk space after cleanup:"
df -h /

70
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: Build
on:
pull_request:
push:
branches:
- '**'
paths-ignore:
- 'README.md'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
permissions:
contents: read
jobs:
build:
name: Build & Run tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Free up disk space in runner
uses: ./.github/actions/disk-cleanup
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Enable KVM
run: |
sudo tee /etc/udev/rules.d/99-kvm4all.rules >/dev/null <<EOF
KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"
EOF
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Extract library version info
id: meta
run: |
version=$(awk -F'"' '/^version\s*=/{print $2}' lib/android/build.gradle.kts)
echo "version=v$version" >> $GITHUB_OUTPUT
- name: Compile modules
run: ./gradlew assemble --scan
- name: Run unit tests
run: ./gradlew check
- name: Run instrumented tests on emulator
run: ./gradlew ciGroupDebugAndroidTest
- name: Publish Snapshot
if: "github.ref_name == 'main' && endsWith(steps.meta.outputs.version, 'SNAPSHOT')"
run: ./gradlew publishToMavenCentral
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# Monero Wallet SDK for Android
[![Build](https://github.com/mollyim/monero-wallet-sdk/actions/workflows/build.yml/badge.svg)](https://github.com/mollyim/monero-wallet-sdk/actions/workflows/build.yml)
![Maven Central](https://img.shields.io/maven-central/v/im.molly/monero-wallet-sdk)
![Snapshot](https://img.shields.io/maven-metadata/v?color=orange&label=snapshot&metadataUrl=https%3A%2F%2Fcentral.sonatype.com%2Frepository%2Fmaven-snapshots%2Fim%2Fmolly%2Fmonero-wallet-sdk%2Fmaven-metadata.xml)
A modern Kotlin library that embeds Monero's wallet2 inside a sandboxed Android Service and
exposes an idiomatic, asynchronous API for mobile apps.
## Key Features
- **Kotlin-native API**: Asynchronous by design, using `suspend` functions and `Flow`.
- **Sandboxed native code**: All C++ runs in a zero-privilege, isolated process.
- **Pluggable storage**: Bring your own persistence layer (files, DB, cloud) via the
`StorageProvider` interface.
- **Custom HTTP stack**: Inject any networking code (plain, Tor, I2P, QUIC, …) to talk to Monero
remote nodes.
- **Client-side load-balancing**: Automatic node selection for faster sync & fail-over.
- **Tiny library (~6.5 MB AAR)**: LTO, dead-code elimination, and static vendored deps keep the
footprint small.
- **Jetpack-Compose demo wallet**: Full sample app following Google's official architecture
guidelines.
## Setup
The SDK is available on Maven Central:
```kotlin
dependencies {
implementation("im.molly:monero-wallet-sdk:<latest-version>")
}
```
Make sure `mavenCentral()` is in your repositories block.
To use snapshot versions:
```kotlin
repositories {
maven {
name = "Central Portal Snapshots"
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
content {
includeModule("im.molly", "monero-wallet-sdk")
}
}
}
```
```kotlin
dependencies {
implementation("im.molly:monero-wallet-sdk:<snapshot-version>")
}
```
Replace `<latest-version>` or `<snapshot-version>` with the version you want to use.
## Demo App
A fully functional demo wallet is included in `demo/android`, implemented using Jetpack Compose
and following Android's modern app architecture best practices.
To try it out:
1. Clone the repository with submodules:
```sh
git clone --recursive https://github.com/mollyim/monero-wallet-sdk
```
2. Open the root project directory in Android Studio (Meerkat or later).
3. Select the demo run configuration and press Run.
The demo app showcases wallet creation, sync, transaction sending, and more.
## Requirements
- Android 8.0 (API 26+)
- Kotlin **2.1.0**
- Android Gradle Plugin **8.1.0+**
## Roadmap
| Feature | Status |
|---------------------------------|-----------------|
| Wallet management (create/open) | ✅ Done |
| Balance, history, sync | ✅ Done |
| Send XMR | ✅ Done |
| Seraphis migration support | 🔜 Planned |
## Acknowledgements
- Funded by the Monero Community Crowdfunding System (CCS).
## License
This project is licensed under the
[GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.txt).

View file

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

View file

@ -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 },
)
}
}

View file

@ -0,0 +1,22 @@
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
import java.io.File
class MoneroSdkModulePlugin : Plugin<Project> {
override fun apply(project: Project): Unit = with(project) {
apply(plugin = "jacoco")
configureJacoco(extensions.getByType<LibraryAndroidComponentsExtension>())
}
}
val Project.vendorDir: File
get() = rootProject.layout.projectDirectory.dir("vendor").asFile
val Project.downloadCacheDir: File
get() = layout.buildDirectory.dir("downloads").get().asFile
val Project.isSnapshot: Boolean
get() = version.toString().endsWith("SNAPSHOT")

View file

@ -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"

View file

@ -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()
}

16
build.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
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
alias(libs.plugins.publish) apply false
}
tasks {
wrapper {
distributionType = Wrapper.DistributionType.ALL
}
}

View file

@ -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"
}

View file

@ -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
View file

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

View file

@ -1,10 +1,10 @@
package im.molly.monero.demo package im.molly.monero.demo
import android.net.Uri
import im.molly.monero.MoneroNetwork
import im.molly.monero.demo.data.AppDatabase import im.molly.monero.demo.data.AppDatabase
import im.molly.monero.demo.data.entity.asEntity import im.molly.monero.demo.data.entity.asEntity
import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.sdk.MoneroNetwork
import androidx.core.net.toUri
val DefaultNodeList = listOf( val DefaultNodeList = listOf(
MoneroNetwork.Mainnet to listOf( MoneroNetwork.Mainnet to listOf(
@ -21,7 +21,7 @@ suspend fun addDefaultRemoteNodes(appDatabase: AppDatabase) {
val dao = appDatabase.remoteNodeDao() val dao = appDatabase.remoteNodeDao()
val nodes = DefaultNodeList.flatMap { (network, urls) -> val nodes = DefaultNodeList.flatMap { (network, urls) ->
urls.map { url -> urls.map { url ->
RemoteNode(network = network, uri = Uri.parse(url)).asEntity() RemoteNode(network = network, uri = url.toUri()).asEntity()
} }
}.toTypedArray() }.toTypedArray()
dao.upsert(*nodes) dao.upsert(*nodes)

View file

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

View file

@ -7,7 +7,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import im.molly.monero.demo.service.SyncService import im.molly.monero.demo.service.SyncService
import im.molly.monero.isIsolatedProcess import im.molly.monero.sdk.isIsolatedProcess
val Context.preferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") val Context.preferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

View file

@ -2,8 +2,9 @@ package im.molly.monero.demo.data
import android.content.Context import android.content.Context
import android.util.AtomicFile import android.util.AtomicFile
import im.molly.monero.* import im.molly.monero.sdk.*
import im.molly.monero.loadbalancer.RoundRobinRule import im.molly.monero.sdk.loadbalancer.RoundRobinRule
import im.molly.monero.sdk.service.SandboxedWalletService
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -15,7 +16,7 @@ import java.io.OutputStream
class MoneroSdkClient(private val context: Context) { class MoneroSdkClient(private val context: Context) {
private val providerDeferred = CoroutineScope(Dispatchers.IO).async { private val providerDeferred = CoroutineScope(Dispatchers.IO).async {
WalletProvider.connect(context) SandboxedWalletService.connect(context)
} }
suspend fun createWallet(network: MoneroNetwork, filename: String): MoneroWallet { suspend fun createWallet(network: MoneroNetwork, filename: String): MoneroWallet {
@ -24,7 +25,7 @@ class MoneroSdkClient(private val context: Context) {
network = network, network = network,
dataStore = WalletDataStoreFile(filename, newFile = true), dataStore = WalletDataStoreFile(filename, newFile = true),
).also { wallet -> ).also { wallet ->
wallet.commit() wallet.save()
} }
} }
@ -41,7 +42,7 @@ class MoneroSdkClient(private val context: Context) {
secretSpendKey = secretSpendKey, secretSpendKey = secretSpendKey,
restorePoint = restorePoint, restorePoint = restorePoint,
).also { wallet -> ).also { wallet ->
wallet.commit() wallet.save()
} }
} }
@ -52,7 +53,7 @@ class MoneroSdkClient(private val context: Context) {
httpClient: OkHttpClient, httpClient: OkHttpClient,
): MoneroWallet { ): MoneroWallet {
val dataStore = WalletDataStoreFile(filename) val dataStore = WalletDataStoreFile(filename)
val client = RemoteNodeClient.forNetwork( val client = MoneroNodeClient.create(
network = network, network = network,
remoteNodes = remoteNodes, remoteNodes = remoteNodes,
loadBalancerRule = RoundRobinRule(), loadBalancerRule = RoundRobinRule(),
@ -86,7 +87,7 @@ class MoneroSdkClient(private val context: Context) {
throw IOException("Cannot create wallet data directory: ${walletDataDir.path}") throw IOException("Cannot create wallet data directory: ${walletDataDir.path}")
} }
override suspend fun write(writer: (OutputStream) -> Unit) { override suspend fun save(writer: (OutputStream) -> Unit, overwrite: Boolean) {
val output = file.startWrite() val output = file.startWrite()
try { try {
writer(output) writer(output)
@ -97,7 +98,7 @@ class MoneroSdkClient(private val context: Context) {
} }
} }
override suspend fun read(): InputStream { override suspend fun load(): InputStream {
return file.openRead() return file.openRead()
} }
} }

View file

@ -1,7 +1,7 @@
package im.molly.monero.demo.data package im.molly.monero.demo.data
import im.molly.monero.*
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.sdk.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -33,7 +33,8 @@ class WalletRepository(
remoteNodes = configFlow.map { remoteNodes = configFlow.map {
it.remoteNodes.map { node -> it.remoteNodes.map { node ->
RemoteNode( RemoteNode(
uri = node.uri, url = node.uri.toString(),
network = node.network,
username = node.username, username = node.username,
password = node.password, password = node.password,
) )
@ -51,8 +52,8 @@ class WalletRepository(
fun getWalletIdList() = walletDataSource.readWalletIdList() fun getWalletIdList() = walletDataSource.readWalletIdList()
fun getRemoteClients(): Flow<List<RemoteNodeClient>> = fun getMoneroNodeClients(): Flow<List<MoneroNodeClient>> =
getWalletIdList().map { it.mapNotNull { walletId -> getWallet(walletId).remoteNodeClient } } getWalletIdList().map { it.mapNotNull { walletId -> getWallet(walletId).moneroNodeClient } }
fun getWalletConfig(walletId: Long) = walletDataSource.readWalletConfig(walletId) fun getWalletConfig(walletId: Long) = walletDataSource.readWalletConfig(walletId)

View file

@ -5,8 +5,8 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import im.molly.monero.MoneroNetwork
import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.sdk.MoneroNetwork
@Entity( @Entity(
tableName = "remote_nodes", tableName = "remote_nodes",

View file

@ -1,7 +1,7 @@
package im.molly.monero.demo.data.model package im.molly.monero.demo.data.model
import android.net.Uri import android.net.Uri
import im.molly.monero.MoneroNetwork import im.molly.monero.sdk.MoneroNetwork
data class RemoteNode( data class RemoteNode(
val id: Long? = null, val id: Long? = null,

View file

@ -1,8 +1,8 @@
package im.molly.monero.demo.data.model package im.molly.monero.demo.data.model
import im.molly.monero.AccountAddress import im.molly.monero.sdk.AccountAddress
import im.molly.monero.Enote import im.molly.monero.sdk.Enote
import im.molly.monero.TimeLocked import im.molly.monero.sdk.TimeLocked
data class WalletAddress( data class WalletAddress(
val address: AccountAddress, val address: AccountAddress,

View file

@ -1,6 +1,6 @@
package im.molly.monero.demo.data.model package im.molly.monero.demo.data.model
import im.molly.monero.Transaction import im.molly.monero.sdk.Transaction
data class WalletTransaction( data class WalletTransaction(
val walletId: Long, val walletId: Long,

View file

@ -38,7 +38,7 @@ class SyncService(
syncedWalletIds.map { syncedWalletIds.map {
walletRepository.getWallet(it) walletRepository.getWallet(it)
}.forEach { wallet -> }.forEach { wallet ->
wallet.commit() wallet.save()
} }
} }
delay(60.seconds) delay(60.seconds)
@ -56,7 +56,7 @@ class SyncService(
if (result.isError()) { if (result.isError()) {
// TODO: Handle non-recoverable errors // TODO: Handle non-recoverable errors
} }
wallet.commit() wallet.save()
delay(10.seconds) delay(10.seconds)
} }
} }

View file

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

View file

@ -16,13 +16,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.MoneroNetwork
import im.molly.monero.demo.data.model.DefaultMoneroNetwork import im.molly.monero.demo.data.model.DefaultMoneroNetwork
import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.demo.ui.component.SelectListBox import im.molly.monero.demo.ui.component.SelectListBox
import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.sdk.MoneroNetwork
@Composable @Composable
fun AddWalletFirstStepRoute( fun AddWalletFirstStepRoute(

View file

@ -10,10 +10,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.calculateBalance
import im.molly.monero.demo.data.model.WalletAddress import im.molly.monero.demo.data.model.WalletAddress
import im.molly.monero.demo.ui.component.CopyableText import im.molly.monero.demo.ui.component.CopyableText
import im.molly.monero.toFormattedString import im.molly.monero.sdk.calculateBalance
import im.molly.monero.sdk.toFormattedString
@Composable @Composable
fun AddressCardExpanded( fun AddressCardExpanded(

View file

@ -15,10 +15,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.MoneroNetwork
import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.demo.ui.component.SelectListBox import im.molly.monero.demo.ui.component.SelectListBox
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.sdk.MoneroNetwork
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@Composable @Composable

View file

@ -7,8 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.PendingTransfer import im.molly.monero.sdk.PendingTransfer
import im.molly.monero.toFormattedString import im.molly.monero.sdk.toFormattedString
@Composable @Composable
fun PendingTransferView( fun PendingTransferView(

View file

@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroCurrency import im.molly.monero.sdk.MoneroCurrency
@Composable @Composable
fun EditableRecipientList( fun EditableRecipientList(

View file

@ -4,15 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.FeePriority
import im.molly.monero.MoneroCurrency
import im.molly.monero.PaymentDetail
import im.molly.monero.PaymentRequest
import im.molly.monero.PendingTransfer
import im.molly.monero.PublicAddress
import im.molly.monero.TransferRequest
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.sdk.FeePriority
import im.molly.monero.sdk.MoneroCurrency
import im.molly.monero.sdk.PaymentDetail
import im.molly.monero.sdk.PaymentRequest
import im.molly.monero.sdk.PendingTransfer
import im.molly.monero.sdk.PublicAddress
import im.molly.monero.sdk.TransferRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate

View file

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

View file

@ -15,12 +15,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.preview.PreviewParameterData import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.demo.ui.theme.Blue40 import im.molly.monero.demo.ui.theme.Blue40
import im.molly.monero.demo.ui.theme.Red40 import im.molly.monero.demo.ui.theme.Red40
import im.molly.monero.sdk.MoneroCurrency
import im.molly.monero.sdk.Transaction
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter

View file

@ -22,12 +22,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.sdk.MoneroCurrency
import im.molly.monero.sdk.Transaction
@Composable @Composable

View file

@ -4,11 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Transaction
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.sdk.Transaction
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map

View file

@ -19,15 +19,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroAmount import im.molly.monero.sdk.MoneroAmount
import im.molly.monero.Balance import im.molly.monero.sdk.Balance
import im.molly.monero.BlockchainTime import im.molly.monero.sdk.BlockchainTime
import im.molly.monero.MoneroCurrency import im.molly.monero.sdk.Mainnet
import im.molly.monero.MoneroNetwork import im.molly.monero.sdk.MoneroCurrency
import im.molly.monero.TimeLocked import im.molly.monero.sdk.TimeLocked
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.genesisTime import im.molly.monero.sdk.genesisTime
import im.molly.monero.xmr import im.molly.monero.sdk.xmr
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Duration import java.time.Duration
@ -120,13 +120,13 @@ fun WalletBalanceDetailsPreview() {
WalletBalanceView( WalletBalanceView(
balance = Balance( balance = Balance(
pendingAmount = 5.xmr, pendingAmount = 5.xmr,
timeLockedAmounts = listOf( lockableAmounts = listOf(
TimeLocked(10.xmr, null), TimeLocked(10.xmr, null),
TimeLocked(BigDecimal("0.000000000001").xmr, null), TimeLocked(BigDecimal("0.000000000001").xmr, null),
TimeLocked(30.xmr, null) TimeLocked(30.xmr, null)
), ),
), ),
blockchainTime = MoneroNetwork.Mainnet.genesisTime, blockchainTime = Mainnet.genesisTime,
) )
} }
} }

View file

@ -25,17 +25,17 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.FeePriority
import im.molly.monero.Ledger
import im.molly.monero.MoneroCurrency
import im.molly.monero.PendingTransfer
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.component.SelectListBox import im.molly.monero.demo.ui.component.SelectListBox
import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
import im.molly.monero.toFormattedString import im.molly.monero.sdk.FeePriority
import im.molly.monero.sdk.Ledger
import im.molly.monero.sdk.MoneroCurrency
import im.molly.monero.sdk.PendingTransfer
import im.molly.monero.sdk.toFormattedString
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.time.Instant import java.time.Instant
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds

View file

@ -4,9 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Balance
import im.molly.monero.BlockchainTime
import im.molly.monero.MoneroNetwork
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult import im.molly.monero.demo.common.asResult
@ -14,6 +11,9 @@ import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.demo.data.model.WalletAddress import im.molly.monero.demo.data.model.WalletAddress
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.data.model.WalletTransaction import im.molly.monero.demo.data.model.WalletTransaction
import im.molly.monero.sdk.Balance
import im.molly.monero.sdk.BlockchainTime
import im.molly.monero.sdk.MoneroNetwork
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant

View file

@ -1,21 +1,10 @@
package im.molly.monero.demo.ui.preview package im.molly.monero.demo.ui.preview
import im.molly.monero.BlockHeader import im.molly.monero.sdk.*
import im.molly.monero.BlockchainTime
import im.molly.monero.HashDigest
import im.molly.monero.Ledger
import im.molly.monero.MoneroAmount
import im.molly.monero.MoneroNetwork
import im.molly.monero.PaymentDetail
import im.molly.monero.PublicAddress
import im.molly.monero.Transaction
import im.molly.monero.TxState
import im.molly.monero.UnlockTime
import im.molly.monero.xmr
import java.time.Instant import java.time.Instant
object PreviewParameterData { object PreviewParameterData {
val network = MoneroNetwork.Mainnet val network = Mainnet
val blockHeader = BlockHeader(height = 2999840, epochSecond = 1697792826) val blockHeader = BlockHeader(height = 2999840, epochSecond = 1697792826)

View file

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

50
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,50 @@
[versions]
agp = "8.10.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"
publish = "0.32.0"
[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" }
publish = { id = "com.vanniktech.maven.publish", version.ref = "publish" }
# Plugins defined by this project
monero-sdk-module = { id ="im.molly.monero.sdk.module" }

View file

@ -0,0 +1,19 @@
[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" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }

Binary file not shown.

View file

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

37
gradlew vendored
View file

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -80,13 +82,11 @@ do
esac esac
done done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # This is normally unused
# shellcheck disable=SC2034
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@ -133,22 +133,29 @@ location of your Java installation."
fi fi
else else
JAVACMD=java 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 Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) 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 ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
case $MAX_FD in #( case $MAX_FD in #(
'' | soft) :;; #( '' | 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" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -193,11 +200,15 @@ if "$cygwin" || "$msys" ; then
done done
fi fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
# shell script including quotes and variable substitutions, so put them in DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded. # Collect all arguments for the java command:
# * DEFAULT_JVM_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 -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \

23
gradlew.bat vendored
View file

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

View file

@ -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"
}

View file

@ -0,0 +1,178 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.monero.sdk.module)
alias(libs.plugins.publish)
signing
}
version = "1.0.1-SNAPSHOT"
kotlin {
jvmToolchain(17)
}
android {
namespace = "im.molly.monero.sdk"
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") {
isMinifyEnabled = false
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("atdApi35") {
device = "Small Phone"
sdkVersion = 35
systemImageSource = "aosp-atd"
require64Bit = true
}
}
groups {
create("ciGroup") {
targetDevices.add(allDevices["atdApi35"])
}
}
}
animationsDisabled = true
}
}
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.android)
}
mavenPublishing {
coordinates("im.molly", "monero-wallet-sdk", version.toString())
pom {
name = "Monero Wallet SDK for Android"
description =
"Kotlin-based Android library for interacting with the Monero blockchain and managing Monero wallets."
url = "https://github.com/mollyim/monero-wallet-sdk"
licenses {
license {
name = "GNU General Public License v3.0"
url = "https://www.gnu.org/licenses/gpl-3.0.txt"
distribution = "repo"
}
}
issueManagement {
system = "GitHub Issues"
url = "https://github.com/mollyim/monero-wallet-sdk/issues"
}
scm {
connection = "scm:git:git://github.com/mollyim/monero-wallet-sdk.git"
developerConnection = "scm:git:ssh://git@github.com/mollyim/monero-wallet-sdk.git"
url = "https://github.com/mollyim/monero-wallet-sdk"
}
developers {
developer {
name = "Oscar Mira"
url = "https://github.com/valldrac/"
organization = "MollyIM"
organizationUrl = "https://molly.im"
}
}
}
publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL)
}
signing {
useGpgCmd()
if (!isSnapshot) {
sign(publishing.publications)
}
}

View file

@ -22,5 +22,5 @@
# Keeps methods that are invoked by JNI. # Keeps methods that are invoked by JNI.
-keepclassmembers class * { -keepclassmembers class * {
@im.molly.monero.UsedByNative *; @im.molly.monero.sdk.internal.CalledByNative *;
} }

View file

@ -1,9 +0,0 @@
package im.molly.monero
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()
}
}

View file

@ -1,24 +0,0 @@
package im.molly.monero
import android.os.Parcel
import com.google.common.truth.Truth
import org.junit.Test
import kotlin.random.Random
class SecretKeyParcelableTest {
@Test
fun testParcel() {
val secret = Random.nextBytes(32)
val originalKey = SecretKey(secret)
val parcel = Parcel.obtain()
originalKey.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
val key = SecretKey.create(parcel)
Truth.assertThat(key == originalKey).isTrue()
}
}

View file

@ -1,27 +0,0 @@
package im.molly.monero
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
class WalletNativeServiceTest {
@get:Rule
val serviceRule = ServiceTestRule()
private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
// private val resources by lazy { context.resources }
@Test
fun testBind() {
assertThat(bindService()).isNotNull()
}
private fun bindService(): IWalletService {
val binder = serviceRule.bindService(Intent(context, WalletService::class.java))
return IWalletService.Stub.asInterface(binder)
}
}

View file

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

View file

@ -1,45 +0,0 @@
package im.molly.monero.mnemonics
import com.google.common.truth.Truth.assertThat
import im.molly.monero.parseHex
import org.junit.Test
class MoneroMnemonicTest {
data class TestCase(val entropy: String, val words: String, val language: String)
private val testVector = listOf(
TestCase(
entropy = "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 {
validateMnemonicGeneration(it)
validateEntropyRecovery(it)
}
}
private fun validateMnemonicGeneration(testCase: TestCase) {
val mnemonicCode = MoneroMnemonic.generateMnemonic(testCase.entropy.parseHex())
assertMnemonicCode(mnemonicCode, testCase)
}
private fun validateEntropyRecovery(testCase: TestCase) {
val mnemonicCode = MoneroMnemonic.recoverEntropy(testCase.words)
assertThat(mnemonicCode).isNotNull()
assertMnemonicCode(mnemonicCode!!, testCase)
}
private fun assertMnemonicCode(mnemonicCode: MnemonicCode, testCase: TestCase) {
with(mnemonicCode) {
assertThat(entropy).isEqualTo(testCase.entropy.parseHex())
assertThat(String(words)).isEqualTo(testCase.words)
assertThat(locale.language).isEqualTo(testCase.language)
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,29 @@
package im.molly.monero.sdk
import com.google.common.truth.FailureMetadata
import com.google.common.truth.Subject
import com.google.common.truth.Truth.assertAbout
import kotlinx.coroutines.flow.first
class MoneroWalletSubject private constructor(
metadata: FailureMetadata,
private val actual: MoneroWallet,
) : Subject(metadata, actual) {
companion object {
fun assertThat(wallet: MoneroWallet): MoneroWalletSubject {
return assertAbout(factory).that(wallet)
}
private val factory = Factory { metadata, actual: MoneroWallet ->
MoneroWalletSubject(metadata, actual)
}
}
suspend fun matchesStateOf(expected: MoneroWallet) {
with(actual) {
check("publicAddress").that(publicAddress).isEqualTo(expected.publicAddress)
check("ledger").that(ledger().first()).isEqualTo(expected.ledger().first())
}
}
}

View file

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

View file

@ -0,0 +1,116 @@
package im.molly.monero.sdk.e2etest
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertThat
import im.molly.monero.sdk.InMemoryWalletDataStore
import im.molly.monero.sdk.Mainnet
import im.molly.monero.sdk.MoneroWalletSubject
import im.molly.monero.sdk.RestorePoint
import im.molly.monero.sdk.SecretKey
import im.molly.monero.sdk.exceptions.NoSuchAccountException
import im.molly.monero.sdk.service.BaseWalletService
import im.molly.monero.sdk.service.InProcessWalletService
import im.molly.monero.sdk.service.SandboxedWalletService
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
abstract class WalletPersistenceTest(
serviceClass: Class<out BaseWalletService>,
) : WalletTestBase(serviceClass) {
@Test
fun restoredWalletHasExpectedAccountKeys() = runTest {
val sk = "148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e".toSecretKey()
val vk = "49774391fa5e8d249fc2c5b45dadef13534bf2483dede880dac88f061e809100".toSecretKey()
val wallet = walletProvider.restoreWallet(
network = Mainnet,
secretSpendKey = sk,
restorePoint = RestorePoint.creationTime(Instant.now()),
)
wallet.withViewKey { assertThat(it).isEqualTo(vk) }
wallet.withSpendKey { assertThat(it).isEqualTo(sk) }
wallet.withViewAndSpendKeys { viewKey, spendKey ->
assertThat(viewKey).isEqualTo(vk)
assertThat(spendKey).isEqualTo(sk)
}
with(wallet.publicAddress) {
assertThat(address).isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
assertThat(spendPublicKey.toString()).isEqualTo("1b3bd040020d3712ab84992b773d0a965134eb2df0392fb84af95de8a17be2ab")
assertThat(viewPublicKey.toString()).isEqualTo("231c9bf8341c6a870d92e3fb98063a90a355fb8dbf74a8561b9d7f9273247e99")
}
}
@OptIn(ExperimentalStdlibApi::class)
fun String.toSecretKey() = SecretKey(this.hexToByteArray())
@Test
fun saveToMultipleStores() = runTest {
val defaultStore = InMemoryWalletDataStore()
val wallet = wallet(defaultStore)
wallet.save()
val newStore = InMemoryWalletDataStore()
wallet.save(newStore)
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
}
@Test
fun getAllAccountsReturnsAll() = runTest {
val wallet = wallet().apply {
repeat(2) { createAccount() }
createSubAddressForAccount(1)
}
val allAccounts = wallet.getAllAccounts()
assertThat(allAccounts).hasSize(3)
assertThat(allAccounts).containsExactlyElementsIn(
(0..2).map { wallet.getAccount(it) }
)
val addresses = allAccounts.map { it.addresses }
assertThat(addresses[0]).hasSize(1)
assertThat(addresses[1]).hasSize(2)
assertThat(addresses[2]).hasSize(1)
allAccounts.forEach { acc ->
val primary = acc.addresses[0]
assertThat(primary.isPrimaryAddress).isTrue()
assertThat(primary.subAddressIndex).isEqualTo(0)
if (acc.accountIndex == 1) {
val sub = acc.addresses[1]
assertThat(sub.isPrimaryAddress).isFalse()
assertThat(sub.subAddressIndex).isEqualTo(1)
}
}
withReopenedWallet(wallet) { original, reopened ->
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
}
}
@Test(expected = NoSuchAccountException::class)
fun getAccountThrowsForMissingAccount() = runTest {
wallet().getAccount(accountIndex = 42)
}
@Test(expected = NoSuchAccountException::class)
fun createSubAddressForAccountThrowsForMissingAccount() = runTest {
wallet().createSubAddressForAccount(accountIndex = 42)
}
@Test(expected = NoSuchAccountException::class)
fun findUnusedSubAddressThrowsForMissingAccount() = runTest {
wallet().findUnusedSubAddress(accountIndex = 42)
}
}
@LargeTest
class WalletPersistenceInProcessTest : WalletPersistenceTest(InProcessWalletService::class.java)
@LargeTest
class WalletPersistenceSandboxedTest : WalletPersistenceTest(SandboxedWalletService::class.java)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,116 @@
package im.molly.monero.sdk.internal
import android.os.ParcelFileDescriptor
import com.google.common.truth.Truth.assertThat
import im.molly.monero.sdk.WalletDataStore
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
class DataStoreAdapterTest {
@get:Rule
val mockkRule = MockKRule(this)
private val dataStore = mockk<WalletDataStore>(relaxed = true)
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
private val testIOException = IOException("Test IO Exception")
private lateinit var adapter: DataStoreAdapter
@Before
fun setUp() {
adapter = DataStoreAdapter(dataStore, ioDispatcher = testDispatcher)
}
@Test
fun overwriteIsPassedToDataStore() = runTest(testDispatcher) {
coEvery { dataStore.save(any(), any()) } returns Unit
listOf(true, false).forEach {
adapter.saveWithFd(overwrite = it) {}
coVerify(exactly = 1) { dataStore.save(any(), overwrite = it) }
}
}
@Test
fun propagatesIOExceptionWhenLoadFails() = runTest(testDispatcher) {
coEvery { dataStore.load() } throws testIOException
val exception = runCatching {
adapter.loadWithFd {}
}.exceptionOrNull()
assertThat(exception).isEqualTo(testIOException)
}
@Test
fun propagatesIOExceptionWhenSaveFails() = runTest(testDispatcher) {
coEvery { dataStore.save(any(), any()) } throws testIOException
val exception = runCatching {
adapter.saveWithFd(overwrite = true) {}
}.exceptionOrNull()
assertThat(exception).isEqualTo(testIOException)
}
@Test
fun pipeIsAlwaysClosedAfterLoad() = runTest(testDispatcher) {
val (readFd, writeFd) = mockPipe()
coEvery { dataStore.load() } returns ByteArrayInputStream(byteArrayOf(1, 2, 3))
adapter.loadWithFd({})
coVerify { readFd.close() }
coVerify { writeFd.close() }
clearAllMocks(answers = false)
runCatching {
adapter.loadWithFd({ throw RuntimeException() })
}
coVerify { readFd.close() }
coVerify { writeFd.close() }
}
@Test
fun pipeIsAlwaysClosedAfterSave() = runTest(testDispatcher) {
val (readFd, writeFd) = mockPipe()
coEvery { dataStore.save(any(), any()) } returns Unit
adapter.saveWithFd(overwrite = true, {})
coVerify { readFd.close() }
coVerify { writeFd.close() }
clearAllMocks(answers = false)
runCatching {
adapter.saveWithFd(overwrite = true, { throw RuntimeException() })
}
coVerify { readFd.close() }
coVerify { writeFd.close() }
}
private fun mockPipe(): Pair<ParcelFileDescriptor, ParcelFileDescriptor> {
val readFd = mockk<ParcelFileDescriptor>(relaxed = true)
val writeFd = mockk<ParcelFileDescriptor>(relaxed = true)
mockkStatic(ParcelFileDescriptor::class)
coEvery { ParcelFileDescriptor.createPipe() } returns arrayOf(readFd, writeFd)
return readFd to writeFd
}
}

View file

@ -0,0 +1,71 @@
package im.molly.monero.sdk.internal
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertThat
import im.molly.monero.sdk.Mainnet
import im.molly.monero.sdk.SecretKey
import im.molly.monero.sdk.Stagenet
import im.molly.monero.sdk.Testnet
import im.molly.monero.sdk.randomSecretKey
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalStdlibApi::class)
class NativeWalletTest {
@LargeTest
@Test
fun keyGenerationIsDeterministic() = runTest {
assertThat(
NativeWallet.localSyncWallet(
networkId = Mainnet.id,
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".hexToByteArray()),
).publicAddress
).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T")
assertThat(
NativeWallet.localSyncWallet(
networkId = Testnet.id,
secretSpendKey = SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".hexToByteArray()),
).publicAddress
).isEqualTo("A1v6gVUcGgGE87c1uFRWB1KfPVik2qLLDJiZT3rhZ8qjF3BGA6oHzeDboD23dH8rFaFFcysyqwF6DBj8WUTBWwEhESB7nZz")
assertThat(
NativeWallet.localSyncWallet(
networkId = Stagenet.id,
secretSpendKey = SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".hexToByteArray()),
).publicAddress
).isEqualTo("54kPaUhYgGNBT72N8Bv2DFMqstLGJCEcWg1EAjwpxABkKL3uBtBLAh4VAPKvhWBdaD4ZpiftA8YWFLAxnWL4aQ9TD4vhY4W")
}
@LargeTest
@Test
fun publicAddressesAreDistinct() = runTest {
val publicAddress =
NativeWallet.localSyncWallet(
networkId = Mainnet.id,
secretSpendKey = randomSecretKey(),
).publicAddress
val anotherPublicAddress =
NativeWallet.localSyncWallet(
networkId = Mainnet.id,
secretSpendKey = randomSecretKey(),
).publicAddress
assertThat(publicAddress).isNotEqualTo(anotherPublicAddress)
}
@Test
fun balanceIsZeroAtGenesis() = runTest {
with(
NativeWallet.localSyncWallet(
networkId = Mainnet.id,
secretSpendKey = randomSecretKey(),
).getLedger()
) {
assertThat(transactions).isEmpty()
assertThat(isBalanceZero).isTrue()
}
}
}

View file

@ -0,0 +1,69 @@
package im.molly.monero.sdk.mnemonics
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import java.util.Locale
class MoneroMnemonicTest {
@OptIn(ExperimentalStdlibApi::class)
data class TestCase(val key: String, val words: String, val language: String) {
val entropy = key.hexToByteArray()
}
private val testCases = listOf(
TestCase(
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 knownMnemonics() {
testCases.forEach {
validateMnemonicGeneration(it)
validateEntropyRecovery(it)
}
}
@Test(expected = IllegalArgumentException::class)
fun emptyEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(0))
}
@Test(expected = IllegalArgumentException::class)
fun invalidEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(2))
}
@Test(expected = IllegalArgumentException::class)
fun emptyWords() {
MoneroMnemonic.recoverEntropy("")
}
@Test(expected = IllegalArgumentException::class)
fun invalidLanguage() {
MoneroMnemonic.generateMnemonic(ByteArray(32), Locale("ZZ"))
}
private fun validateMnemonicGeneration(testCase: TestCase) {
val mnemonicCode =
MoneroMnemonic.generateMnemonic(testCase.entropy, Locale(testCase.language))
assertMnemonicCode(mnemonicCode, testCase)
}
private fun validateEntropyRecovery(testCase: TestCase) {
val mnemonicCode = MoneroMnemonic.recoverEntropy(testCase.words)
assertMnemonicCode(mnemonicCode, testCase)
}
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)
}
}
}

View file

@ -0,0 +1,26 @@
package im.molly.monero.sdk.service
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class WalletServiceSandboxingTest {
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
@Test
fun inProcessWalletServiceIsNotIsolated() = runTest {
InProcessWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceSandboxed()).isFalse()
}
}
@Test
fun sandboxedWalletServiceIsIsolated() = runTest {
SandboxedWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceSandboxed()).isTrue()
}
}
}

View file

@ -4,11 +4,17 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application android:usesCleartextTraffic="true"> <application
android:largeHeap="true"
android:usesCleartextTraffic="true">
<service android:name=".service.InProcessWalletService" />
<service <service
android:name=".WalletService" android:name=".service.SandboxedWalletService"
android:isolatedProcess="true" android:isolatedProcess="true"
android:process=":wallet_service" /> android:process=":wallet_service" />
</application> </application>
</manifest> </manifest>

View file

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

View file

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

View file

@ -1,10 +0,0 @@
package im.molly.monero;
import im.molly.monero.BlockchainTime;
import im.molly.monero.internal.TxInfo;
oneway interface IBalanceListener {
void onBalanceChanged(in List<TxInfo> txHistory, in String[] subAddresses, in BlockchainTime blockchainTime);
void onRefresh(in BlockchainTime blockchainTime);
void onSubAddressListUpdated(in String[] subAddresses);
}

View file

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

View file

@ -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);
}

View file

@ -1,6 +0,0 @@
package im.molly.monero;
interface IStorageAdapter {
boolean writeAsync(in ParcelFileDescriptor pfd);
oneway void readAsync(in ParcelFileDescriptor pfd);
}

View file

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

View file

@ -1,15 +0,0 @@
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;
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);
void setListener(in IWalletServiceListener listener);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
package im.molly.monero.sdk.internal;
import im.molly.monero.sdk.BlockchainTime;
import im.molly.monero.sdk.internal.TxInfo;
oneway interface IBalanceListener {
void onBalanceUpdateFinalized(in List<TxInfo> txBatch, in String[] allSubAddresses, in BlockchainTime blockchainTime);
void onBalanceUpdateChunk(in List<TxInfo> txBatch);
void onWalletRefreshed(in BlockchainTime blockchainTime);
void onSubAddressListUpdated(in String[] allSubAddresses);
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
import im.molly.monero.ITransferCallback; import im.molly.monero.sdk.internal.ITransferCallback;
interface IPendingTransfer { interface IPendingTransfer {
long getAmount(); long getAmount();

View file

@ -1,6 +1,6 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
import im.molly.monero.IPendingTransfer; import im.molly.monero.sdk.internal.IPendingTransfer;
oneway interface ITransferCallback { oneway interface ITransferCallback {
void onTransferCreated(in IPendingTransfer pendingTransfer); void onTransferCreated(in IPendingTransfer pendingTransfer);

View file

@ -1,13 +1,16 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
import im.molly.monero.IBalanceListener; import im.molly.monero.sdk.PaymentRequest;
import im.molly.monero.ITransferCallback; import im.molly.monero.sdk.SecretKey;
import im.molly.monero.IWalletCallbacks; import im.molly.monero.sdk.SweepRequest;
import im.molly.monero.PaymentRequest; import im.molly.monero.sdk.internal.IBalanceListener;
import im.molly.monero.SweepRequest; import im.molly.monero.sdk.internal.ITransferCallback;
import im.molly.monero.sdk.internal.IWalletCallbacks;
interface IWallet { interface IWallet {
String getPublicAddress(); String getPublicAddress();
SecretKey getSpendSecretKey();
SecretKey getViewSecretKey();
void addBalanceListener(in IBalanceListener listener); void addBalanceListener(in IBalanceListener listener);
void removeBalanceListener(in IBalanceListener listener); void removeBalanceListener(in IBalanceListener listener);
oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback);
@ -18,7 +21,7 @@ interface IWallet {
oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback); oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback);
oneway void cancelRefresh(); oneway void cancelRefresh();
oneway void setRefreshSince(long heightOrTimestamp); oneway void setRefreshSince(long heightOrTimestamp);
oneway void commit(in IWalletCallbacks callback); oneway void commit(in ParcelFileDescriptor outputFd, in IWalletCallbacks callback);
oneway void createPayment(in PaymentRequest request, in ITransferCallback callback); oneway void createPayment(in PaymentRequest request, in ITransferCallback callback);
oneway void createSweep(in SweepRequest request, in ITransferCallback callback); oneway void createSweep(in SweepRequest request, in ITransferCallback callback);
oneway void requestFees(in IWalletCallbacks callback); oneway void requestFees(in IWalletCallbacks callback);

View file

@ -1,11 +1,12 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
import im.molly.monero.BlockchainTime; import im.molly.monero.sdk.BlockchainTime;
oneway interface IWalletCallbacks { oneway interface IWalletCallbacks {
void onRefreshResult(in BlockchainTime blockchainTime, int status); void onRefreshResult(in BlockchainTime blockchainTime, int status);
void onCommitResult(boolean success); void onCommitResult(boolean success);
void onSubAddressReady(String subAddress); void onSubAddressReady(String subAddress);
void onSubAddressListReceived(in String[] subAddresses); void onSubAddressListReceived(in String[] subAddresses);
void onAccountNotFound(int accountIndex);
void onFeesReceived(in long[] fees); void onFeesReceived(in long[] fees);
} }

View file

@ -0,0 +1,15 @@
package im.molly.monero.sdk.internal;
import im.molly.monero.sdk.SecretKey;
import im.molly.monero.sdk.internal.IHttpRpcClient;
import im.molly.monero.sdk.internal.IWalletServiceCallbacks;
import im.molly.monero.sdk.internal.IWalletServiceListener;
import im.molly.monero.sdk.internal.WalletConfig;
interface IWalletService {
oneway void createWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback);
oneway void restoreWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long restorePoint);
oneway void openWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in ParcelFileDescriptor inputFd);
void setListener(in IWalletServiceListener listener);
boolean isServiceIsolated();
}

View file

@ -1,6 +1,6 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
import im.molly.monero.IWallet; import im.molly.monero.sdk.internal.IWallet;
oneway interface IWalletServiceCallbacks { oneway interface IWalletServiceCallbacks {
void onWalletResult(in IWallet wallet); void onWalletResult(in IWallet wallet);

View file

@ -1,4 +1,4 @@
package im.molly.monero; package im.molly.monero.sdk.internal;
oneway interface IWalletServiceListener { oneway interface IWalletServiceListener {
void onLogMessage(int priority, String tag, String msg, String cause); void onLogMessage(int priority, String tag, String msg, String cause);

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more