diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..b939051
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,27 @@
+version: 2
+jobs:
+ build:
+ working_directory: ~/code
+ docker:
+ - image: cimg/android:2022.03-ndk
+ environment:
+ JVM_OPTS: -Xmx3200m
+ steps:
+ - checkout
+ - restore_cache:
+ key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }}
+ - run:
+ name: Download Dependencies
+ command: ./gradlew androidDependencies
+ - save_cache:
+ paths:
+ - ~/.gradle
+ key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }}
+ - run:
+ name: Run Tests
+ command: ./gradlew test
+ - store_artifacts:
+ path: app/build/reports
+ destination: reports
+ - store_test_results:
+ path: app/build/test-results
diff --git a/.gitignore b/.gitignore
index a1c2a23..679ff4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,18 @@
-# Compiled class file
-*.class
-
-# Log file
-*.log
-
-# BlueJ files
-*.ctxt
-
-# Mobile Tools for Java (J2ME)
-.mtj.tmp/
-
-# Package Files #
-*.jar
-*.war
-*.nar
-*.ear
-*.zip
-*.tar.gz
-*.rar
-
-# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
-hs_err_pid*
+.gradle
+/build
+*.iml
+/.idea
+/local.properties
+/captures
+.externalNativeBuild
+.DS_Store
+/app/build
+/app/release
+/app/alpha
+/app/prod
+/app/alphaMainnet
+/app/prodMainnet
+/app/alphaStagenet
+/app/prodStagenet
+/app/.cxx
+/monerujo.id
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
new file mode 100644
index 0000000..08904bf
--- /dev/null
+++ b/app/CMakeLists.txt
@@ -0,0 +1,239 @@
+cmake_minimum_required(VERSION 3.4.1)
+message(STATUS ABI_INFO = ${ANDROID_ABI})
+
+add_library( monerujo
+ SHARED
+ src/main/cpp/monerujo.cpp )
+
+set(EXTERNAL_LIBS_DIR ${CMAKE_SOURCE_DIR}/../external-libs)
+
+############
+# libsodium
+############
+
+add_library(sodium STATIC IMPORTED)
+set_target_properties(sodium PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libsodium.a)
+
+############
+# OpenSSL
+############
+
+add_library(crypto STATIC IMPORTED)
+set_target_properties(crypto PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libcrypto.a)
+
+add_library(ssl STATIC IMPORTED)
+set_target_properties(ssl PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libssl.a)
+
+############
+# Boost
+############
+
+add_library(boost_chrono STATIC IMPORTED)
+set_target_properties(boost_chrono PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_chrono.a)
+
+add_library(boost_date_time STATIC IMPORTED)
+set_target_properties(boost_date_time PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_date_time.a)
+
+add_library(boost_filesystem STATIC IMPORTED)
+set_target_properties(boost_filesystem PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_filesystem.a)
+
+add_library(boost_program_options STATIC IMPORTED)
+set_target_properties(boost_program_options PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_program_options.a)
+
+add_library(boost_regex STATIC IMPORTED)
+set_target_properties(boost_regex PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_regex.a)
+
+add_library(boost_serialization STATIC IMPORTED)
+set_target_properties(boost_serialization PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_serialization.a)
+
+add_library(boost_system STATIC IMPORTED)
+set_target_properties(boost_system PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_system.a)
+
+add_library(boost_thread STATIC IMPORTED)
+set_target_properties(boost_thread PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_thread.a)
+
+add_library(boost_wserialization STATIC IMPORTED)
+set_target_properties(boost_wserialization PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libboost_wserialization.a)
+
+#############
+# Monero
+#############
+
+add_library(wallet_api STATIC IMPORTED)
+set_target_properties(wallet_api PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libwallet_api.a)
+
+add_library(wallet STATIC IMPORTED)
+set_target_properties(wallet PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libwallet.a)
+
+add_library(cryptonote_core STATIC IMPORTED)
+set_target_properties(cryptonote_core PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcryptonote_core.a)
+
+add_library(cryptonote_basic STATIC IMPORTED)
+set_target_properties(cryptonote_basic PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcryptonote_basic.a)
+
+add_library(mnemonics STATIC IMPORTED)
+set_target_properties(mnemonics PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libmnemonics.a)
+
+add_library(common STATIC IMPORTED)
+set_target_properties(common PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcommon.a)
+
+add_library(cncrypto STATIC IMPORTED)
+set_target_properties(cncrypto PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcncrypto.a)
+
+add_library(ringct STATIC IMPORTED)
+set_target_properties(ringct PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libringct.a)
+
+add_library(ringct_basic STATIC IMPORTED)
+set_target_properties(ringct_basic PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libringct_basic.a)
+
+add_library(blockchain_db STATIC IMPORTED)
+set_target_properties(blockchain_db PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libblockchain_db.a)
+
+add_library(lmdb STATIC IMPORTED)
+set_target_properties(lmdb PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/liblmdb.a)
+
+add_library(easylogging STATIC IMPORTED)
+set_target_properties(easylogging PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libeasylogging.a)
+
+add_library(unbound STATIC IMPORTED)
+set_target_properties(unbound PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/libunbound.a)
+
+add_library(epee STATIC IMPORTED)
+set_target_properties(epee PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libepee.a)
+
+add_library(blocks STATIC IMPORTED)
+set_target_properties(blocks PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libblocks.a)
+
+add_library(checkpoints STATIC IMPORTED)
+set_target_properties(checkpoints PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcheckpoints.a)
+
+add_library(device STATIC IMPORTED)
+set_target_properties(device PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libdevice.a)
+
+add_library(device_trezor STATIC IMPORTED)
+set_target_properties(device_trezor PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libdevice_trezor.a)
+
+add_library(multisig STATIC IMPORTED)
+set_target_properties(multisig PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libmultisig.a)
+
+add_library(version STATIC IMPORTED)
+set_target_properties(version PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libversion.a)
+
+add_library(net STATIC IMPORTED)
+set_target_properties(net PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libnet.a)
+
+add_library(hardforks STATIC IMPORTED)
+set_target_properties(hardforks PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libhardforks.a)
+
+add_library(randomx STATIC IMPORTED)
+set_target_properties(randomx PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/librandomx.a)
+
+add_library(rpc_base STATIC IMPORTED)
+set_target_properties(rpc_base PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/librpc_base.a)
+
+add_library(wallet-crypto STATIC IMPORTED)
+set_target_properties(wallet-crypto PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libwallet-crypto.a)
+
+add_library(cryptonote_format_utils_basic STATIC IMPORTED)
+set_target_properties(cryptonote_format_utils_basic PROPERTIES IMPORTED_LOCATION
+ ${EXTERNAL_LIBS_DIR}/${ANDROID_ABI}/monero/libcryptonote_format_utils_basic.a)
+
+#############
+# System
+#############
+
+find_library( log-lib log )
+
+include_directories( ${EXTERNAL_LIBS_DIR}/include )
+
+message(STATUS EXTERNAL_LIBS_DIR : ${EXTERNAL_LIBS_DIR})
+
+if(${ANDROID_ABI} STREQUAL "x86_64")
+ set(EXTRA_LIBS "wallet-crypto")
+else()
+ set(EXTRA_LIBS "")
+endif()
+
+target_link_libraries( monerujo
+
+ wallet_api
+ wallet
+ cryptonote_core
+ cryptonote_basic
+ cryptonote_format_utils_basic
+ mnemonics
+ ringct
+ ringct_basic
+ net
+ common
+ cncrypto
+ blockchain_db
+ lmdb
+ easylogging
+ unbound
+ epee
+ blocks
+ checkpoints
+ device
+ device_trezor
+ multisig
+ version
+ randomx
+ hardforks
+ rpc_base
+ ${EXTRA_LIBS}
+
+ boost_chrono
+ boost_date_time
+ boost_filesystem
+ boost_program_options
+ boost_regex
+ boost_serialization
+ boost_system
+ boost_thread
+ boost_wserialization
+
+ ssl
+ crypto
+
+ sodium
+
+ ${log-lib}
+ )
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..40e250f
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,159 @@
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion 31
+ buildToolsVersion '30.0.3'
+ ndkVersion '17.2.4988734'
+ defaultConfig {
+ applicationId "com.m2049r.xmrwallet"
+ minSdkVersion 21
+ targetSdkVersion 31
+ versionCode 3002
+ versionName "3.0.2 'Fluorine Fermi'"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ externalNativeBuild {
+ cmake {
+ cppFlags "-std=c++11"
+ arguments '-DANDROID_STL=c++_shared'
+ }
+ }
+ }
+ bundle {
+ language {
+ enableSplit = false
+ }
+ }
+
+ flavorDimensions 'type', 'net'
+ productFlavors {
+ mainnet {
+ dimension 'net'
+ }
+ stagenet {
+ dimension 'net'
+ applicationIdSuffix '.stage'
+ versionNameSuffix ' (stage)'
+ }
+ devnet {
+ dimension 'net'
+ applicationIdSuffix '.test'
+ versionNameSuffix ' (test)'
+ }
+ alpha {
+ dimension 'type'
+ applicationIdSuffix '.alpha'
+ versionNameSuffix ' (alpha)'
+ }
+ prod {
+ dimension 'type'
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ debug {
+ applicationIdSuffix ".debug"
+ }
+ applicationVariants.all { variant ->
+ variant.buildConfigField "String", "ID_A", "\"" + getId("ID_A") + "\""
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path "CMakeLists.txt"
+ }
+ }
+
+ splits {
+ abi {
+ enable true
+ reset()
+ include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
+ universalApk true
+ }
+ }
+
+ // Map for the version code that gives each ABI a value.
+ def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
+
+ // Enumerate translated locales
+ def availableLocales = ["en"]
+ new File("app/src/main/res/").eachFileMatch(~/^values-.*/) { file ->
+ def languageTag = file.name.substring(7).replace("-r", "-")
+ if (languageTag != "night")
+ availableLocales.add(languageTag)
+ }
+
+ // APKs for the same app that all have the same version information.
+ android.applicationVariants.all { variant ->
+ // Update string resource: available_locales
+ variant.resValue("string", "available_locales", availableLocales.join(","))
+ // Assigns a different version code for each output APK.
+ variant.outputs.all {
+ output ->
+ def abiName = output.getFilter(com.android.build.OutputFile.ABI)
+ output.versionCodeOverride = abiCodes.get(abiName, 0) + 10 * versionCode
+
+ if (abiName == null) abiName = "universal"
+ def v = "${variant.versionName}".replaceFirst(" '.*' ?", "")
+ .replace(".", "x")
+ .replace("(", "-")
+ .replace(")", "")
+ outputFileName = "$rootProject.ext.apkName-" + v + "_" + abiName + ".apk"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ namespace 'com.m2049r.xmrwallet'
+}
+
+static def getId(name) {
+ Properties props = new Properties()
+ props.load(new FileInputStream(new File('monerujo.id')))
+ return props[name]
+}
+
+dependencies {
+ implementation 'androidx.core:core:1.7.0'
+ implementation 'androidx.appcompat:appcompat:1.4.1'
+ implementation 'androidx.legacy:legacy-support-v4:1.0.0'
+ implementation 'androidx.recyclerview:recyclerview:1.2.1'
+ implementation 'androidx.cardview:cardview:1.0.0'
+ implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+ implementation 'androidx.preference:preference:1.2.0'
+
+ implementation 'com.google.android.material:material:1.6.0'
+
+ implementation 'me.dm7.barcodescanner:zxing:1.9.8'
+ implementation "com.squareup.okhttp3:okhttp:4.9.3"
+ implementation "io.github.rburgst:okhttp-digest:2.6"
+ implementation "com.jakewharton.timber:timber:5.0.1"
+
+ implementation 'info.guardianproject.netcipher:netcipher:2.1.0'
+ //implementation 'info.guardianproject.netcipher:netcipher-okhttp3:2.1.0'
+ implementation fileTree(dir: 'libs/classes', include: ['*.jar'])
+ implementation 'com.nulab-inc:zxcvbn:1.5.2'
+
+ implementation 'dnsjava:dnsjava:2.1.9'
+ implementation 'org.jitsi:dnssecjava:1.2.0'
+ implementation 'org.slf4j:slf4j-nop:1.7.36'
+ implementation 'com.github.brnunes:swipeablerecyclerview:1.0.2'
+
+ //noinspection GradleDependency
+ testImplementation "junit:junit:4.13.2"
+ testImplementation "org.mockito:mockito-all:1.10.19"
+ testImplementation "com.squareup.okhttp3:mockwebserver:4.9.3"
+ testImplementation 'org.json:json:20211205'
+ testImplementation 'net.jodah:concurrentunit:0.4.6'
+
+ compileOnly 'org.projectlombok:lombok:1.18.22'
+ annotationProcessor 'org.projectlombok:lombok:1.18.22'
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..cb4574a
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,25 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in C:\Users\Test\AppData\Local\Android\Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/app/src/alpha/ic_launcher-web.png b/app/src/alpha/ic_launcher-web.png
new file mode 100644
index 0000000..f34cb0a
Binary files /dev/null and b/app/src/alpha/ic_launcher-web.png differ
diff --git a/app/src/alpha/res/mipmap-hdpi/ic_launcher.png b/app/src/alpha/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..260905e
Binary files /dev/null and b/app/src/alpha/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/alpha/res/mipmap-mdpi/ic_launcher.png b/app/src/alpha/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..1de6820
Binary files /dev/null and b/app/src/alpha/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..ce00826
Binary files /dev/null and b/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..aaa34fd
Binary files /dev/null and b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..7f7e707
Binary files /dev/null and b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png
new file mode 100755
index 0000000..d08ba7f
Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png
new file mode 100755
index 0000000..c45b492
Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
new file mode 100755
index 0000000..44f4c13
Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100755
index 0000000..257fc8e
Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100755
index 0000000..27e58bb
Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4e9ab3e
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/licenses.html b/app/src/main/assets/licenses.html
new file mode 100644
index 0000000..ff886b6
--- /dev/null
+++ b/app/src/main/assets/licenses.html
@@ -0,0 +1,829 @@
+
Open Source Licenses
+Licensed under the Apache License, Version 2.0
+monerujo (https://github.com/m2049r/xmrwallet)
+Copyright (c) 2017-2018 m2049r et al.
+
+The Android Open Source Project
+
+ com.android.support:design
+ com.android.support:support-v4
+ com.android.support:appcompat-v7
+ com.android.support:recyclerview-v7
+ com.android.support:cardview-v7
+ com.android.support.constraint:constraint-layout
+ com.android.support:support-annotations
+ com.android.support:support-vector-drawable
+ com.android.support:animated-vector-drawable
+ com.android.support:transition
+ com.android.support:support-compat
+ com.android.support:support-media-compat
+ com.android.support:support-core-utils
+ com.android.support:support-core-ui
+ com.android.support:support-fragment
+ com.android.support.constraint:constraint-layout-solver
+
+Copyright (c) The Android Open Source Project
+
+OkHttp
+Copyright (c) 2014 Square, Inc.
+
+Timber
+Copyright (c) 2013 Jake Wharton
+
+com.google.zxing:core
+Copyright (c) 2012 ZXing authors
+
+me.dm7.barcodescanner
+
+ me.dm7.barcodescanner:core
+ me.dm7.barcodescanner:zxing
+
+Copyright (c) 2014 Dushyanth Maguluru
+
+AndroidLicensesPage (https://github.com/adamsp/AndroidLicensesPage)
+Copyright (c) 2013 Adam Speakman
+
+SwipeableRecyclerView (https://github.com/brnunes/SwipeableRecyclerView)
+Copyright (c) 2015 Bruno R. Nunes
+
+Apache License, Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction,
+and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by
+the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all
+other entities that control, are controlled by, or are under common
+control with that entity. For the purposes of this definition,
+"control" means (i) the power, direct or indirect, to cause the
+direction or management of such entity, whether by contract or
+otherwise, or (ii) ownership of fifty percent (50%) or more of the
+outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity
+exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications,
+including but not limited to software source code, documentation
+source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical
+transformation or translation of a Source form, including but
+not limited to compiled object code, generated documentation,
+and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or
+Object form, made available under the License, as indicated by a
+copyright notice that is included in or attached to the work
+(an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object
+form, that is based on (or derived from) the Work and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship. For the purposes
+of this License, Derivative Works shall not include works that remain
+separable from, or merely link (or bind by name) to the interfaces of,
+the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including
+the original version of the Work and any modifications or additions
+to that Work or Derivative Works thereof, that is intentionally
+submitted to Licensor for inclusion in the Work by the copyright owner
+or by an individual or Legal Entity authorized to submit on behalf of
+the copyright owner. For the purposes of this definition, "submitted"
+means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems,
+and issue tracking systems that are managed by, or on behalf of, the
+Licensor for the purpose of discussing and improving the Work, but
+excluding communication that is conspicuously marked or otherwise
+designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity
+on behalf of whom a Contribution has been received by Licensor and
+subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+this License, each Contributor hereby grants to You a perpetual,
+worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the
+Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+this License, each Contributor hereby grants to You a perpetual,
+worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+(except as stated in this section) patent license to make, have made,
+use, offer to sell, sell, import, and otherwise transfer the Work,
+where such license applies only to those patent claims licensable
+by such Contributor that are necessarily infringed by their
+Contribution(s) alone or by combination of their Contribution(s)
+with the Work to which such Contribution(s) was submitted. If You
+institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work
+or a Contribution incorporated within the Work constitutes direct
+or contributory patent infringement, then any patent licenses
+granted to You under this License for that Work shall terminate
+as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+Work or Derivative Works thereof in any medium, with or without
+modifications, and in Source or Object form, provided that You
+meet the following conditions:
+
+(a) You must give any other recipients of the Work or
+Derivative Works a copy of this License; and
+
+(b) You must cause any modified files to carry prominent notices
+stating that You changed the files; and
+
+(c) You must retain, in the Source form of any Derivative Works
+that You distribute, all copyright, patent, trademark, and
+attribution notices from the Source form of the Work,
+excluding those notices that do not pertain to any part of
+the Derivative Works; and
+
+(d) If the Work includes a "NOTICE" text file as part of its
+distribution, then any Derivative Works that You distribute must
+include a readable copy of the attribution notices contained
+within such NOTICE file, excluding those notices that do not
+pertain to any part of the Derivative Works, in at least one
+of the following places: within a NOTICE text file distributed
+as part of the Derivative Works; within the Source form or
+documentation, if provided along with the Derivative Works; or,
+within a display generated by the Derivative Works, if and
+wherever such third-party notices normally appear. The contents
+of the NOTICE file are for informational purposes only and
+do not modify the License. You may add Your own attribution
+notices within Derivative Works that You distribute, alongside
+or as an addendum to the NOTICE text from the Work, provided
+that such additional attribution notices cannot be construed
+as modifying the License.
+
+You may add Your own copyright statement to Your modifications and
+may provide additional or different license terms and conditions
+for use, reproduction, or distribution of Your modifications, or
+for any such Derivative Works as a whole, provided Your use,
+reproduction, and distribution of the Work otherwise complies with
+the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+any Contribution intentionally submitted for inclusion in the Work
+by You to the Licensor shall be under the terms and conditions of
+this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify
+the terms of any separate license agreement you may have executed
+with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+names, trademarks, service marks, or product names of the Licensor,
+except as required for reasonable and customary use in describing the
+origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+agreed to in writing, Licensor provides the Work (and each
+Contributor provides its Contributions) on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+implied, including, without limitation, any warranties or conditions
+of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+PARTICULAR PURPOSE. You are solely responsible for determining the
+appropriateness of using or redistributing the Work and assume any
+risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+whether in tort (including negligence), contract, or otherwise,
+unless required by applicable law (such as deliberate and grossly
+negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special,
+incidental, or consequential damages of any character arising as a
+result of this License or out of the use or inability to use the
+Work (including but not limited to damages for loss of goodwill,
+work stoppage, computer failure or malfunction, or any and all
+other commercial damages or losses), even if such Contributor
+has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+the Work or Derivative Works thereof, You may choose to offer,
+and charge a fee for, acceptance of support, warranty, indemnity,
+or other liability obligations and/or rights consistent with this
+License. However, in accepting such obligations, You may act only
+on Your own behalf and on Your sole responsibility, not on behalf
+of any other Contributor, and only if You agree to indemnify,
+defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason
+of your accepting any such warranty or additional liability.
+
+dnsjava (http://dnsjava.org/)
+Copyright (c) 1998-2011, Brian Wellington. All rights reserved.
+The 2-Clause BSD License
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+dnssecjava - a DNSSEC validating stub resolver for Java
+Copyright (c) 2013-2015 Ingo Bauersachs
+The Eclipse Public License - v 1.0
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC
+LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM
+CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+a) in the case of the initial Contributor, the initial code and documentation
+distributed under this Agreement, and
+
+b) in the case of each subsequent Contributor:
+
+i) changes to the Program, and
+
+ii) additions to the Program;
+
+where such changes and/or additions to the Program originate from and are distributed
+by that particular Contributor. A Contribution 'originates' from a Contributor if it was
+added to the Program by such Contributor itself or anyone acting on such Contributor's
+behalf. Contributions do not include additions to the Program which: (i) are separate modules
+of software distributed in conjunction with the Program under their own license agreement, and
+(ii) are not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents" mean patent claims licensable by a Contributor which are necessarily
+infringed by the use or sale of its Contribution alone or when combined with the Program.
+
+"Program" means the Contributions distributed in accordance with this Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement, including all
+Contributors.
+
+2. GRANT OF RIGHTS
+
+a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a
+non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative
+works of, publicly display, publicly perform, distribute and sublicense the Contribution of
+such Contributor, if any, and such derivative works, in source code and object code form.
+
+b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a
+non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use,
+sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any,
+in source code and object code form. This patent license shall apply to the combination of
+the Contribution and the Program if, at the time the Contribution is added by the Contributor,
+such addition of the Contribution causes such combination to be covered by the Licensed Patents.
+The patent license shall not apply to any other combinations which include the Contribution.
+No hardware per se is licensed hereunder.
+
+c) Recipient understands that although each Contributor grants the licenses to its Contributions
+set forth herein, no assurances are provided by any Contributor that the Program does not
+infringe the patent or other intellectual property rights of any other entity. Each Contributor
+disclaims any liability to Recipient for claims brought by any other entity based on
+infringement of intellectual property rights or otherwise. As a condition to exercising the
+rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to
+secure any other intellectual property rights needed, if any. For example, if a third party
+patent license is required to allow Recipient to distribute the Program, it is Recipient's
+responsibility to acquire that license before distributing the Program.
+
+d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its
+Contribution, if any, to grant the copyright license set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code form under its own license
+agreement, provided that:
+
+a) it complies with the terms and conditions of this Agreement; and
+
+b) its license agreement:
+
+i) effectively disclaims on behalf of all Contributors all warranties and conditions,
+express and implied, including warranties or conditions of title and non-infringement,
+and implied warranties or conditions of merchantability and fitness for a particular purpose;
+
+ii) effectively excludes on behalf of all Contributors all liability for damages, including
+direct, indirect, special, incidental and consequential damages, such as lost profits;
+
+iii) states that any provisions which differ from this Agreement are offered by that Contributor
+alone and not by any other party; and
+
+iv) states that source code for the Program is available from such Contributor, and informs
+licensees how to obtain it in a reasonable manner on or through a medium customarily used for
+software exchange.
+
+When the Program is made available in source code form:
+
+a) it must be made available under this Agreement; and
+
+b) a copy of this Agreement must be included with each copy of the Program.
+
+Contributors may not remove or alter any copyright notices contained within the Program.
+
+Each Contributor must identify itself as the originator of its Contribution, if any, in a
+manner that reasonably allows subsequent Recipients to identify the originator of the
+Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities with respect to end
+users, business partners and the like. While this license is intended to facilitate the
+commercial use of the Program, the Contributor who includes the Program in a commercial
+product offering should do so in a manner which does not create potential liability for
+other Contributors. Therefore, if a Contributor includes the Program in a commercial
+product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and
+indemnify every other Contributor ("Indemnified Contributor") against any losses, damages
+and costs (collectively "Losses") arising from claims, lawsuits and other legal actions
+brought by a third party against the Indemnified Contributor to the extent caused by the
+acts or omissions of such Commercial Contributor in connection with its distribution of the
+Program in a commercial product offering. The obligations in this section do not apply to
+any claims or Losses relating to any actual or alleged intellectual property infringement.
+In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial
+Contributor in writing of such claim, and b) allow the Commercial Contributor to control,
+and cooperate with the Commercial Contributor in, the defense and any related settlement
+negotiations. The Indemnified Contributor may participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial product offering,
+Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor
+then makes performance claims, or offers warranties related to Product X, those performance
+claims and warranties are such Commercial Contributor's responsibility alone. Under this
+section, the Commercial Contributor would have to defend claims against the other Contributors
+related to those performance claims and warranties, and if a court requires any other Contributor
+to pay any damages as a result, the Commercial Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT
+LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR
+FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the
+appropriateness of using and distributing the Program and assumes all risks associated with
+its exercise of rights under this Agreement , including but not limited to the risks and costs
+of program errors, compliance with applicable laws, damage to or loss of data, programs or
+equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL
+HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS
+GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under applicable law, it shall
+not affect the validity or enforceability of the remainder of the terms of this Agreement,
+and without further action by the parties hereto, such provision shall be reformed to the
+minimum extent necessary to make such provision valid and enforceable.
+
+If Recipient institutes patent litigation against any entity (including a cross-claim or
+counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the
+Program with other software or hardware) infringes such Recipient's patent(s), then such
+Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation
+is filed.
+
+All Recipient's rights under this Agreement shall terminate if it fails to comply with any of
+the material terms or conditions of this Agreement and does not cure such failure in a
+reasonable period of time after becoming aware of such noncompliance. If all Recipient's
+rights under this Agreement terminate, Recipient agrees to cease use and distribution of the
+Program as soon as reasonably practicable. However, Recipient's obligations under this
+Agreement and any licenses granted by Recipient relating to the Program shall continue
+and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid
+inconsistency the Agreement is copyrighted and may only be modified in the following manner.
+The Agreement Steward reserves the right to publish new versions (including revisions)
+of this Agreement from time to time. No one other than the Agreement Steward has the
+right to modify this Agreement. The Eclipse Foundation is the initial Agreement Steward.
+The Eclipse Foundation may assign the responsibility to serve as the Agreement Steward to
+a suitable separate entity. Each new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be distributed subject to
+the version of the Agreement under which it was received. In addition, after a new version
+of the Agreement is published, Contributor may elect to distribute the Program (including
+its Contributions) under the new version. Except as expressly stated in Sections 2(a) and
+2(b) above, Recipient receives no rights or licenses to the intellectual property of any
+Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise.
+All rights in the Program not expressly granted under this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and the intellectual property
+laws of the United States of America. No party to this Agreement will bring a legal action
+under this Agreement more than one year after the cause of action arose. Each party waives
+its rights to a jury trial in any resulting litigation.
+
+Licensed under the MIT License
+rapidjson (https://github.com/monero-project/monero/blob/master/external/rapidjson)
+Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved.
+easylogging++ (https://github.com/monero-project/monero/tree/master/external/easylogging%2B%2B)
+Copyright (c) 2017 muflihun.com
+zxcvbn4j (https://github.com/nulab/zxcvbn4j)
+Copyright (c) 2014 Nulab Inc
+slfj-nop - Simple Logging Facade for Java no-operation binding (https://www.slf4j.org/)
+Copyright (c) 2004-2017 QOS.ch
+The MIT License
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software
+and associated documentation files (the "Software"), to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute,
+sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or
+substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
+BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+Monero (https://github.com/monero-project/monero)
+The Monero Project License
+Copyright (c) 2014-2017, The Monero Project. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+may be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Parts of the project are originally copyright (c) 2012-2013 The Cryptonote
+developers
+
+OpenSSL (https://github.com/openssl/openssl)
+LICENSE ISSUES
+The OpenSSL toolkit stays under a double license, i.e. both the conditions of
+the OpenSSL License and the original SSLeay license apply to the toolkit.
+See below for the actual license texts.
+OpenSSL License
+Copyright (c) 1998-2017 The OpenSSL Project. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in
+the documentation and/or other materials provided with the
+distribution.
+
+3. All advertising materials mentioning features or use of this
+software must display the following acknowledgment:
+"This product includes software developed by the OpenSSL Project
+for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
+
+4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to
+endorse or promote products derived from this software without
+prior written permission. For written permission, please contact
+openssl-core@openssl.org.
+
+5. Products derived from this software may not be called "OpenSSL"
+nor may "OpenSSL" appear in their names without prior written
+permission of the OpenSSL Project.
+
+6. Redistributions of any form whatsoever must retain the following
+acknowledgment:
+"This product includes software developed by the OpenSSL Project
+for use in the OpenSSL Toolkit (http://www.openssl.org/)"
+
+THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT "AS IS" AND ANY
+EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR
+ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+OF THE POSSIBILITY OF SUCH DAMAGE.
+
+This product includes cryptographic software written by Eric Young
+(eay@cryptsoft.com). This product includes software written by Tim
+Hudson (tjh@cryptsoft.com).
+Original SSLeay License
+Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved.
+
+This package is an SSL implementation written
+by Eric Young (eay@cryptsoft.com).
+The implementation was written so as to conform with Netscapes SSL.
+
+This library is free for commercial and non-commercial use as long as
+the following conditions are aheared to. The following conditions
+apply to all code found in this distribution, be it the RC4, RSA,
+lhash, DES, etc., code; not just the SSL code. The SSL documentation
+included with this distribution is covered by the same copyright terms
+except that the holder is Tim Hudson (tjh@cryptsoft.com).
+
+Copyright remains Eric Young's, and as such any Copyright notices in
+the code are not to be removed.
+If this package is used in a product, Eric Young should be given attribution
+as the author of the parts of the library used.
+This can be in the form of a textual message at program startup or
+in documentation (online or textual) provided with the package.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the copyright
+notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+3. All advertising materials mentioning features or use of this software
+must display the following acknowledgement:
+"This product includes cryptographic software written by
+Eric Young (eay@cryptsoft.com)"
+The word 'cryptographic' can be left out if the rouines from the library
+being used are not cryptographic related :-).
+4. If you include any Windows specific code (or a derivative thereof) from
+the apps directory (application code) you must include an acknowledgement:
+"This product includes software written by Tim Hudson (tjh@cryptsoft.com)"
+
+THIS SOFTWARE IS PROVIDED BY ERIC YOUNG "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+
+The licence and distribution terms for any publically available version or
+derivative of this code cannot be changed. i.e. this code cannot simply be
+copied and put under another distribution licence
+[including the GNU Public Licence.]
+
+Boost
+
+ Boost (https://sourceforge.net/projects/boost)
+ Boost/Archive (https://github.com/monero-project/monero/tree/master/external/boost/archive)
+
+Boost Software License - Version 1.0 - August 17th, 2003
+Permission is hereby granted, free of charge, to any person or organization
+obtaining a copy of the software and accompanying documentation covered by
+this license (the "Software") to use, reproduce, display, distribute,
+execute, and transmit the Software, and to prepare derivative works of the
+Software, and to permit third-parties to whom the Software is furnished to
+do so, all subject to the following:
+
+The copyright notices in the Software and this entire statement, including
+the above license grant, this restriction and the following disclaimer,
+must be included in all copies of the Software, in whole or in part, and
+all derivative works of the Software, unless such copies or derivative
+works are solely in the form of machine-executable object code generated by
+a source language processor.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+Unbound (https://github.com/monero-project/monero/blob/master/external/unbound)
+Unbound Software License
+Copyright (c) 2007, NLnet Labs. All rights reserved.
+
+This software is open source.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+Neither the name of the NLNET LABS nor the names of its contributors may
+be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+MiniUPnPc (https://github.com/monero-project/monero/blob/master/external/miniupnpc)
+Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved.
+The MiniUPnPc License
+Copyright (c) 2005-2015, Thomas BERNARD. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+* The name of the author may not be used to endorse or promote products
+derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+liblmdb (https://github.com/monero-project/monero/blob/master/external/db_drivers/liblmdb)
+The OpenLDAP Public License, Version 2.8, 17 August 2003
+Redistribution and use of this software and associated documentation
+("Software"), with or without modification, are permitted provided
+that the following conditions are met:
+
+1. Redistributions in source form must retain copyright statements
+and notices,
+
+2. Redistributions in binary form must reproduce applicable copyright
+statements and notices, this list of conditions, and the following
+disclaimer in the documentation and/or other materials provided
+with the distribution, and
+
+3. Redistributions must contain a verbatim copy of this document.
+
+The OpenLDAP Foundation may revise this license from time to time.
+Each revision is distinguished by a version number. You may use
+this Software under terms of this license revision or under the
+terms of any subsequent revision of the license.
+
+THIS SOFTWARE IS PROVIDED BY THE OPENLDAP FOUNDATION AND ITS
+CONTRIBUTORS "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES,
+INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+SHALL THE OPENLDAP FOUNDATION, ITS CONTRIBUTORS, OR THE AUTHOR(S)
+OR OWNER(S) OF THE SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+The names of the authors and copyright holders must not be used in
+advertising or otherwise to promote the sale, use or other dealing
+in this Software without specific, written prior permission. Title
+to copyright in this Software shall at all times remain with copyright
+holders.
+
+OpenLDAP is a registered trademark of the OpenLDAP Foundation.
+
+Copyright 1999-2003 The OpenLDAP Foundation, Redwood City,
+California, USA. All Rights Reserved. Permission to copy and
+distribute verbatim copies of this document is granted.
+
+epee (https://github.com/monero-project/monero/blob/master/contrib/epee)
+Copyright (c) 2006-2013, Andrey N. Sabelnikov, www.sabelnikov.net. All rights reserved.
+The epee License
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+* Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+* Neither the name of the Andrey N. Sabelnikov nor the
+names of its contributors may be used to endorse or promote products
+derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL Andrey N. Sabelnikov BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+'Poppins' Font
+SIL Open Font License
+Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com).
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+—————————————————————————————-
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+ —————————————————————————————-
+PREAMBLE
+ The goals of the Open Font License (OFL) are to stimulate worldwide development of
+ collaborative font projects, to support the font creation efforts of academic and
+ linguistic communities, and to provide a free and open framework in which fonts may be
+ shared and improved in partnership with others.
+The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as
+ long as they are not sold by themselves. The fonts, including any derivative works, can be
+ bundled, embedded, redistributed and/or sold with any software provided that any reserved
+ names are not used by derivative works. The fonts and derivatives, however, cannot be
+ released under any other type of license. The requirement for fonts to remain under this
+ license does not apply to any document created using the fonts or their derivatives.
+DEFINITIONS
+ “Font Software” refers to the set of files released by the Copyright Holder(s)
+ under this license and clearly marked as such. This may include source files, build scripts
+ and documentation.
+“Reserved Font Name” refers to any names specified as such after the copyright
+ statement(s).
+“Original Version” refers to the collection of Font Software components as
+ distributed by the Copyright Holder(s).
+“Modified Version” refers to any derivative made by adding to, deleting, or
+ substituting—in part or in whole—any of the components of the Original Version,
+ by changing formats or by porting the Font Software to a new environment.
+“Author” refers to any designer, engineer, programmer, technical writer or other
+ person who contributed to the Font Software.
+PERMISSION & CONDITIONS
+ Permission is hereby granted, free of charge, to any person obtaining a copy of the Font
+ Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and
+ unmodified copies of the Font Software, subject to the following conditions:
+1) Neither the Font Software nor any of its individual components, in Original or Modified
+ Versions, may be sold by itself.
+2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold
+ with any software, provided that each copy contains the above copyright notice and this
+ license. These can be included either as stand-alone text files, human-readable headers or
+ in the appropriate machine-readable metadata fields within text or binary files as long as
+ those fields can be easily viewed by the user.
+3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit
+ written permission is granted by the corresponding Copyright Holder. This restriction only
+ applies to the primary font name as presented to the users.
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be
+ used to promote, endorse or advertise any Modified Version, except to acknowledge the
+ contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit
+ written permission.
+5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely
+ under this license, and must not be distributed under any other license. The requirement
+ for fonts to remain under this license does not apply to any document created using the Font
+ Software.
+TERMINATION
+ This license becomes null and void if any of the above conditions are not met.
+DISCLAIMER
+ THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN
+ NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN
+ AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE
+ THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/cpp/monerujo.cpp b/app/src/main/cpp/monerujo.cpp
new file mode 100644
index 0000000..03ab0ab
--- /dev/null
+++ b/app/src/main/cpp/monerujo.cpp
@@ -0,0 +1,1531 @@
+/**
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include
+#include "monerujo.h"
+#include "wallet2_api.h"
+
+//TODO explicit casting jlong, jint, jboolean to avoid warnings
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+#include
+#define LOG_TAG "WalletNDK"
+#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG,__VA_ARGS__)
+#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG , LOG_TAG,__VA_ARGS__)
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO , LOG_TAG,__VA_ARGS__)
+#define LOGW(...) __android_log_print(ANDROID_LOG_WARN , LOG_TAG,__VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR , LOG_TAG,__VA_ARGS__)
+
+static JavaVM *cachedJVM;
+static jclass class_ArrayList;
+static jclass class_WalletListener;
+static jclass class_TransactionInfo;
+static jclass class_Transfer;
+static jclass class_Ledger;
+static jclass class_WalletStatus;
+
+std::mutex _listenerMutex;
+
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) {
+ cachedJVM = jvm;
+ LOGI("JNI_OnLoad");
+ JNIEnv *jenv;
+ if (jvm->GetEnv(reinterpret_cast(&jenv), JNI_VERSION_1_6) != JNI_OK) {
+ return -1;
+ }
+ //LOGI("JNI_OnLoad ok");
+
+ class_ArrayList = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("java/util/ArrayList")));
+ class_TransactionInfo = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("com/m2049r/xmrwallet/model/TransactionInfo")));
+ class_Transfer = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("com/m2049r/xmrwallet/model/Transfer")));
+ class_WalletListener = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("com/m2049r/xmrwallet/model/WalletListener")));
+ class_Ledger = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("com/m2049r/xmrwallet/ledger/Ledger")));
+ class_WalletStatus = static_cast(jenv->NewGlobalRef(
+ jenv->FindClass("com/m2049r/xmrwallet/model/Wallet$Status")));
+ return JNI_VERSION_1_6;
+}
+#ifdef __cplusplus
+}
+#endif
+
+int attachJVM(JNIEnv **jenv) {
+ int envStat = cachedJVM->GetEnv((void **) jenv, JNI_VERSION_1_6);
+ if (envStat == JNI_EDETACHED) {
+ if (cachedJVM->AttachCurrentThread(jenv, nullptr) != 0) {
+ LOGE("Failed to attach");
+ return JNI_ERR;
+ }
+ } else if (envStat == JNI_EVERSION) {
+ LOGE("GetEnv: version not supported");
+ return JNI_ERR;
+ }
+ //LOGI("envStat=%i", envStat);
+ return envStat;
+}
+
+void detachJVM(JNIEnv *jenv, int envStat) {
+ //LOGI("envStat=%i", envStat);
+ if (jenv->ExceptionCheck()) {
+ jenv->ExceptionDescribe();
+ }
+
+ if (envStat == JNI_EDETACHED) {
+ cachedJVM->DetachCurrentThread();
+ }
+}
+
+struct MyWalletListener : Monero::WalletListener {
+ jobject jlistener;
+
+ MyWalletListener(JNIEnv *env, jobject aListener) {
+ LOGD("Created MyListener");
+ jlistener = env->NewGlobalRef(aListener);;
+ }
+
+ ~MyWalletListener() {
+ LOGD("Destroyed MyListener");
+ };
+
+ void deleteGlobalJavaRef(JNIEnv *env) {
+ std::lock_guard lock(_listenerMutex);
+ env->DeleteGlobalRef(jlistener);
+ jlistener = nullptr;
+ }
+
+ /**
+ * @brief updated - generic callback, called when any event (sent/received/block reveived/etc) happened with the wallet;
+ */
+ void updated() {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ LOGD("updated");
+ JNIEnv *jenv;
+ int envStat = attachJVM(&jenv);
+ if (envStat == JNI_ERR) return;
+
+ jmethodID listenerClass_updated = jenv->GetMethodID(class_WalletListener, "updated", "()V");
+ jenv->CallVoidMethod(jlistener, listenerClass_updated);
+
+ detachJVM(jenv, envStat);
+ }
+
+
+ /**
+ * @brief moneySpent - called when money spent
+ * @param txId - transaction id
+ * @param amount - amount
+ */
+ void moneySpent(const std::string &txId, uint64_t amount) {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ LOGD("moneySpent %"
+ PRIu64, amount);
+ }
+
+ /**
+ * @brief moneyReceived - called when money received
+ * @param txId - transaction id
+ * @param amount - amount
+ */
+ void moneyReceived(const std::string &txId, uint64_t amount) {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ LOGD("moneyReceived %"
+ PRIu64, amount);
+ }
+
+ /**
+ * @brief unconfirmedMoneyReceived - called when payment arrived in tx pool
+ * @param txId - transaction id
+ * @param amount - amount
+ */
+ void unconfirmedMoneyReceived(const std::string &txId, uint64_t amount) {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ LOGD("unconfirmedMoneyReceived %"
+ PRIu64, amount);
+ }
+
+ /**
+ * @brief newBlock - called when new block received
+ * @param height - block height
+ */
+ void newBlock(uint64_t height) {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ //LOGD("newBlock");
+ JNIEnv *jenv;
+ int envStat = attachJVM(&jenv);
+ if (envStat == JNI_ERR) return;
+
+ jlong h = static_cast(height);
+ jmethodID listenerClass_newBlock = jenv->GetMethodID(class_WalletListener, "newBlock",
+ "(J)V");
+ jenv->CallVoidMethod(jlistener, listenerClass_newBlock, h);
+
+ detachJVM(jenv, envStat);
+ }
+
+/**
+ * @brief refreshed - called when wallet refreshed by background thread or explicitly refreshed by calling "refresh" synchronously
+ */
+ void refreshed() {
+ std::lock_guard lock(_listenerMutex);
+ if (jlistener == nullptr) return;
+ LOGD("refreshed");
+ JNIEnv *jenv;
+
+ int envStat = attachJVM(&jenv);
+ if (envStat == JNI_ERR) return;
+
+ jmethodID listenerClass_refreshed = jenv->GetMethodID(class_WalletListener, "refreshed",
+ "()V");
+ jenv->CallVoidMethod(jlistener, listenerClass_refreshed);
+ detachJVM(jenv, envStat);
+ }
+};
+
+
+//// helper methods
+std::vector java2cpp(JNIEnv *env, jobject arrayList) {
+
+ jmethodID java_util_ArrayList_size = env->GetMethodID(class_ArrayList, "size", "()I");
+ jmethodID java_util_ArrayList_get = env->GetMethodID(class_ArrayList, "get",
+ "(I)Ljava/lang/Object;");
+
+ jint len = env->CallIntMethod(arrayList, java_util_ArrayList_size);
+ std::vector result;
+ result.reserve(len);
+ for (jint i = 0; i < len; i++) {
+ jstring element = static_cast(env->CallObjectMethod(arrayList,
+ java_util_ArrayList_get, i));
+ const char *pchars = env->GetStringUTFChars(element, nullptr);
+ result.emplace_back(pchars);
+ env->ReleaseStringUTFChars(element, pchars);
+ env->DeleteLocalRef(element);
+ }
+ return result;
+}
+
+jobject cpp2java(JNIEnv *env, const std::vector &vector) {
+
+ jmethodID java_util_ArrayList_ = env->GetMethodID(class_ArrayList, "", "(I)V");
+ jmethodID java_util_ArrayList_add = env->GetMethodID(class_ArrayList, "add",
+ "(Ljava/lang/Object;)Z");
+
+ jobject result = env->NewObject(class_ArrayList, java_util_ArrayList_,
+ static_cast (vector.size()));
+ for (const std::string &s: vector) {
+ jstring element = env->NewStringUTF(s.c_str());
+ env->CallBooleanMethod(result, java_util_ArrayList_add, element);
+ env->DeleteLocalRef(element);
+ }
+ return result;
+}
+
+/// end helpers
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+
+/**********************************/
+/********** WalletManager *********/
+/**********************************/
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_createWalletJ(JNIEnv *env, jobject instance,
+ jstring path, jstring password,
+ jstring language,
+ jint networkType) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ const char *_language = env->GetStringUTFChars(language, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+
+ Monero::Wallet *wallet =
+ Monero::WalletManagerFactory::getWalletManager()->createWallet(
+ std::string(_path),
+ std::string(_password),
+ std::string(_language),
+ _networkType);
+
+ env->ReleaseStringUTFChars(path, _path);
+ env->ReleaseStringUTFChars(password, _password);
+ env->ReleaseStringUTFChars(language, _language);
+ return reinterpret_cast(wallet);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_openWalletJ(JNIEnv *env, jobject instance,
+ jstring path, jstring password,
+ jint networkType) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+
+ Monero::Wallet *wallet =
+ Monero::WalletManagerFactory::getWalletManager()->openWallet(
+ std::string(_path),
+ std::string(_password),
+ _networkType);
+
+ env->ReleaseStringUTFChars(path, _path);
+ env->ReleaseStringUTFChars(password, _password);
+ return reinterpret_cast(wallet);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_recoveryWalletJ(JNIEnv *env, jobject instance,
+ jstring path, jstring password,
+ jstring mnemonic, jstring offset,
+ jint networkType,
+ jlong restoreHeight) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ const char *_mnemonic = env->GetStringUTFChars(mnemonic, nullptr);
+ const char *_offset = env->GetStringUTFChars(offset, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+
+ Monero::Wallet *wallet =
+ Monero::WalletManagerFactory::getWalletManager()->recoveryWallet(
+ std::string(_path),
+ std::string(_password),
+ std::string(_mnemonic),
+ _networkType,
+ (uint64_t) restoreHeight,
+ 1, // kdf_rounds
+ std::string(_offset));
+
+ env->ReleaseStringUTFChars(path, _path);
+ env->ReleaseStringUTFChars(password, _password);
+ env->ReleaseStringUTFChars(mnemonic, _mnemonic);
+ env->ReleaseStringUTFChars(offset, _offset);
+ return reinterpret_cast(wallet);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_createWalletFromKeysJ(JNIEnv *env, jobject instance,
+ jstring path, jstring password,
+ jstring language,
+ jint networkType,
+ jlong restoreHeight,
+ jstring addressString,
+ jstring viewKeyString,
+ jstring spendKeyString) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ const char *_language = env->GetStringUTFChars(language, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+ const char *_addressString = env->GetStringUTFChars(addressString, nullptr);
+ const char *_viewKeyString = env->GetStringUTFChars(viewKeyString, nullptr);
+ const char *_spendKeyString = env->GetStringUTFChars(spendKeyString, nullptr);
+
+ Monero::Wallet *wallet =
+ Monero::WalletManagerFactory::getWalletManager()->createWalletFromKeys(
+ std::string(_path),
+ std::string(_password),
+ std::string(_language),
+ _networkType,
+ (uint64_t) restoreHeight,
+ std::string(_addressString),
+ std::string(_viewKeyString),
+ std::string(_spendKeyString));
+
+ env->ReleaseStringUTFChars(path, _path);
+ env->ReleaseStringUTFChars(password, _password);
+ env->ReleaseStringUTFChars(language, _language);
+ env->ReleaseStringUTFChars(addressString, _addressString);
+ env->ReleaseStringUTFChars(viewKeyString, _viewKeyString);
+ env->ReleaseStringUTFChars(spendKeyString, _spendKeyString);
+ return reinterpret_cast(wallet);
+}
+
+
+// virtual void setSubaddressLookahead(uint32_t major, uint32_t minor) = 0;
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_createWalletFromDeviceJ(JNIEnv *env, jobject instance,
+ jstring path,
+ jstring password,
+ jint networkType,
+ jstring deviceName,
+ jlong restoreHeight,
+ jstring subaddressLookahead) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+ const char *_deviceName = env->GetStringUTFChars(deviceName, nullptr);
+ const char *_subaddressLookahead = env->GetStringUTFChars(subaddressLookahead, nullptr);
+
+ Monero::Wallet *wallet =
+ Monero::WalletManagerFactory::getWalletManager()->createWalletFromDevice(
+ std::string(_path),
+ std::string(_password),
+ _networkType,
+ std::string(_deviceName),
+ (uint64_t) restoreHeight,
+ std::string(_subaddressLookahead));
+
+ env->ReleaseStringUTFChars(path, _path);
+ env->ReleaseStringUTFChars(password, _password);
+ env->ReleaseStringUTFChars(deviceName, _deviceName);
+ env->ReleaseStringUTFChars(subaddressLookahead, _subaddressLookahead);
+ return reinterpret_cast(wallet);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_walletExists(JNIEnv *env, jobject instance,
+ jstring path) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ bool exists =
+ Monero::WalletManagerFactory::getWalletManager()->walletExists(std::string(_path));
+ env->ReleaseStringUTFChars(path, _path);
+ return static_cast(exists);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_verifyWalletPassword(JNIEnv *env, jobject instance,
+ jstring keys_file_name,
+ jstring password,
+ jboolean watch_only) {
+ const char *_keys_file_name = env->GetStringUTFChars(keys_file_name, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ bool passwordOk =
+ Monero::WalletManagerFactory::getWalletManager()->verifyWalletPassword(
+ std::string(_keys_file_name), std::string(_password), watch_only);
+ env->ReleaseStringUTFChars(keys_file_name, _keys_file_name);
+ env->ReleaseStringUTFChars(password, _password);
+ return static_cast(passwordOk);
+}
+
+//virtual int queryWalletHardware(const std::string &keys_file_name, const std::string &password) const = 0;
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_queryWalletDeviceJ(JNIEnv *env, jobject instance,
+ jstring keys_file_name,
+ jstring password) {
+ const char *_keys_file_name = env->GetStringUTFChars(keys_file_name, nullptr);
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ Monero::Wallet::Device device_type;
+ bool ok = Monero::WalletManagerFactory::getWalletManager()->
+ queryWalletDevice(device_type, std::string(_keys_file_name), std::string(_password));
+ env->ReleaseStringUTFChars(keys_file_name, _keys_file_name);
+ env->ReleaseStringUTFChars(password, _password);
+ if (ok)
+ return static_cast(device_type);
+ else
+ return -1;
+}
+
+JNIEXPORT jobject JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_findWallets(JNIEnv *env, jobject instance,
+ jstring path) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ std::vector walletPaths =
+ Monero::WalletManagerFactory::getWalletManager()->findWallets(std::string(_path));
+ env->ReleaseStringUTFChars(path, _path);
+ return cpp2java(env, walletPaths);
+}
+
+//TODO virtual bool checkPayment(const std::string &address, const std::string &txid, const std::string &txkey, const std::string &daemon_address, uint64_t &received, uint64_t &height, std::string &error) const = 0;
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_setDaemonAddressJ(JNIEnv *env, jobject instance,
+ jstring address) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ Monero::WalletManagerFactory::getWalletManager()->setDaemonAddress(std::string(_address));
+ env->ReleaseStringUTFChars(address, _address);
+}
+
+// returns whether the daemon can be reached, and its version number
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getDaemonVersion(JNIEnv *env,
+ jobject instance) {
+ uint32_t version;
+ bool isConnected =
+ Monero::WalletManagerFactory::getWalletManager()->connected(&version);
+ if (!isConnected) version = 0;
+ return version;
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getBlockchainHeight(JNIEnv *env, jobject instance) {
+ return Monero::WalletManagerFactory::getWalletManager()->blockchainHeight();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getBlockchainTargetHeight(JNIEnv *env,
+ jobject instance) {
+ return Monero::WalletManagerFactory::getWalletManager()->blockchainTargetHeight();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getNetworkDifficulty(JNIEnv *env, jobject instance) {
+ return Monero::WalletManagerFactory::getWalletManager()->networkDifficulty();
+}
+
+JNIEXPORT jdouble JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getMiningHashRate(JNIEnv *env, jobject instance) {
+ return Monero::WalletManagerFactory::getWalletManager()->miningHashRate();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_getBlockTarget(JNIEnv *env, jobject instance) {
+ return Monero::WalletManagerFactory::getWalletManager()->blockTarget();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_isMining(JNIEnv *env, jobject instance) {
+ return static_cast(Monero::WalletManagerFactory::getWalletManager()->isMining());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_startMining(JNIEnv *env, jobject instance,
+ jstring address,
+ jboolean background_mining,
+ jboolean ignore_battery) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ bool success =
+ Monero::WalletManagerFactory::getWalletManager()->startMining(std::string(_address),
+ background_mining,
+ ignore_battery);
+ env->ReleaseStringUTFChars(address, _address);
+ return static_cast(success);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_stopMining(JNIEnv *env, jobject instance) {
+ return static_cast(Monero::WalletManagerFactory::getWalletManager()->stopMining());
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_resolveOpenAlias(JNIEnv *env, jobject instance,
+ jstring address,
+ jboolean dnssec_valid) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ bool _dnssec_valid = (bool) dnssec_valid;
+ std::string resolvedAlias =
+ Monero::WalletManagerFactory::getWalletManager()->resolveOpenAlias(
+ std::string(_address),
+ _dnssec_valid);
+ env->ReleaseStringUTFChars(address, _address);
+ return env->NewStringUTF(resolvedAlias.c_str());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_setProxy(JNIEnv *env, jobject instance,
+ jstring address) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ bool rc =
+ Monero::WalletManagerFactory::getWalletManager()->setProxy(std::string(_address));
+ env->ReleaseStringUTFChars(address, _address);
+ return rc;
+}
+
+
+//TODO static std::tuple checkUpdates(const std::string &software, const std::string &subdir);
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_closeJ(JNIEnv *env, jobject instance,
+ jobject walletInstance) {
+ Monero::Wallet *wallet = getHandle(env, walletInstance);
+ bool closeSuccess = Monero::WalletManagerFactory::getWalletManager()->closeWallet(wallet,
+ false);
+ if (closeSuccess) {
+ MyWalletListener *walletListener = getHandle(env, walletInstance,
+ "listenerHandle");
+ if (walletListener != nullptr) {
+ walletListener->deleteGlobalJavaRef(env);
+ delete walletListener;
+ }
+ }
+ LOGD("wallet closed");
+ return static_cast(closeSuccess);
+}
+
+
+
+
+/**********************************/
+/************ Wallet **************/
+/**********************************/
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getSeed(JNIEnv *env, jobject instance, jstring seedOffset) {
+ const char *_seedOffset = env->GetStringUTFChars(seedOffset, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ jstring seed = env->NewStringUTF(wallet->seed(std::string(_seedOffset)).c_str());
+ env->ReleaseStringUTFChars(seedOffset, _seedOffset);
+ return seed;
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getSeedLanguage(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(wallet->getSeedLanguage().c_str());
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setSeedLanguage(JNIEnv *env, jobject instance,
+ jstring language) {
+ const char *_language = env->GetStringUTFChars(language, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->setSeedLanguage(std::string(_language));
+ env->ReleaseStringUTFChars(language, _language);
+}
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getStatusJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->status();
+}
+
+jobject newWalletStatusInstance(JNIEnv *env, int status, const std::string &errorString) {
+ jmethodID init = env->GetMethodID(class_WalletStatus, "",
+ "(ILjava/lang/String;)V");
+ jstring _errorString = env->NewStringUTF(errorString.c_str());
+ jobject instance = env->NewObject(class_WalletStatus, init, status, _errorString);
+ env->DeleteLocalRef(_errorString);
+ return instance;
+}
+
+
+JNIEXPORT jobject JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_statusWithErrorString(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ int status;
+ std::string errorString;
+ wallet->statusWithErrorString(status, errorString);
+
+ return newWalletStatusInstance(env, status, errorString);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setPassword(JNIEnv *env, jobject instance,
+ jstring password) {
+ const char *_password = env->GetStringUTFChars(password, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ bool success = wallet->setPassword(std::string(_password));
+ env->ReleaseStringUTFChars(password, _password);
+ return static_cast(success);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getAddressJ(JNIEnv *env, jobject instance,
+ jint accountIndex,
+ jint addressIndex) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(
+ wallet->address((uint32_t) accountIndex, (uint32_t) addressIndex).c_str());
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getPath(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(wallet->path().c_str());
+}
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_nettype(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->nettype();
+}
+
+//TODO virtual void hardForkInfo(uint8_t &version, uint64_t &earliest_height) const = 0;
+//TODO virtual bool useForkRules(uint8_t version, int64_t early_blocks) const = 0;
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getIntegratedAddress(JNIEnv *env, jobject instance,
+ jstring payment_id) {
+ const char *_payment_id = env->GetStringUTFChars(payment_id, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ std::string address = wallet->integratedAddress(_payment_id);
+ env->ReleaseStringUTFChars(payment_id, _payment_id);
+ return env->NewStringUTF(address.c_str());
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getSecretViewKey(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(wallet->secretViewKey().c_str());
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getSecretSpendKey(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(wallet->secretSpendKey().c_str());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_store(JNIEnv *env, jobject instance,
+ jstring path) {
+ const char *_path = env->GetStringUTFChars(path, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ bool success = wallet->store(std::string(_path));
+ if (!success) {
+ LOGE("store() %s", wallet->errorString().c_str());
+ }
+ env->ReleaseStringUTFChars(path, _path);
+ return static_cast(success);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getFilename(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return env->NewStringUTF(wallet->filename().c_str());
+}
+
+// virtual std::string keysFilename() const = 0;
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_initJ(JNIEnv *env, jobject instance,
+ jstring daemon_address,
+ jlong upper_transaction_size_limit,
+ jstring daemon_username, jstring daemon_password) {
+ const char *_daemon_address = env->GetStringUTFChars(daemon_address, nullptr);
+ const char *_daemon_username = env->GetStringUTFChars(daemon_username, nullptr);
+ const char *_daemon_password = env->GetStringUTFChars(daemon_password, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ bool status = wallet->init(_daemon_address, (uint64_t) upper_transaction_size_limit,
+ _daemon_username,
+ _daemon_password);
+ env->ReleaseStringUTFChars(daemon_address, _daemon_address);
+ env->ReleaseStringUTFChars(daemon_username, _daemon_username);
+ env->ReleaseStringUTFChars(daemon_password, _daemon_password);
+ return static_cast(status);
+}
+
+// virtual bool createWatchOnly(const std::string &path, const std::string &password, const std::string &language) const = 0;
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setRestoreHeight(JNIEnv *env, jobject instance,
+ jlong height) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->setRefreshFromBlockHeight((uint64_t) height);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getRestoreHeight(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->getRefreshFromBlockHeight();
+}
+
+// virtual void setRecoveringFromSeed(bool recoveringFromSeed) = 0;
+// virtual bool connectToDaemon() = 0;
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getConnectionStatusJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->connected();
+}
+//TODO virtual void setTrustedDaemon(bool arg) = 0;
+//TODO virtual bool trustedDaemon() const = 0;
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setProxy(JNIEnv *env, jobject instance,
+ jstring address) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ bool rc = wallet->setProxy(std::string(_address));
+ env->ReleaseStringUTFChars(address, _address);
+ return rc;
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getBalance(JNIEnv *env, jobject instance,
+ jint accountIndex) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->balance((uint32_t) accountIndex);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getBalanceAll(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->balanceAll();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getUnlockedBalance(JNIEnv *env, jobject instance,
+ jint accountIndex) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->unlockedBalance((uint32_t) accountIndex);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getUnlockedBalanceAll(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->unlockedBalanceAll();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_isWatchOnly(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return static_cast(wallet->watchOnly());
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getBlockChainHeight(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->blockChainHeight();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getApproximateBlockChainHeight(JNIEnv *env,
+ jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->approximateBlockChainHeight();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getDaemonBlockChainHeight(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->daemonBlockChainHeight();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getDaemonBlockChainTargetHeight(JNIEnv *env,
+ jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->daemonBlockChainTargetHeight();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_isSynchronizedJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return static_cast(wallet->synchronized());
+}
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getDeviceTypeJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ Monero::Wallet::Device device_type = wallet->getDeviceType();
+ return static_cast(device_type);
+}
+
+//void cn_slow_hash(const void *data, size_t length, char *hash); // from crypto/hash-ops.h
+JNIEXPORT jbyteArray JNICALL
+Java_com_m2049r_xmrwallet_util_KeyStoreHelper_slowHash(JNIEnv *env, jclass clazz,
+ jbyteArray data, jint brokenVariant) {
+ char hash[HASH_SIZE];
+ jsize size = env->GetArrayLength(data);
+ if ((brokenVariant > 0) && (size < 200 /*sizeof(union hash_state)*/)) {
+ return nullptr;
+ }
+
+ jbyte *buffer = env->GetByteArrayElements(data, nullptr);
+ switch (brokenVariant) {
+ case 1:
+ slow_hash_broken(buffer, hash, 1);
+ break;
+ case 2:
+ slow_hash_broken(buffer, hash, 0);
+ break;
+ default: // not broken
+ slow_hash(buffer, (size_t) size, hash);
+ }
+ env->ReleaseByteArrayElements(data, buffer, JNI_ABORT); // do not update java byte[]
+ jbyteArray result = env->NewByteArray(HASH_SIZE);
+ env->SetByteArrayRegion(result, 0, HASH_SIZE, (jbyte *) hash);
+ return result;
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getDisplayAmount(JNIEnv *env, jclass clazz,
+ jlong amount) {
+ return env->NewStringUTF(Monero::Wallet::displayAmount(amount).c_str());
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getAmountFromString(JNIEnv *env, jclass clazz,
+ jstring amount) {
+ const char *_amount = env->GetStringUTFChars(amount, nullptr);
+ uint64_t x = Monero::Wallet::amountFromString(_amount);
+ env->ReleaseStringUTFChars(amount, _amount);
+ return x;
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getAmountFromDouble(JNIEnv *env, jclass clazz,
+ jdouble amount) {
+ return Monero::Wallet::amountFromDouble(amount);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_generatePaymentId(JNIEnv *env, jclass clazz) {
+ return env->NewStringUTF(Monero::Wallet::genPaymentId().c_str());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_isPaymentIdValid(JNIEnv *env, jclass clazz,
+ jstring payment_id) {
+ const char *_payment_id = env->GetStringUTFChars(payment_id, nullptr);
+ bool isValid = Monero::Wallet::paymentIdValid(_payment_id);
+ env->ReleaseStringUTFChars(payment_id, _payment_id);
+ return static_cast(isValid);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_isAddressValid(JNIEnv *env, jclass clazz,
+ jstring address, jint networkType) {
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ Monero::NetworkType _networkType = static_cast(networkType);
+ bool isValid = Monero::Wallet::addressValid(_address, _networkType);
+ env->ReleaseStringUTFChars(address, _address);
+ return static_cast(isValid);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getPaymentIdFromAddress(JNIEnv *env, jclass clazz,
+ jstring address,
+ jint networkType) {
+ Monero::NetworkType _networkType = static_cast(networkType);
+ const char *_address = env->GetStringUTFChars(address, nullptr);
+ std::string payment_id = Monero::Wallet::paymentIdFromAddress(_address, _networkType);
+ env->ReleaseStringUTFChars(address, _address);
+ return env->NewStringUTF(payment_id.c_str());
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getMaximumAllowedAmount(JNIEnv *env, jclass clazz) {
+ return Monero::Wallet::maximumAllowedAmount();
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_startRefresh(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->startRefresh();
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_pauseRefresh(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->pauseRefresh();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_refresh(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return static_cast(wallet->refresh());
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_refreshAsync(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->refreshAsync();
+}
+
+//TODO virtual bool rescanBlockchain() = 0;
+
+//virtual void rescanBlockchainAsync() = 0;
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_rescanBlockchainAsyncJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->rescanBlockchainAsync();
+}
+
+
+//TODO virtual void setAutoRefreshInterval(int millis) = 0;
+//TODO virtual int autoRefreshInterval() const = 0;
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_createTransactionJ(JNIEnv *env, jobject instance,
+ jstring dst_addr, jstring payment_id,
+ jlong amount, jint mixin_count,
+ jint priority,
+ jint accountIndex) {
+
+ const char *_dst_addr = env->GetStringUTFChars(dst_addr, nullptr);
+ const char *_payment_id = env->GetStringUTFChars(payment_id, nullptr);
+ Monero::PendingTransaction::Priority _priority =
+ static_cast(priority);
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ Monero::PendingTransaction *tx = wallet->createTransaction(_dst_addr, _payment_id,
+ amount, (uint32_t) mixin_count,
+ _priority,
+ (uint32_t) accountIndex);
+
+ env->ReleaseStringUTFChars(dst_addr, _dst_addr);
+ env->ReleaseStringUTFChars(payment_id, _payment_id);
+ return reinterpret_cast(tx);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_createSweepTransaction(JNIEnv *env, jobject instance,
+ jstring dst_addr, jstring payment_id,
+ jint mixin_count,
+ jint priority,
+ jint accountIndex) {
+
+ const char *_dst_addr = env->GetStringUTFChars(dst_addr, nullptr);
+ const char *_payment_id = env->GetStringUTFChars(payment_id, nullptr);
+ Monero::PendingTransaction::Priority _priority =
+ static_cast(priority);
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ Monero::optional empty;
+
+ Monero::PendingTransaction *tx = wallet->createTransaction(_dst_addr, _payment_id,
+ empty, (uint32_t) mixin_count,
+ _priority,
+ (uint32_t) accountIndex);
+
+ env->ReleaseStringUTFChars(dst_addr, _dst_addr);
+ env->ReleaseStringUTFChars(payment_id, _payment_id);
+ return reinterpret_cast(tx);
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_createSweepUnmixableTransactionJ(JNIEnv *env,
+ jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ Monero::PendingTransaction *tx = wallet->createSweepUnmixableTransaction();
+ return reinterpret_cast(tx);
+}
+
+//virtual UnsignedTransaction * loadUnsignedTx(const std::string &unsigned_filename) = 0;
+//virtual bool submitTransaction(const std::string &fileName) = 0;
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_disposeTransaction(JNIEnv *env, jobject instance,
+ jobject pendingTransaction) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ Monero::PendingTransaction *_pendingTransaction =
+ getHandle(env, pendingTransaction);
+ wallet->disposeTransaction(_pendingTransaction);
+}
+
+//virtual bool exportKeyImages(const std::string &filename) = 0;
+//virtual bool importKeyImages(const std::string &filename) = 0;
+
+
+//virtual TransactionHistory * history() const = 0;
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getHistoryJ(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return reinterpret_cast(wallet->history());
+}
+
+//virtual AddressBook * addressBook() const = 0;
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setListenerJ(JNIEnv *env, jobject instance,
+ jobject javaListener) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->setListener(nullptr); // clear old listener
+ // delete old listener
+ MyWalletListener *oldListener = getHandle(env, instance,
+ "listenerHandle");
+ if (oldListener != nullptr) {
+ oldListener->deleteGlobalJavaRef(env);
+ delete oldListener;
+ }
+ if (javaListener == nullptr) {
+ LOGD("null listener");
+ return 0;
+ } else {
+ MyWalletListener *listener = new MyWalletListener(env, javaListener);
+ wallet->setListener(listener);
+ return reinterpret_cast(listener);
+ }
+}
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getDefaultMixin(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->defaultMixin();
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setDefaultMixin(JNIEnv *env, jobject instance, jint mixin) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return wallet->setDefaultMixin(mixin);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setUserNote(JNIEnv *env, jobject instance,
+ jstring txid, jstring note) {
+
+ const char *_txid = env->GetStringUTFChars(txid, nullptr);
+ const char *_note = env->GetStringUTFChars(note, nullptr);
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ bool success = wallet->setUserNote(_txid, _note);
+
+ env->ReleaseStringUTFChars(txid, _txid);
+ env->ReleaseStringUTFChars(note, _note);
+
+ return static_cast(success);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getUserNote(JNIEnv *env, jobject instance,
+ jstring txid) {
+
+ const char *_txid = env->GetStringUTFChars(txid, nullptr);
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ std::string note = wallet->getUserNote(_txid);
+
+ env->ReleaseStringUTFChars(txid, _txid);
+ return env->NewStringUTF(note.c_str());
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getTxKey(JNIEnv *env, jobject instance,
+ jstring txid) {
+
+ const char *_txid = env->GetStringUTFChars(txid, nullptr);
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ std::string txKey = wallet->getTxKey(_txid);
+
+ env->ReleaseStringUTFChars(txid, _txid);
+ return env->NewStringUTF(txKey.c_str());
+}
+
+//virtual void addSubaddressAccount(const std::string& label) = 0;
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_addAccount(JNIEnv *env, jobject instance,
+ jstring label) {
+
+ const char *_label = env->GetStringUTFChars(label, nullptr);
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->addSubaddressAccount(_label);
+
+ env->ReleaseStringUTFChars(label, _label);
+}
+
+//virtual std::string getSubaddressLabel(uint32_t accountIndex, uint32_t addressIndex) const = 0;
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getSubaddressLabel(JNIEnv *env, jobject instance,
+ jint accountIndex, jint addressIndex) {
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+
+ std::string label = wallet->getSubaddressLabel((uint32_t) accountIndex,
+ (uint32_t) addressIndex);
+
+ return env->NewStringUTF(label.c_str());
+}
+
+//virtual void setSubaddressLabel(uint32_t accountIndex, uint32_t addressIndex, const std::string &label) = 0;
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_setSubaddressLabel(JNIEnv *env, jobject instance,
+ jint accountIndex, jint addressIndex,
+ jstring label) {
+
+ const char *_label = env->GetStringUTFChars(label, nullptr);
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->setSubaddressLabel(accountIndex, addressIndex, _label);
+
+ env->ReleaseStringUTFChars(label, _label);
+}
+
+// virtual size_t numSubaddressAccounts() const = 0;
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getNumAccounts(JNIEnv *env, jobject instance) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return static_cast(wallet->numSubaddressAccounts());
+}
+
+//virtual size_t numSubaddresses(uint32_t accountIndex) const = 0;
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getNumSubaddresses(JNIEnv *env, jobject instance,
+ jint accountIndex) {
+ Monero::Wallet *wallet = getHandle(env, instance);
+ return static_cast(wallet->numSubaddresses(accountIndex));
+}
+
+//virtual void addSubaddress(uint32_t accountIndex, const std::string &label) = 0;
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_addSubaddress(JNIEnv *env, jobject instance,
+ jint accountIndex,
+ jstring label) {
+
+ const char *_label = env->GetStringUTFChars(label, nullptr);
+ Monero::Wallet *wallet = getHandle(env, instance);
+ wallet->addSubaddress(accountIndex, _label);
+ env->ReleaseStringUTFChars(label, _label);
+}
+
+/*JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_Wallet_getLastSubaddress(JNIEnv *env, jobject instance,
+ jint accountIndex) {
+
+ Monero::Wallet *wallet = getHandle(env, instance);
+ size_t num = wallet->numSubaddresses(accountIndex);
+ //wallet->subaddress()->getAll()[num]->getAddress().c_str()
+ Monero::Subaddress *s = wallet->subaddress();
+ s->refresh(accountIndex);
+ std::vector v = s->getAll();
+ return env->NewStringUTF(v[num - 1]->getAddress().c_str());
+}
+*/
+//virtual std::string signMessage(const std::string &message) = 0;
+//virtual bool verifySignedMessage(const std::string &message, const std::string &addres, const std::string &signature) const = 0;
+
+//virtual bool parse_uri(const std::string &uri, std::string &address, std::string &payment_id, uint64_t &tvAmount, std::string &tx_description, std::string &recipient_name, std::vector &unknown_parameters, std::string &error) = 0;
+//virtual bool rescanSpent() = 0;
+
+
+// TransactionHistory
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_TransactionHistory_getCount(JNIEnv *env, jobject instance) {
+ Monero::TransactionHistory *history = getHandle(env,
+ instance);
+ return history->count();
+}
+
+jobject newTransferInstance(JNIEnv *env, uint64_t amount, const std::string &address) {
+ jmethodID c = env->GetMethodID(class_Transfer, "",
+ "(JLjava/lang/String;)V");
+ jstring _address = env->NewStringUTF(address.c_str());
+ jobject transfer = env->NewObject(class_Transfer, c, static_cast (amount), _address);
+ env->DeleteLocalRef(_address);
+ return transfer;
+}
+
+jobject newTransferList(JNIEnv *env, Monero::TransactionInfo *info) {
+ const std::vector &transfers = info->transfers();
+ if (transfers.empty()) { // don't create empty Lists
+ return nullptr;
+ }
+ // make new ArrayList
+ jmethodID java_util_ArrayList_ = env->GetMethodID(class_ArrayList, "", "(I)V");
+ jmethodID java_util_ArrayList_add = env->GetMethodID(class_ArrayList, "add",
+ "(Ljava/lang/Object;)Z");
+ jobject result = env->NewObject(class_ArrayList, java_util_ArrayList_,
+ static_cast (transfers.size()));
+ // create Transfer objects and stick them in the List
+ for (const Monero::TransactionInfo::Transfer &s: transfers) {
+ jobject element = newTransferInstance(env, s.amount, s.address);
+ env->CallBooleanMethod(result, java_util_ArrayList_add, element);
+ env->DeleteLocalRef(element);
+ }
+ return result;
+}
+
+jobject newTransactionInfo(JNIEnv *env, Monero::TransactionInfo *info) {
+ jmethodID c = env->GetMethodID(class_TransactionInfo, "",
+ "(IZZJJJLjava/lang/String;JLjava/lang/String;IIJLjava/lang/String;Ljava/util/List;)V");
+ jobject transfers = newTransferList(env, info);
+ jstring _hash = env->NewStringUTF(info->hash().c_str());
+ jstring _paymentId = env->NewStringUTF(info->paymentId().c_str());
+ jstring _label = env->NewStringUTF(info->label().c_str());
+ uint32_t subaddrIndex = 0;
+ if (info->direction() == Monero::TransactionInfo::Direction_In)
+ subaddrIndex = *(info->subaddrIndex().begin());
+ jobject result = env->NewObject(class_TransactionInfo, c,
+ info->direction(),
+ info->isPending(),
+ info->isFailed(),
+ static_cast (info->amount()),
+ static_cast (info->fee()),
+ static_cast (info->blockHeight()),
+ _hash,
+ static_cast (info->timestamp()),
+ _paymentId,
+ static_cast (info->subaddrAccount()),
+ static_cast (subaddrIndex),
+ static_cast (info->confirmations()),
+ _label,
+ transfers);
+ env->DeleteLocalRef(transfers);
+ env->DeleteLocalRef(_hash);
+ env->DeleteLocalRef(_paymentId);
+ return result;
+}
+
+#include
+#include
+
+jobject cpp2java(JNIEnv *env, const std::vector &vector) {
+
+ jmethodID java_util_ArrayList_ = env->GetMethodID(class_ArrayList, "", "(I)V");
+ jmethodID java_util_ArrayList_add = env->GetMethodID(class_ArrayList, "add",
+ "(Ljava/lang/Object;)Z");
+
+ jobject arrayList = env->NewObject(class_ArrayList, java_util_ArrayList_,
+ static_cast (vector.size()));
+ for (Monero::TransactionInfo *s: vector) {
+ jobject info = newTransactionInfo(env, s);
+ env->CallBooleanMethod(arrayList, java_util_ArrayList_add, info);
+ env->DeleteLocalRef(info);
+ }
+ return arrayList;
+}
+
+JNIEXPORT jobject JNICALL
+Java_com_m2049r_xmrwallet_model_TransactionHistory_refreshJ(JNIEnv *env, jobject instance) {
+ Monero::TransactionHistory *history = getHandle(env,
+ instance);
+ history->refresh();
+ return cpp2java(env, history->getAll());
+}
+
+// TransactionInfo is implemented in Java - no need here
+
+JNIEXPORT jint JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getStatusJ(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return tx->status();
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getErrorString(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return env->NewStringUTF(tx->errorString().c_str());
+}
+
+// commit transaction or save to file if filename is provided.
+JNIEXPORT jboolean JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_commit(JNIEnv *env, jobject instance,
+ jstring filename, jboolean overwrite) {
+
+ const char *_filename = env->GetStringUTFChars(filename, nullptr);
+
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ bool success = tx->commit(_filename, overwrite);
+
+ env->ReleaseStringUTFChars(filename, _filename);
+ return static_cast(success);
+}
+
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getAmount(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return tx->amount();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getDust(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return tx->dust();
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getFee(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return tx->fee();
+}
+
+// TODO this returns a vector of strings - deal with this later - for now return first one
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getFirstTxIdJ(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ std::vector txids = tx->txid();
+ if (!txids.empty())
+ return env->NewStringUTF(txids.front().c_str());
+ else
+ return nullptr;
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_m2049r_xmrwallet_model_PendingTransaction_getTxCount(JNIEnv *env, jobject instance) {
+ Monero::PendingTransaction *tx = getHandle(env, instance);
+ return tx->txCount();
+}
+
+
+// these are all in Monero::Wallet - which I find wrong, so they are here!
+//static void init(const char *argv0, const char *default_log_base_name);
+//static void debug(const std::string &category, const std::string &str);
+//static void info(const std::string &category, const std::string &str);
+//static void warning(const std::string &category, const std::string &str);
+//static void error(const std::string &category, const std::string &str);
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_initLogger(JNIEnv *env, jclass clazz,
+ jstring argv0,
+ jstring default_log_base_name) {
+
+ const char *_argv0 = env->GetStringUTFChars(argv0, nullptr);
+ const char *_default_log_base_name = env->GetStringUTFChars(default_log_base_name, nullptr);
+
+ Monero::Wallet::init(_argv0, _default_log_base_name);
+
+ env->ReleaseStringUTFChars(argv0, _argv0);
+ env->ReleaseStringUTFChars(default_log_base_name, _default_log_base_name);
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_logDebug(JNIEnv *env, jclass clazz,
+ jstring category, jstring message) {
+
+ const char *_category = env->GetStringUTFChars(category, nullptr);
+ const char *_message = env->GetStringUTFChars(message, nullptr);
+
+ Monero::Wallet::debug(_category, _message);
+
+ env->ReleaseStringUTFChars(category, _category);
+ env->ReleaseStringUTFChars(message, _message);
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_logInfo(JNIEnv *env, jclass clazz,
+ jstring category, jstring message) {
+
+ const char *_category = env->GetStringUTFChars(category, nullptr);
+ const char *_message = env->GetStringUTFChars(message, nullptr);
+
+ Monero::Wallet::info(_category, _message);
+
+ env->ReleaseStringUTFChars(category, _category);
+ env->ReleaseStringUTFChars(message, _message);
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_logWarning(JNIEnv *env, jclass clazz,
+ jstring category, jstring message) {
+
+ const char *_category = env->GetStringUTFChars(category, nullptr);
+ const char *_message = env->GetStringUTFChars(message, nullptr);
+
+ Monero::Wallet::warning(_category, _message);
+
+ env->ReleaseStringUTFChars(category, _category);
+ env->ReleaseStringUTFChars(message, _message);
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_logError(JNIEnv *env, jclass clazz,
+ jstring category, jstring message) {
+
+ const char *_category = env->GetStringUTFChars(category, nullptr);
+ const char *_message = env->GetStringUTFChars(message, nullptr);
+
+ Monero::Wallet::error(_category, _message);
+
+ env->ReleaseStringUTFChars(category, _category);
+ env->ReleaseStringUTFChars(message, _message);
+}
+
+JNIEXPORT void JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_setLogLevel(JNIEnv *env, jclass clazz,
+ jint level) {
+ Monero::WalletManagerFactory::setLogLevel(level);
+}
+
+JNIEXPORT jstring JNICALL
+Java_com_m2049r_xmrwallet_model_WalletManager_moneroVersion(JNIEnv *env, jclass clazz) {
+ return env->NewStringUTF(MONERO_VERSION);
+}
+
+//
+// Ledger Stuff
+//
+
+/**
+ * @brief LedgerExchange - exchange data with Ledger Device
+ * @param command - buffer for data to send
+ * @param cmd_len - length of send to send
+ * @param response - buffer for received data
+ * @param max_resp_len - size of receive buffer
+ *
+ * @return length of received data in response or -1 if error
+ */
+int LedgerExchange(
+ unsigned char *command,
+ unsigned int cmd_len,
+ unsigned char *response,
+ unsigned int max_resp_len) {
+ LOGD("LedgerExchange");
+ JNIEnv *jenv;
+ int envStat = attachJVM(&jenv);
+ if (envStat == JNI_ERR) return -1;
+
+ jmethodID exchangeMethod = jenv->GetStaticMethodID(class_Ledger, "Exchange", "([B)[B");
+
+ jsize sendLen = static_cast(cmd_len);
+ jbyteArray dataSend = jenv->NewByteArray(sendLen);
+ jenv->SetByteArrayRegion(dataSend, 0, sendLen, (jbyte *) command);
+ jbyteArray dataRecv = (jbyteArray) jenv->CallStaticObjectMethod(class_Ledger, exchangeMethod,
+ dataSend);
+ jenv->DeleteLocalRef(dataSend);
+ if (dataRecv == nullptr) {
+ detachJVM(jenv, envStat);
+ LOGD("LedgerExchange SCARD_E_NO_READERS_AVAILABLE");
+ return -1;
+ }
+ jsize len = jenv->GetArrayLength(dataRecv);
+ LOGD("LedgerExchange SCARD_S_SUCCESS %u/%d", cmd_len, len);
+ if (len <= max_resp_len) {
+ jenv->GetByteArrayRegion(dataRecv, 0, len, (jbyte *) response);
+ jenv->DeleteLocalRef(dataRecv);
+ detachJVM(jenv, envStat);
+ return static_cast(len);;
+ } else {
+ jenv->DeleteLocalRef(dataRecv);
+ detachJVM(jenv, envStat);
+ LOGE("LedgerExchange SCARD_E_INSUFFICIENT_BUFFER");
+ return -1;
+ }
+}
+
+/**
+ * @brief LedgerFind - find Ledger Device and return it's name
+ * @param buffer - buffer for name of found device
+ * @param len - length of buffer
+ * @return 0 - success
+ * -1 - no device connected / found
+ * -2 - JVM not found
+ */
+int LedgerFind(char *buffer, size_t len) {
+ LOGD("LedgerName");
+ JNIEnv *jenv;
+ int envStat = attachJVM(&jenv);
+ if (envStat == JNI_ERR) return -2;
+
+ jmethodID nameMethod = jenv->GetStaticMethodID(class_Ledger, "Name", "()Ljava/lang/String;");
+ jstring name = (jstring) jenv->CallStaticObjectMethod(class_Ledger, nameMethod);
+
+ int ret;
+ if (name != nullptr) {
+ const char *_name = jenv->GetStringUTFChars(name, nullptr);
+ strncpy(buffer, _name, len);
+ jenv->ReleaseStringUTFChars(name, _name);
+ buffer[len - 1] = 0; // terminate in case _name is bigger
+ ret = 0;
+ LOGD("LedgerName is %s", buffer);
+ } else {
+ buffer[0] = 0;
+ ret = -1;
+ }
+
+ detachJVM(jenv, envStat);
+ return ret;
+}
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/app/src/main/cpp/monerujo.h b/app/src/main/cpp/monerujo.h
new file mode 100644
index 0000000..0fb3444
--- /dev/null
+++ b/app/src/main/cpp/monerujo.h
@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef XMRWALLET_WALLET_LIB_H
+#define XMRWALLET_WALLET_LIB_H
+
+#include
+
+/*
+#include
+
+#define LOG_TAG "[NDK]"
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,LOG_TAG,__VA_ARGS__)
+#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,LOG_TAG,__VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,LOG_TAG,__VA_ARGS__)
+*/
+
+jfieldID getHandleField(JNIEnv *env, jobject obj, const char *fieldName = "handle") {
+ jclass c = env->GetObjectClass(obj);
+ return env->GetFieldID(c, fieldName, "J"); // of type long
+}
+
+template
+T *getHandle(JNIEnv *env, jobject obj, const char *fieldName = "handle") {
+ jlong handle = env->GetLongField(obj, getHandleField(env, obj, fieldName));
+ return reinterpret_cast(handle);
+}
+
+void setHandleFromLong(JNIEnv *env, jobject obj, jlong handle) {
+ env->SetLongField(obj, getHandleField(env, obj), handle);
+}
+
+template
+void setHandle(JNIEnv *env, jobject obj, T *t) {
+ jlong handle = reinterpret_cast(t);
+ setHandleFromLong(env, obj, handle);
+}
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+extern const char* const MONERO_VERSION; // the actual monero core version
+
+// from monero-core crypto/hash-ops.h - avoid #including monero code here
+enum {
+ HASH_SIZE = 32,
+ HASH_DATA_AREA = 136
+};
+
+void cn_slow_hash(const void *data, size_t length, char *hash, int variant, int prehashed, uint64_t height);
+
+inline void slow_hash(const void *data, const size_t length, char *hash) {
+ cn_slow_hash(data, length, hash, 0 /*variant*/, 0 /*prehashed*/, 0 /*height*/);
+}
+
+inline void slow_hash_broken(const void *data, char *hash, int variant) {
+ cn_slow_hash(data, 200 /*sizeof(union hash_state)*/, hash, variant, 1 /*prehashed*/, 0 /*height*/);
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //XMRWALLET_WALLET_LIB_H
diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png
new file mode 100644
index 0000000..8047f23
Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ
diff --git a/app/src/main/java/com/btchip/BTChipException.java b/app/src/main/java/com/btchip/BTChipException.java
new file mode 100644
index 0000000..440a3ad
--- /dev/null
+++ b/app/src/main/java/com/btchip/BTChipException.java
@@ -0,0 +1,53 @@
+/*
+ *******************************************************************************
+ * BTChip Bitcoin Hardware Wallet Java API
+ * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ********************************************************************************
+ */
+
+package com.btchip;
+
+public class BTChipException extends Exception {
+
+ private static final long serialVersionUID = 5512803003827126405L;
+
+ public BTChipException(String reason) {
+ super(reason);
+ }
+
+ public BTChipException(String reason, Throwable cause) {
+ super(reason, cause);
+ }
+
+ public BTChipException(String reason, int sw) {
+ super(reason);
+ this.sw = sw;
+ }
+
+ public int getSW() {
+ return sw;
+ }
+
+ public String toString() {
+ if (sw == 0) {
+ return "BTChip Exception : " + getMessage();
+ } else {
+ return "BTChip Exception : " + getMessage() + " " + Integer.toHexString(sw);
+ }
+ }
+
+ private int sw;
+
+}
diff --git a/app/src/main/java/com/btchip/comm/BTChipTransport.java b/app/src/main/java/com/btchip/comm/BTChipTransport.java
new file mode 100644
index 0000000..cc55573
--- /dev/null
+++ b/app/src/main/java/com/btchip/comm/BTChipTransport.java
@@ -0,0 +1,31 @@
+/*
+ *******************************************************************************
+ * BTChip Bitcoin Hardware Wallet Java API
+ * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn
+ * (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ********************************************************************************
+ */
+
+package com.btchip.comm;
+
+import com.btchip.BTChipException;
+
+public interface BTChipTransport {
+ byte[] exchange(byte[] command);
+
+ void close();
+
+ void setDebug(boolean debugFlag);
+}
diff --git a/app/src/main/java/com/btchip/comm/LedgerHelper.java b/app/src/main/java/com/btchip/comm/LedgerHelper.java
new file mode 100644
index 0000000..db24899
--- /dev/null
+++ b/app/src/main/java/com/btchip/comm/LedgerHelper.java
@@ -0,0 +1,126 @@
+/*
+ *******************************************************************************
+ * BTChip Bitcoin Hardware Wallet Java API
+ * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn
+ * (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ********************************************************************************
+ */
+
+package com.btchip.comm;
+
+import java.io.ByteArrayOutputStream;
+
+public class LedgerHelper {
+
+ private static final int TAG_APDU = 0x05;
+
+ public static byte[] wrapCommandAPDU(int channel, byte[] command, int packetSize) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ if (packetSize < 3) {
+ throw new IllegalArgumentException("Can't handle Ledger framing with less than 3 bytes for the report");
+ }
+ int sequenceIdx = 0;
+ int offset = 0;
+ output.write(channel >> 8);
+ output.write(channel);
+ output.write(TAG_APDU);
+ output.write(sequenceIdx >> 8);
+ output.write(sequenceIdx);
+ sequenceIdx++;
+ output.write(command.length >> 8);
+ output.write(command.length);
+ int blockSize = (command.length > packetSize - 7 ? packetSize - 7 : command.length);
+ output.write(command, offset, blockSize);
+ offset += blockSize;
+ while (offset != command.length) {
+ output.write(channel >> 8);
+ output.write(channel);
+ output.write(TAG_APDU);
+ output.write(sequenceIdx >> 8);
+ output.write(sequenceIdx);
+ sequenceIdx++;
+ blockSize = (command.length - offset > packetSize - 5 ? packetSize - 5 : command.length - offset);
+ output.write(command, offset, blockSize);
+ offset += blockSize;
+ }
+ if ((output.size() % packetSize) != 0) {
+ byte[] padding = new byte[packetSize - (output.size() % packetSize)];
+ output.write(padding, 0, padding.length);
+ }
+ return output.toByteArray();
+ }
+
+ public static byte[] unwrapResponseAPDU(int channel, byte[] data, int packetSize) {
+ ByteArrayOutputStream response = new ByteArrayOutputStream();
+ int offset = 0;
+ int responseLength;
+ int sequenceIdx = 0;
+ if ((data == null) || (data.length < 7 + 5)) {
+ return null;
+ }
+ if (data[offset++] != (channel >> 8)) {
+ throw new IllegalArgumentException("Invalid channel");
+ }
+ if (data[offset++] != (channel & 0xff)) {
+ throw new IllegalArgumentException("Invalid channel");
+ }
+ if (data[offset++] != TAG_APDU) {
+ throw new IllegalArgumentException("Invalid tag");
+ }
+ if (data[offset++] != 0x00) {
+ throw new IllegalArgumentException("Invalid sequence");
+ }
+ if (data[offset++] != 0x00) {
+ throw new IllegalArgumentException("Invalid sequence");
+ }
+ responseLength = ((data[offset++] & 0xff) << 8);
+ responseLength |= (data[offset++] & 0xff);
+ if (data.length < 7 + responseLength) {
+ return null;
+ }
+ int blockSize = (responseLength > packetSize - 7 ? packetSize - 7 : responseLength);
+ response.write(data, offset, blockSize);
+ offset += blockSize;
+ while (response.size() != responseLength) {
+ sequenceIdx++;
+ if (offset == data.length) {
+ return null;
+ }
+ if (data[offset++] != (channel >> 8)) {
+ throw new IllegalArgumentException("Invalid channel");
+ }
+ if (data[offset++] != (channel & 0xff)) {
+ throw new IllegalArgumentException("Invalid channel");
+ }
+ if (data[offset++] != TAG_APDU) {
+ throw new IllegalArgumentException("Invalid tag");
+ }
+ if (data[offset++] != (sequenceIdx >> 8)) {
+ throw new IllegalArgumentException("Invalid sequence");
+ }
+ if (data[offset++] != (sequenceIdx & 0xff)) {
+ throw new IllegalArgumentException("Invalid sequence");
+ }
+ blockSize = (responseLength - response.size() > packetSize - 5 ? packetSize - 5 : responseLength - response.size());
+ if (blockSize > data.length - offset) {
+ return null;
+ }
+ response.write(data, offset, blockSize);
+ offset += blockSize;
+ }
+ return response.toByteArray();
+ }
+
+}
diff --git a/app/src/main/java/com/btchip/comm/android/BTChipTransportAndroidHID.java b/app/src/main/java/com/btchip/comm/android/BTChipTransportAndroidHID.java
new file mode 100644
index 0000000..37c4f49
--- /dev/null
+++ b/app/src/main/java/com/btchip/comm/android/BTChipTransportAndroidHID.java
@@ -0,0 +1,149 @@
+/*
+ *******************************************************************************
+ * BTChip Bitcoin Hardware Wallet Java API
+ * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn
+ * (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ********************************************************************************
+ */
+
+package com.btchip.comm.android;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.hardware.usb.UsbManager;
+import android.hardware.usb.UsbRequest;
+
+import com.btchip.BTChipException;
+import com.btchip.comm.BTChipTransport;
+import com.btchip.comm.LedgerHelper;
+import com.btchip.utils.Dump;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+
+import timber.log.Timber;
+
+public class BTChipTransportAndroidHID implements BTChipTransport {
+
+ public static UsbDevice getDevice(UsbManager manager) {
+ HashMap deviceList = manager.getDeviceList();
+ for (UsbDevice device : deviceList.values()) {
+ Timber.d("%04X:%04X %s, %s", device.getVendorId(), device.getProductId(), device.getManufacturerName(), device.getProductName());
+ if (device.getVendorId() == VID) {
+ final int deviceProductId = device.getProductId();
+ for (int pid : PID_HIDS) {
+ if (deviceProductId == pid)
+ return device;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static BTChipTransport open(UsbManager manager, UsbDevice device) throws IOException {
+ UsbDeviceConnection connection = manager.openDevice(device);
+ if (connection == null) throw new IOException("Device not connected");
+ // Must only be called once permission is granted (see http://developer.android.com/reference/android/hardware/usb/UsbManager.html)
+ // Important if enumerating, rather than being awaken by the intent notification
+ UsbInterface dongleInterface = device.getInterface(0);
+ UsbEndpoint in = null;
+ UsbEndpoint out = null;
+ for (int i = 0; i < dongleInterface.getEndpointCount(); i++) {
+ UsbEndpoint tmpEndpoint = dongleInterface.getEndpoint(i);
+ if (tmpEndpoint.getDirection() == UsbConstants.USB_DIR_IN) {
+ in = tmpEndpoint;
+ } else {
+ out = tmpEndpoint;
+ }
+ }
+ connection.claimInterface(dongleInterface, true);
+ return new BTChipTransportAndroidHID(connection, dongleInterface, in, out);
+ }
+
+ private static final int VID = 0x2C97;
+ private static final int[] PID_HIDS = {0x0001, 0x0004, 0x0005};
+
+ private UsbDeviceConnection connection;
+ private UsbInterface dongleInterface;
+ private UsbEndpoint in;
+ private UsbEndpoint out;
+ private byte transferBuffer[];
+ private boolean debug;
+
+ public BTChipTransportAndroidHID(UsbDeviceConnection connection, UsbInterface dongleInterface, UsbEndpoint in, UsbEndpoint out) {
+ this.connection = connection;
+ this.dongleInterface = dongleInterface;
+ this.in = in;
+ this.out = out;
+ transferBuffer = new byte[HID_BUFFER_SIZE];
+ }
+
+ @Override
+ public byte[] exchange(byte[] command) {
+ ByteArrayOutputStream response = new ByteArrayOutputStream();
+ byte[] responseData = null;
+ int offset = 0;
+ if (debug) {
+ Timber.d("=> %s", Dump.dump(command));
+ }
+ command = LedgerHelper.wrapCommandAPDU(LEDGER_DEFAULT_CHANNEL, command, HID_BUFFER_SIZE);
+ UsbRequest requestOut = new UsbRequest();
+ requestOut.initialize(connection, out);
+ while (offset != command.length) {
+ int blockSize = (command.length - offset > HID_BUFFER_SIZE ? HID_BUFFER_SIZE : command.length - offset);
+ System.arraycopy(command, offset, transferBuffer, 0, blockSize);
+ requestOut.queue(ByteBuffer.wrap(transferBuffer), HID_BUFFER_SIZE);
+ connection.requestWait();
+ offset += blockSize;
+ }
+ requestOut.close();
+ ByteBuffer responseBuffer = ByteBuffer.allocate(HID_BUFFER_SIZE);
+ UsbRequest requestIn = new UsbRequest();
+ requestIn.initialize(connection, in);
+ while ((responseData = LedgerHelper.unwrapResponseAPDU(LEDGER_DEFAULT_CHANNEL, response.toByteArray(), HID_BUFFER_SIZE)) == null) {
+ responseBuffer.clear();
+ requestIn.queue(responseBuffer, HID_BUFFER_SIZE);
+ connection.requestWait();
+ responseBuffer.rewind();
+ responseBuffer.get(transferBuffer, 0, HID_BUFFER_SIZE);
+ response.write(transferBuffer, 0, HID_BUFFER_SIZE);
+ }
+ requestIn.close();
+ if (debug) {
+ Timber.d("<= %s", Dump.dump(responseData));
+ }
+ return responseData;
+ }
+
+ @Override
+ public void close() {
+ connection.releaseInterface(dongleInterface);
+ connection.close();
+ }
+
+ @Override
+ public void setDebug(boolean debugFlag) {
+ this.debug = debugFlag;
+ }
+
+ private static final int HID_BUFFER_SIZE = 64;
+ private static final int LEDGER_DEFAULT_CHANNEL = 1;
+ private static final int SW1_DATA_AVAILABLE = 0x61;
+}
diff --git a/app/src/main/java/com/btchip/utils/Dump.java b/app/src/main/java/com/btchip/utils/Dump.java
new file mode 100644
index 0000000..2d453fc
--- /dev/null
+++ b/app/src/main/java/com/btchip/utils/Dump.java
@@ -0,0 +1,62 @@
+/*
+ *******************************************************************************
+ * BTChip Bitcoin Hardware Wallet Java API
+ * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ********************************************************************************
+ */
+
+package com.btchip.utils;
+
+import java.io.ByteArrayOutputStream;
+
+public class Dump {
+
+ public static String dump(byte[] buffer, int offset, int length) {
+ String result = "";
+ for (int i = 0; i < length; i++) {
+ String temp = Integer.toHexString((buffer[offset + i]) & 0xff);
+ if (temp.length() < 2) {
+ temp = "0" + temp;
+ }
+ result += temp;
+ }
+ return result;
+ }
+
+ public static String dump(byte[] buffer) {
+ return dump(buffer, 0, buffer.length);
+ }
+
+ public static byte[] hexToBin(String src) {
+ ByteArrayOutputStream result = new ByteArrayOutputStream();
+ int i = 0;
+ while (i < src.length()) {
+ char x = src.charAt(i);
+ if (!((x >= '0' && x <= '9') || (x >= 'A' && x <= 'F') || (x >= 'a' && x <= 'f'))) {
+ i++;
+ continue;
+ }
+ try {
+ result.write(Integer.valueOf("" + src.charAt(i) + src.charAt(i + 1), 16));
+ i += 2;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+ return result.toByteArray();
+ }
+
+
+}
diff --git a/app/src/main/java/com/m2049r/levin/data/Bucket.java b/app/src/main/java/com/m2049r/levin/data/Bucket.java
new file mode 100644
index 0000000..fc9b571
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/data/Bucket.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.data;
+
+import com.m2049r.levin.util.HexHelper;
+import com.m2049r.levin.util.LevinReader;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+
+public class Bucket {
+
+ // constants copied from monero p2p & epee
+
+ public final static int P2P_COMMANDS_POOL_BASE = 1000;
+ public final static int COMMAND_HANDSHAKE_ID = P2P_COMMANDS_POOL_BASE + 1;
+ public final static int COMMAND_TIMED_SYNC_ID = P2P_COMMANDS_POOL_BASE + 2;
+ public final static int COMMAND_PING_ID = P2P_COMMANDS_POOL_BASE + 3;
+ public final static int COMMAND_REQUEST_STAT_INFO_ID = P2P_COMMANDS_POOL_BASE + 4;
+ public final static int COMMAND_REQUEST_NETWORK_STATE_ID = P2P_COMMANDS_POOL_BASE + 5;
+ public final static int COMMAND_REQUEST_PEER_ID_ID = P2P_COMMANDS_POOL_BASE + 6;
+ public final static int COMMAND_REQUEST_SUPPORT_FLAGS_ID = P2P_COMMANDS_POOL_BASE + 7;
+
+ public final static long LEVIN_SIGNATURE = 0x0101010101012101L; // Bender's nightmare
+
+ public final static long LEVIN_DEFAULT_MAX_PACKET_SIZE = 100000000; // 100MB by default
+
+ public final static int LEVIN_PACKET_REQUEST = 0x00000001;
+ public final static int LEVIN_PACKET_RESPONSE = 0x00000002;
+
+ public final static int LEVIN_PROTOCOL_VER_0 = 0;
+ public final static int LEVIN_PROTOCOL_VER_1 = 1;
+
+ public final static int LEVIN_OK = 0;
+ public final static int LEVIN_ERROR_CONNECTION = -1;
+ public final static int LEVIN_ERROR_CONNECTION_NOT_FOUND = -2;
+ public final static int LEVIN_ERROR_CONNECTION_DESTROYED = -3;
+ public final static int LEVIN_ERROR_CONNECTION_TIMEDOUT = -4;
+ public final static int LEVIN_ERROR_CONNECTION_NO_DUPLEX_PROTOCOL = -5;
+ public final static int LEVIN_ERROR_CONNECTION_HANDLER_NOT_DEFINED = -6;
+ public final static int LEVIN_ERROR_FORMAT = -7;
+
+ public final static int P2P_SUPPORT_FLAG_FLUFFY_BLOCKS = 0x01;
+ public final static int P2P_SUPPORT_FLAGS = P2P_SUPPORT_FLAG_FLUFFY_BLOCKS;
+
+ final private long signature;
+ final private long cb;
+ final public boolean haveToReturnData;
+ final public int command;
+ final public int returnCode;
+ final private int flags;
+ final private int protcolVersion;
+ final byte[] payload;
+
+ final public Section payloadSection;
+
+ // create a request
+ public Bucket(int command, byte[] payload) throws IOException {
+ this.signature = LEVIN_SIGNATURE;
+ this.cb = payload.length;
+ this.haveToReturnData = true;
+ this.command = command;
+ this.returnCode = 0;
+ this.flags = LEVIN_PACKET_REQUEST;
+ this.protcolVersion = LEVIN_PROTOCOL_VER_1;
+ this.payload = payload;
+ payloadSection = LevinReader.readPayload(payload);
+ }
+
+ // create a response
+ public Bucket(int command, byte[] payload, int rc) throws IOException {
+ this.signature = LEVIN_SIGNATURE;
+ this.cb = payload.length;
+ this.haveToReturnData = false;
+ this.command = command;
+ this.returnCode = rc;
+ this.flags = LEVIN_PACKET_RESPONSE;
+ this.protcolVersion = LEVIN_PROTOCOL_VER_1;
+ this.payload = payload;
+ payloadSection = LevinReader.readPayload(payload);
+ }
+
+ public Bucket(DataInput in) throws IOException {
+ signature = in.readLong();
+ cb = in.readLong();
+ haveToReturnData = in.readBoolean();
+ command = in.readInt();
+ returnCode = in.readInt();
+ flags = in.readInt();
+ protcolVersion = in.readInt();
+
+ if (signature == Bucket.LEVIN_SIGNATURE) {
+ if (cb > Integer.MAX_VALUE)
+ throw new IllegalArgumentException();
+ payload = new byte[(int) cb];
+ in.readFully(payload);
+ } else
+ throw new IllegalStateException();
+ payloadSection = LevinReader.readPayload(payload);
+ }
+
+ public Section getPayloadSection() {
+ return payloadSection;
+ }
+
+ public void send(DataOutput out) throws IOException {
+ out.writeLong(signature);
+ out.writeLong(cb);
+ out.writeBoolean(haveToReturnData);
+ out.writeInt(command);
+ out.writeInt(returnCode);
+ out.writeInt(flags);
+ out.writeInt(protcolVersion);
+ out.write(payload);
+ }
+
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("sig: ").append(signature).append("\n");
+ sb.append("cb: ").append(cb).append("\n");
+ sb.append("call: ").append(haveToReturnData).append("\n");
+ sb.append("cmd: ").append(command).append("\n");
+ sb.append("rc: ").append(returnCode).append("\n");
+ sb.append("flags:").append(flags).append("\n");
+ sb.append("proto:").append(protcolVersion).append("\n");
+ sb.append(HexHelper.bytesToHex(payload)).append("\n");
+ sb.append(payloadSection.toString());
+ return sb.toString();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/data/Section.java b/app/src/main/java/com/m2049r/levin/data/Section.java
new file mode 100644
index 0000000..9be9732
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/data/Section.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.data;
+
+import com.m2049r.levin.util.HexHelper;
+import com.m2049r.levin.util.LevinReader;
+import com.m2049r.levin.util.LevinWriter;
+import com.m2049r.levin.util.LittleEndianDataOutputStream;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutput;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class Section {
+
+ // constants copied from monero p2p & epee
+
+ static final public int PORTABLE_STORAGE_SIGNATUREA = 0x01011101;
+ static final public int PORTABLE_STORAGE_SIGNATUREB = 0x01020101;
+
+ static final public byte PORTABLE_STORAGE_FORMAT_VER = 1;
+
+ static final public byte PORTABLE_RAW_SIZE_MARK_MASK = 0x03;
+ static final public byte PORTABLE_RAW_SIZE_MARK_BYTE = 0;
+ static final public byte PORTABLE_RAW_SIZE_MARK_WORD = 1;
+ static final public byte PORTABLE_RAW_SIZE_MARK_DWORD = 2;
+ static final public byte PORTABLE_RAW_SIZE_MARK_INT64 = 3;
+
+ static final long MAX_STRING_LEN_POSSIBLE = 2000000000; // do not let string be so big
+
+ // data types
+ static final public byte SERIALIZE_TYPE_INT64 = 1;
+ static final public byte SERIALIZE_TYPE_INT32 = 2;
+ static final public byte SERIALIZE_TYPE_INT16 = 3;
+ static final public byte SERIALIZE_TYPE_INT8 = 4;
+ static final public byte SERIALIZE_TYPE_UINT64 = 5;
+ static final public byte SERIALIZE_TYPE_UINT32 = 6;
+ static final public byte SERIALIZE_TYPE_UINT16 = 7;
+ static final public byte SERIALIZE_TYPE_UINT8 = 8;
+ static final public byte SERIALIZE_TYPE_DUOBLE = 9;
+ static final public byte SERIALIZE_TYPE_STRING = 10;
+ static final public byte SERIALIZE_TYPE_BOOL = 11;
+ static final public byte SERIALIZE_TYPE_OBJECT = 12;
+ static final public byte SERIALIZE_TYPE_ARRAY = 13;
+
+ static final public byte SERIALIZE_FLAG_ARRAY = (byte) 0x80;
+
+ private final Map entries = new HashMap();
+
+ public void add(String key, Object entry) {
+ entries.put(key, entry);
+ }
+
+ public int size() {
+ return entries.size();
+ }
+
+ public Set> entrySet() {
+ return entries.entrySet();
+ }
+
+ public Object get(String key) {
+ return entries.get(key);
+ }
+
+ public String toString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ for (Map.Entry entry : entries.entrySet()) {
+ sb.append(entry.getKey()).append("=");
+ final Object value = entry.getValue();
+ if (value instanceof List) {
+ @SuppressWarnings("unchecked") final List list = (List) value;
+ for (Object listEntry : list) {
+ sb.append(listEntry.toString()).append("\n");
+ }
+ } else if (value instanceof String) {
+ sb.append("(").append(value).append(")\n");
+ } else if (value instanceof byte[]) {
+ sb.append(HexHelper.bytesToHex((byte[]) value)).append("\n");
+ } else {
+ sb.append(value.toString()).append("\n");
+ }
+ }
+ return sb.toString();
+ }
+
+ static public Section fromByteArray(byte[] buffer) {
+ try {
+ return LevinReader.readPayload(buffer);
+ } catch (IOException ex) {
+ throw new IllegalStateException();
+ }
+ }
+
+ public byte[] asByteArray() {
+ try {
+ ByteArrayOutputStream bas = new ByteArrayOutputStream();
+ DataOutput out = new LittleEndianDataOutputStream(bas);
+ LevinWriter writer = new LevinWriter(out);
+ writer.writePayload(this);
+ return bas.toByteArray();
+ } catch (IOException ex) {
+ throw new IllegalStateException();
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/scanner/Dispatcher.java b/app/src/main/java/com/m2049r/levin/scanner/Dispatcher.java
new file mode 100644
index 0000000..b9f8be5
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/scanner/Dispatcher.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.scanner;
+
+import com.m2049r.xmrwallet.data.NodeInfo;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import timber.log.Timber;
+
+public class Dispatcher implements PeerRetriever.OnGetPeers {
+ static final public int NUM_THREADS = 50;
+ static final public int MAX_PEERS = 1000;
+ static final public long MAX_TIME = 30000000000L; //30 seconds
+
+ private int peerCount = 0;
+ final private Set knownNodes = new HashSet<>(); // set of nodes to test
+ final private Set rpcNodes = new HashSet<>(); // set of RPC nodes we like
+ final private ExecutorService exeService = Executors.newFixedThreadPool(NUM_THREADS);
+
+ public interface Listener {
+ void onGet(NodeInfo nodeInfo);
+ }
+
+ private Listener listener;
+
+ public Dispatcher(Listener listener) {
+ this.listener = listener;
+ }
+
+ public Set getRpcNodes() {
+ return rpcNodes;
+ }
+
+ public int getPeerCount() {
+ return peerCount;
+ }
+
+ public boolean getMorePeers() {
+ return peerCount < MAX_PEERS;
+ }
+
+ public void awaitTermination(int nodesToFind) {
+ try {
+ final long t = System.nanoTime();
+ while (!jobs.isEmpty()) {
+ try {
+ Timber.d("Remaining jobs %d", jobs.size());
+ final PeerRetriever retrievedPeer = jobs.poll().get();
+ if (retrievedPeer.isGood() && getMorePeers())
+ retrievePeers(retrievedPeer);
+ final NodeInfo nodeInfo = retrievedPeer.getNodeInfo();
+ Timber.d("Retrieved %s", nodeInfo);
+ if ((nodeInfo.isValid() || nodeInfo.isFavourite())) {
+ nodeInfo.setDefaultName();
+ rpcNodes.add(nodeInfo);
+ Timber.d("RPC: %s", nodeInfo);
+ // the following is not totally correct but it works (otherwise we need to
+ // load much more before filtering - but we don't have time
+ if (listener != null) listener.onGet(nodeInfo);
+ if (rpcNodes.size() >= nodesToFind) {
+ Timber.d("are we done here?");
+ filterRpcNodes();
+ if (rpcNodes.size() >= nodesToFind) {
+ Timber.d("we're done here");
+ break;
+ }
+ }
+ }
+ if (System.nanoTime() - t > MAX_TIME) break; // watchdog
+ } catch (ExecutionException ex) {
+ Timber.d(ex); // tell us about it and continue
+ }
+ }
+ } catch (InterruptedException ex) {
+ Timber.d(ex);
+ } finally {
+ Timber.d("Shutting down!");
+ exeService.shutdownNow();
+ try {
+ exeService.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);
+ } catch (InterruptedException ex) {
+ Timber.d(ex);
+ }
+ }
+ filterRpcNodes();
+ }
+
+ static final public int HEIGHT_WINDOW = 1;
+
+ private boolean testHeight(long height, long consensus) {
+ return (height >= (consensus - HEIGHT_WINDOW))
+ && (height <= (consensus + HEIGHT_WINDOW));
+ }
+
+ private long calcConsensusHeight() {
+ Timber.d("Calc Consensus height from %d nodes", rpcNodes.size());
+ final Map nodeHeights = new TreeMap();
+ for (NodeInfo info : rpcNodes) {
+ if (!info.isValid()) continue;
+ Integer h = nodeHeights.get(info.getHeight());
+ if (h == null)
+ h = 0;
+ nodeHeights.put(info.getHeight(), h + 1);
+ }
+ long consensusHeight = 0;
+ long consensusCount = 0;
+ for (Map.Entry entry : nodeHeights.entrySet()) {
+ final long entryHeight = entry.getKey();
+ int count = 0;
+ for (long i = entryHeight - HEIGHT_WINDOW; i <= entryHeight + HEIGHT_WINDOW; i++) {
+ Integer v = nodeHeights.get(i);
+ if (v == null)
+ v = 0;
+ count += v;
+ }
+ if (count >= consensusCount) {
+ consensusCount = count;
+ consensusHeight = entryHeight;
+ }
+ Timber.d("%d - %d/%d", entryHeight, count, entry.getValue());
+ }
+ return consensusHeight;
+ }
+
+ private void filterRpcNodes() {
+ long consensus = calcConsensusHeight();
+ Timber.d("Consensus Height = %d for %d nodes", consensus, rpcNodes.size());
+ for (Iterator iter = rpcNodes.iterator(); iter.hasNext(); ) {
+ NodeInfo info = iter.next();
+ // don't remove favourites
+ if (!info.isFavourite()) {
+ if (!testHeight(info.getHeight(), consensus)) {
+ iter.remove();
+ Timber.d("Removed %s", info);
+ }
+ }
+ }
+ }
+
+ // TODO: does this NEED to be a ConcurrentLinkedDeque?
+ private ConcurrentLinkedDeque> jobs = new ConcurrentLinkedDeque<>();
+
+ private void retrievePeer(NodeInfo nodeInfo) {
+ if (knownNodes.add(nodeInfo)) {
+ Timber.d("\t%d:%s", knownNodes.size(), nodeInfo);
+ jobs.add(exeService.submit(new PeerRetriever(nodeInfo, this)));
+ peerCount++; // jobs.size() does not perform well
+ }
+ }
+
+ private void retrievePeers(PeerRetriever peer) {
+ for (LevinPeer levinPeer : peer.getPeers()) {
+ if (getMorePeers())
+ retrievePeer(new NodeInfo(levinPeer));
+ else
+ break;
+ }
+ }
+
+ public void seedPeers(Collection seedNodes) {
+ for (NodeInfo node : seedNodes) {
+ if (node.isFavourite()) {
+ rpcNodes.add(node);
+ if (listener != null) listener.onGet(node);
+ }
+ retrievePeer(node);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/scanner/LevinPeer.java b/app/src/main/java/com/m2049r/levin/scanner/LevinPeer.java
new file mode 100644
index 0000000..aba2fa8
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/scanner/LevinPeer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.scanner;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+
+public class LevinPeer {
+ final public InetSocketAddress socketAddress;
+ final public int version;
+ final public long height;
+ final public String top;
+
+
+ public InetSocketAddress getSocketAddress() {
+ return socketAddress;
+ }
+
+ LevinPeer(InetAddress address, int port, int version, long height, String top) {
+ this.socketAddress = new InetSocketAddress(address, port);
+ this.version = version;
+ this.height = height;
+ this.top = top;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/scanner/PeerRetriever.java b/app/src/main/java/com/m2049r/levin/scanner/PeerRetriever.java
new file mode 100644
index 0000000..fcdc4a0
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/scanner/PeerRetriever.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.scanner;
+
+import com.m2049r.levin.data.Bucket;
+import com.m2049r.levin.data.Section;
+import com.m2049r.levin.util.HexHelper;
+import com.m2049r.levin.util.LittleEndianDataInputStream;
+import com.m2049r.levin.util.LittleEndianDataOutputStream;
+import com.m2049r.xmrwallet.data.NodeInfo;
+import com.m2049r.xmrwallet.util.Helper;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Callable;
+
+import timber.log.Timber;
+
+public class PeerRetriever implements Callable {
+ static final public int CONNECT_TIMEOUT = 500; //ms
+ static final public int SOCKET_TIMEOUT = 500; //ms
+ static final public long PEER_ID = new Random().nextLong();
+ static final private byte[] HANDSHAKE = handshakeRequest().asByteArray();
+ static final private byte[] FLAGS_RESP = flagsResponse().asByteArray();
+
+ final private List peers = new ArrayList<>();
+
+ private NodeInfo nodeInfo;
+ private OnGetPeers onGetPeersCallback;
+
+ public interface OnGetPeers {
+ boolean getMorePeers();
+ }
+
+ public PeerRetriever(NodeInfo nodeInfo, OnGetPeers onGetPeers) {
+ this.nodeInfo = nodeInfo;
+ this.onGetPeersCallback = onGetPeers;
+ }
+
+ public NodeInfo getNodeInfo() {
+ return nodeInfo;
+ }
+
+ public boolean isGood() {
+ return !peers.isEmpty();
+ }
+
+ public List getPeers() {
+ return peers;
+ }
+
+ public PeerRetriever call() {
+ if (isGood()) // we have already been called?
+ throw new IllegalStateException();
+ // first check for an rpc service
+ nodeInfo.findRpcService();
+ if (onGetPeersCallback.getMorePeers())
+ try {
+ Timber.d("%s CONN", nodeInfo.getLevinSocketAddress());
+ if (!connect())
+ return this;
+ Bucket handshakeBucket = new Bucket(Bucket.COMMAND_HANDSHAKE_ID, HANDSHAKE);
+ handshakeBucket.send(getDataOutput());
+
+ while (true) {// wait for response (which may never come)
+ Bucket recv = new Bucket(getDataInput()); // times out after SOCKET_TIMEOUT
+ if ((recv.command == Bucket.COMMAND_HANDSHAKE_ID)
+ && (!recv.haveToReturnData)) {
+ readAddressList(recv.payloadSection);
+ return this;
+ } else if ((recv.command == Bucket.COMMAND_REQUEST_SUPPORT_FLAGS_ID)
+ && (recv.haveToReturnData)) {
+ Bucket flagsBucket = new Bucket(Bucket.COMMAND_REQUEST_SUPPORT_FLAGS_ID, FLAGS_RESP, 1);
+ flagsBucket.send(getDataOutput());
+ } else {// and ignore others
+ Timber.d("Ignored LEVIN COMMAND %d", recv.command);
+ }
+ }
+ } catch (IOException ex) {
+ } finally {
+ disconnect(); // we have what we want - byebye
+ Timber.d("%s DISCONN", nodeInfo.getLevinSocketAddress());
+ }
+ return this;
+ }
+
+ private void readAddressList(Section section) {
+ Section data = (Section) section.get("payload_data");
+ int topVersion = (Integer) data.get("top_version");
+ long currentHeight = (Long) data.get("current_height");
+ String topId = HexHelper.bytesToHex((byte[]) data.get("top_id"));
+ Timber.d("PAYLOAD_DATA %d/%d/%s", topVersion, currentHeight, topId);
+
+ @SuppressWarnings("unchecked")
+ List peerList = (List) section.get("local_peerlist_new");
+ if (peerList != null) {
+ for (Section peer : peerList) {
+ Section adr = (Section) peer.get("adr");
+ Integer type = (Integer) adr.get("type");
+ if ((type == null) || (type != 1))
+ continue;
+ Section addr = (Section) adr.get("addr");
+ if (addr == null)
+ continue;
+ Integer ip = (Integer) addr.get("m_ip");
+ if (ip == null)
+ continue;
+ Integer sport = (Integer) addr.get("m_port");
+ if (sport == null)
+ continue;
+ int port = sport;
+ if (port < 0) // port is unsigned
+ port = port + 0x10000;
+ InetAddress inet = HexHelper.toInetAddress(ip);
+ // make sure this is an address we want to talk to (i.e. a remote address)
+ if (!inet.isSiteLocalAddress() && !inet.isAnyLocalAddress()
+ && !inet.isLoopbackAddress()
+ && !inet.isMulticastAddress()
+ && !inet.isLinkLocalAddress()) {
+ peers.add(new LevinPeer(inet, port, topVersion, currentHeight, topId));
+ }
+ }
+ }
+ }
+
+ private Socket socket = null;
+
+ private boolean connect() {
+ if (socket != null) throw new IllegalStateException();
+ try {
+ socket = new Socket();
+ socket.connect(nodeInfo.getLevinSocketAddress(), CONNECT_TIMEOUT);
+ socket.setSoTimeout(SOCKET_TIMEOUT);
+ } catch (IOException ex) {
+ //Timber.d(ex);
+ return false;
+ }
+ return true;
+ }
+
+ private boolean isConnected() {
+ return socket.isConnected();
+ }
+
+ private void disconnect() {
+ try {
+ dataInput = null;
+ dataOutput = null;
+ if ((socket != null) && (!socket.isClosed())) {
+ socket.close();
+ }
+ } catch (IOException ex) {
+ Timber.d(ex);
+ } finally {
+ socket = null;
+ }
+ }
+
+ private DataOutput dataOutput = null;
+
+ private DataOutput getDataOutput() throws IOException {
+ if (dataOutput == null)
+ synchronized (this) {
+ if (dataOutput == null)
+ dataOutput = new LittleEndianDataOutputStream(
+ socket.getOutputStream());
+ }
+ return dataOutput;
+ }
+
+ private DataInput dataInput = null;
+
+ private DataInput getDataInput() throws IOException {
+ if (dataInput == null)
+ synchronized (this) {
+ if (dataInput == null)
+ dataInput = new LittleEndianDataInputStream(
+ socket.getInputStream());
+ }
+ return dataInput;
+ }
+
+ static private Section handshakeRequest() {
+ Section section = new Section(); // root object
+
+ Section nodeData = new Section();
+ nodeData.add("local_time", (new Date()).getTime());
+ nodeData.add("my_port", 0);
+ byte[] networkId = Helper.hexToBytes("1230f171610441611731008216a1a110"); // mainnet
+ nodeData.add("network_id", networkId);
+ nodeData.add("peer_id", PEER_ID);
+ section.add("node_data", nodeData);
+
+ Section payloadData = new Section();
+ payloadData.add("cumulative_difficulty", 1L);
+ payloadData.add("current_height", 1L);
+ byte[] genesisHash =
+ Helper.hexToBytes("418015bb9ae982a1975da7d79277c2705727a56894ba0fb246adaabb1f4632e3");
+ payloadData.add("top_id", genesisHash);
+ payloadData.add("top_version", (byte) 1);
+ section.add("payload_data", payloadData);
+ return section;
+ }
+
+ static private Section flagsResponse() {
+ Section section = new Section(); // root object
+ section.add("support_flags", Bucket.P2P_SUPPORT_FLAGS);
+ return section;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/util/HexHelper.java b/app/src/main/java/com/m2049r/levin/util/HexHelper.java
new file mode 100644
index 0000000..3c26527
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/util/HexHelper.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.util;
+
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+public class HexHelper {
+
+ static public String bytesToHex(byte[] data) {
+ if ((data != null) && (data.length > 0))
+ return String.format("%0" + (data.length * 2) + "X",
+ new BigInteger(1, data));
+ else
+ return "";
+ }
+
+ static public InetAddress toInetAddress(int ip) {
+ try {
+ String ipAddress = String.format("%d.%d.%d.%d", (ip & 0xff),
+ (ip >> 8 & 0xff), (ip >> 16 & 0xff), (ip >> 24 & 0xff));
+ return InetAddress.getByName(ipAddress);
+ } catch (UnknownHostException ex) {
+ throw new IllegalArgumentException(ex);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/util/LevinReader.java b/app/src/main/java/com/m2049r/levin/util/LevinReader.java
new file mode 100644
index 0000000..fd67ca5
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/util/LevinReader.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.util;
+
+import com.m2049r.levin.data.Section;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInput;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+// Full Levin reader as seen on epee
+
+public class LevinReader {
+ private DataInput in;
+
+ private LevinReader(byte[] buffer) {
+ ByteArrayInputStream bis = new ByteArrayInputStream(buffer);
+ in = new LittleEndianDataInputStream(bis);
+ }
+
+ static public Section readPayload(byte[] payload) throws IOException {
+ LevinReader r = new LevinReader(payload);
+ return r.readPayload();
+ }
+
+ private Section readPayload() throws IOException {
+ if (in.readInt() != Section.PORTABLE_STORAGE_SIGNATUREA)
+ throw new IllegalStateException();
+ if (in.readInt() != Section.PORTABLE_STORAGE_SIGNATUREB)
+ throw new IllegalStateException();
+ if (in.readByte() != Section.PORTABLE_STORAGE_FORMAT_VER)
+ throw new IllegalStateException();
+ return readSection();
+ }
+
+ private Section readSection() throws IOException {
+ Section section = new Section();
+ long count = readVarint();
+ while (count-- > 0) {
+ // read section name string
+ String sectionName = readSectionName();
+ section.add(sectionName, loadStorageEntry());
+ }
+ return section;
+ }
+
+ private Object loadStorageArrayEntry(int type) throws IOException {
+ type &= ~Section.SERIALIZE_FLAG_ARRAY;
+ return readArrayEntry(type);
+ }
+
+ private List readArrayEntry(int type) throws IOException {
+ List list = new ArrayList();
+ long size = readVarint();
+ while (size-- > 0)
+ list.add(read(type));
+ return list;
+ }
+
+ private Object read(int type) throws IOException {
+ switch (type) {
+ case Section.SERIALIZE_TYPE_UINT64:
+ case Section.SERIALIZE_TYPE_INT64:
+ return in.readLong();
+ case Section.SERIALIZE_TYPE_UINT32:
+ case Section.SERIALIZE_TYPE_INT32:
+ return in.readInt();
+ case Section.SERIALIZE_TYPE_UINT16:
+ return in.readUnsignedShort();
+ case Section.SERIALIZE_TYPE_INT16:
+ return in.readShort();
+ case Section.SERIALIZE_TYPE_UINT8:
+ return in.readUnsignedByte();
+ case Section.SERIALIZE_TYPE_INT8:
+ return in.readByte();
+ case Section.SERIALIZE_TYPE_OBJECT:
+ return readSection();
+ case Section.SERIALIZE_TYPE_STRING:
+ return readByteArray();
+ default:
+ throw new IllegalArgumentException("type " + type
+ + " not supported");
+ }
+ }
+
+ private Object loadStorageEntry() throws IOException {
+ int type = in.readUnsignedByte();
+ if ((type & Section.SERIALIZE_FLAG_ARRAY) != 0)
+ return loadStorageArrayEntry(type);
+ if (type == Section.SERIALIZE_TYPE_ARRAY)
+ return readStorageEntryArrayEntry();
+ else
+ return readStorageEntry(type);
+ }
+
+ private Object readStorageEntry(int type) throws IOException {
+ return read(type);
+ }
+
+ private Object readStorageEntryArrayEntry() throws IOException {
+ int type = in.readUnsignedByte();
+ if ((type & Section.SERIALIZE_FLAG_ARRAY) != 0)
+ throw new IllegalStateException("wrong type sequences");
+ return loadStorageArrayEntry(type);
+ }
+
+ private String readSectionName() throws IOException {
+ int nameLen = in.readUnsignedByte();
+ return readString(nameLen);
+ }
+
+ private byte[] read(long count) throws IOException {
+ if (count > Integer.MAX_VALUE)
+ throw new IllegalArgumentException();
+ int len = (int) count;
+ final byte buffer[] = new byte[len];
+ in.readFully(buffer);
+ return buffer;
+ }
+
+ private String readString(long count) throws IOException {
+ return new String(read(count), StandardCharsets.US_ASCII);
+ }
+
+ private byte[] readByteArray(long count) throws IOException {
+ return read(count);
+ }
+
+ private byte[] readByteArray() throws IOException {
+ long len = readVarint();
+ return readByteArray(len);
+ }
+
+ private long readVarint() throws IOException {
+ long v = 0;
+ int b = in.readUnsignedByte();
+ int sizeMask = b & Section.PORTABLE_RAW_SIZE_MARK_MASK;
+ switch (sizeMask) {
+ case Section.PORTABLE_RAW_SIZE_MARK_BYTE:
+ v = b >>> 2;
+ break;
+ case Section.PORTABLE_RAW_SIZE_MARK_WORD:
+ v = readRest(b, 1) >>> 2;
+ break;
+ case Section.PORTABLE_RAW_SIZE_MARK_DWORD:
+ v = readRest(b, 3) >>> 2;
+ break;
+ case Section.PORTABLE_RAW_SIZE_MARK_INT64:
+ v = readRest(b, 7) >>> 2;
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+ return v;
+ }
+
+ // this should be in LittleEndianDataInputStream because it has little
+ // endian logic
+ private long readRest(final int firstByte, final int bytes) throws IOException {
+ long result = firstByte;
+ for (int i = 1; i < bytes + 1; i++) {
+ result = result + (((long) in.readUnsignedByte()) << (8 * i));
+ }
+ return result;
+ }
+
+}
diff --git a/app/src/main/java/com/m2049r/levin/util/LevinWriter.java b/app/src/main/java/com/m2049r/levin/util/LevinWriter.java
new file mode 100644
index 0000000..ad2fe32
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/util/LevinWriter.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.util;
+
+import com.m2049r.levin.data.Section;
+
+import java.io.DataOutput;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+// a simplified Levin Writer WITHOUT support for arrays
+
+public class LevinWriter {
+ private DataOutput out;
+
+ public LevinWriter(DataOutput out) {
+ this.out = out;
+ }
+
+ public void writePayload(Section section) throws IOException {
+ out.writeInt(Section.PORTABLE_STORAGE_SIGNATUREA);
+ out.writeInt(Section.PORTABLE_STORAGE_SIGNATUREB);
+ out.writeByte(Section.PORTABLE_STORAGE_FORMAT_VER);
+ putSection(section);
+ }
+
+ private void writeSection(Section section) throws IOException {
+ out.writeByte(Section.SERIALIZE_TYPE_OBJECT);
+ putSection(section);
+ }
+
+ private void putSection(Section section) throws IOException {
+ writeVarint(section.size());
+ for (Map.Entry kv : section.entrySet()) {
+ byte[] key = kv.getKey().getBytes(StandardCharsets.US_ASCII);
+ out.writeByte(key.length);
+ out.write(key);
+ write(kv.getValue());
+ }
+ }
+
+ private void writeVarint(long i) throws IOException {
+ if (i <= 63) {
+ out.writeByte(((int) i << 2) | Section.PORTABLE_RAW_SIZE_MARK_BYTE);
+ } else if (i <= 16383) {
+ out.writeShort(((int) i << 2) | Section.PORTABLE_RAW_SIZE_MARK_WORD);
+ } else if (i <= 1073741823) {
+ out.writeInt(((int) i << 2) | Section.PORTABLE_RAW_SIZE_MARK_DWORD);
+ } else {
+ if (i > 4611686018427387903L)
+ throw new IllegalArgumentException();
+ out.writeLong((i << 2) | Section.PORTABLE_RAW_SIZE_MARK_INT64);
+ }
+ }
+
+ private void write(Object object) throws IOException {
+ if (object instanceof byte[]) {
+ byte[] value = (byte[]) object;
+ out.writeByte(Section.SERIALIZE_TYPE_STRING);
+ writeVarint(value.length);
+ out.write(value);
+ } else if (object instanceof String) {
+ byte[] value = ((String) object)
+ .getBytes(StandardCharsets.US_ASCII);
+ out.writeByte(Section.SERIALIZE_TYPE_STRING);
+ writeVarint(value.length);
+ out.write(value);
+ } else if (object instanceof Integer) {
+ out.writeByte(Section.SERIALIZE_TYPE_UINT32);
+ out.writeInt((int) object);
+ } else if (object instanceof Long) {
+ out.writeByte(Section.SERIALIZE_TYPE_UINT64);
+ out.writeLong((long) object);
+ } else if (object instanceof Byte) {
+ out.writeByte(Section.SERIALIZE_TYPE_UINT8);
+ out.writeByte((byte) object);
+ } else if (object instanceof Section) {
+ writeSection((Section) object);
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/util/LittleEndianDataInputStream.java b/app/src/main/java/com/m2049r/levin/util/LittleEndianDataInputStream.java
new file mode 100644
index 0000000..3924eeb
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/util/LittleEndianDataInputStream.java
@@ -0,0 +1,564 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.util;
+
+import java.io.DataInput;
+import java.io.EOFException;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UTFDataFormatException;
+
+/**
+ * A little endian java.io.DataInputStream (without readLine())
+ */
+
+public class LittleEndianDataInputStream extends FilterInputStream implements
+ DataInput {
+
+ /**
+ * Creates a DataInputStream that uses the specified underlying InputStream.
+ *
+ * @param in the specified input stream
+ */
+ public LittleEndianDataInputStream(InputStream in) {
+ super(in);
+ }
+
+ @Deprecated
+ public final String readLine() {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Reads some number of bytes from the contained input stream and stores
+ * them into the buffer array b
. The number of bytes actually
+ * read is returned as an integer. This method blocks until input data is
+ * available, end of file is detected, or an exception is thrown.
+ *
+ *
+ * If b
is null, a NullPointerException
is thrown.
+ * If the length of b
is zero, then no bytes are read and
+ * 0
is returned; otherwise, there is an attempt to read at
+ * least one byte. If no byte is available because the stream is at end of
+ * file, the value -1
is returned; otherwise, at least one byte
+ * is read and stored into b
.
+ *
+ *
+ * The first byte read is stored into element b[0]
, the next
+ * one into b[1]
, and so on. The number of bytes read is, at
+ * most, equal to the length of b
. Let k
be the
+ * number of bytes actually read; these bytes will be stored in elements
+ * b[0]
through b[k-1]
, leaving elements
+ * b[k]
through b[b.length-1]
unaffected.
+ *
+ *
+ * The read(b)
method has the same effect as:
+ *
+ *
+ * read(b, 0, b.length)
+ *
+ *
+ *
+ *
+ * @param b the buffer into which the data is read.
+ * @return the total number of bytes read into the buffer, or
+ * -1
if there is no more data because the end of the
+ * stream has been reached.
+ * @throws IOException if the first byte cannot be read for any reason other than
+ * end of file, the stream has been closed and the underlying
+ * input stream does not support reading after close, or
+ * another I/O error occurs.
+ * @see FilterInputStream#in
+ * @see InputStream#read(byte[], int, int)
+ */
+ public final int read(byte b[]) throws IOException {
+ return in.read(b, 0, b.length);
+ }
+
+ /**
+ * Reads up to len
bytes of data from the contained input
+ * stream into an array of bytes. An attempt is made to read as many as
+ * len
bytes, but a smaller number may be read, possibly zero.
+ * The number of bytes actually read is returned as an integer.
+ *
+ *
+ * This method blocks until input data is available, end of file is
+ * detected, or an exception is thrown.
+ *
+ *
+ * If len
is zero, then no bytes are read and 0
is
+ * returned; otherwise, there is an attempt to read at least one byte. If no
+ * byte is available because the stream is at end of file, the value
+ * -1
is returned; otherwise, at least one byte is read and
+ * stored into b
.
+ *
+ *
+ * The first byte read is stored into element b[off]
, the next
+ * one into b[off+1]
, and so on. The number of bytes read is,
+ * at most, equal to len
. Let k be the number of bytes
+ * actually read; these bytes will be stored in elements b[off]
+ * through b[off+
k -1]
, leaving elements
+ * b[off+
k ]
through
+ * b[off+len-1]
unaffected.
+ *
+ *
+ * In every case, elements b[0]
through b[off]
and
+ * elements b[off+len]
through b[b.length-1]
are
+ * unaffected.
+ *
+ * @param b the buffer into which the data is read.
+ * @param off the start offset in the destination array b
+ * @param len the maximum number of bytes read.
+ * @return the total number of bytes read into the buffer, or
+ * -1
if there is no more data because the end of the
+ * stream has been reached.
+ * @throws NullPointerException If b
is null
.
+ * @throws IndexOutOfBoundsException If off
is negative, len
is
+ * negative, or len
is greater than
+ * b.length - off
+ * @throws IOException if the first byte cannot be read for any reason other than
+ * end of file, the stream has been closed and the underlying
+ * input stream does not support reading after close, or
+ * another I/O error occurs.
+ * @see FilterInputStream#in
+ * @see InputStream#read(byte[], int, int)
+ */
+ public final int read(byte b[], int off, int len) throws IOException {
+ return in.read(b, off, len);
+ }
+
+ /**
+ * See the general contract of the readFully
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @param b the buffer into which the data is read.
+ * @throws EOFException if this input stream reaches the end before reading all
+ * the bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final void readFully(byte b[]) throws IOException {
+ readFully(b, 0, b.length);
+ }
+
+ /**
+ * See the general contract of the readFully
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @param b the buffer into which the data is read.
+ * @param off the start offset of the data.
+ * @param len the number of bytes to read.
+ * @throws EOFException if this input stream reaches the end before reading all
+ * the bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final void readFully(byte b[], int off, int len) throws IOException {
+ if (len < 0)
+ throw new IndexOutOfBoundsException();
+ int n = 0;
+ while (n < len) {
+ int count = in.read(b, off + n, len - n);
+ if (count < 0)
+ throw new EOFException();
+ n += count;
+ }
+ }
+
+ /**
+ * See the general contract of the skipBytes
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @param n the number of bytes to be skipped.
+ * @return the actual number of bytes skipped.
+ * @throws IOException if the contained input stream does not support seek, or
+ * the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ */
+ public final int skipBytes(int n) throws IOException {
+ int total = 0;
+ int cur = 0;
+
+ while ((total < n) && ((cur = (int) in.skip(n - total)) > 0)) {
+ total += cur;
+ }
+
+ return total;
+ }
+
+ /**
+ * See the general contract of the readBoolean
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the boolean
value read.
+ * @throws EOFException if this input stream has reached the end.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final boolean readBoolean() throws IOException {
+ int ch = in.read();
+ if (ch < 0)
+ throw new EOFException();
+ return (ch != 0);
+ }
+
+ /**
+ * See the general contract of the readByte
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next byte of this input stream as a signed 8-bit
+ * byte
.
+ * @throws EOFException if this input stream has reached the end.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final byte readByte() throws IOException {
+ int ch = in.read();
+ if (ch < 0)
+ throw new EOFException();
+ return (byte) (ch);
+ }
+
+ /**
+ * See the general contract of the readUnsignedByte
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next byte of this input stream, interpreted as an unsigned
+ * 8-bit number.
+ * @throws EOFException if this input stream has reached the end.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final int readUnsignedByte() throws IOException {
+ int ch = in.read();
+ if (ch < 0)
+ throw new EOFException();
+ return ch;
+ }
+
+ /**
+ * See the general contract of the readShort
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next two bytes of this input stream, interpreted as a signed
+ * 16-bit number.
+ * @throws EOFException if this input stream reaches the end before reading two
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final short readShort() throws IOException {
+ int ch1 = in.read();
+ int ch2 = in.read();
+ if ((ch1 | ch2) < 0)
+ throw new EOFException();
+ return (short) ((ch1 << 0) + (ch2 << 8));
+ }
+
+ /**
+ * See the general contract of the readUnsignedShort
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next two bytes of this input stream, interpreted as an
+ * unsigned 16-bit integer.
+ * @throws EOFException if this input stream reaches the end before reading two
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final int readUnsignedShort() throws IOException {
+ int ch1 = in.read();
+ int ch2 = in.read();
+ if ((ch1 | ch2) < 0)
+ throw new EOFException();
+ return (ch1 << 0) + (ch2 << 8);
+ }
+
+ /**
+ * See the general contract of the readChar
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next two bytes of this input stream, interpreted as a
+ * char
.
+ * @throws EOFException if this input stream reaches the end before reading two
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final char readChar() throws IOException {
+ int ch1 = in.read();
+ int ch2 = in.read();
+ if ((ch1 | ch2) < 0)
+ throw new EOFException();
+ return (char) ((ch1 << 0) + (ch2 << 8));
+ }
+
+ /**
+ * See the general contract of the readInt
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next four bytes of this input stream, interpreted as an
+ * int
.
+ * @throws EOFException if this input stream reaches the end before reading four
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final int readInt() throws IOException {
+ int ch1 = in.read();
+ int ch2 = in.read();
+ int ch3 = in.read();
+ int ch4 = in.read();
+ if ((ch1 | ch2 | ch3 | ch4) < 0)
+ throw new EOFException();
+ return ((ch1 << 0) + (ch2 << 8) + (ch3 << 16) + (ch4 << 24));
+ }
+
+ private byte readBuffer[] = new byte[8];
+
+ /**
+ * See the general contract of the readLong
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next eight bytes of this input stream, interpreted as a
+ * long
.
+ * @throws EOFException if this input stream reaches the end before reading eight
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see FilterInputStream#in
+ */
+ public final long readLong() throws IOException {
+ readFully(readBuffer, 0, 8);
+ return (((long) readBuffer[7] << 56)
+ + ((long) (readBuffer[6] & 255) << 48)
+ + ((long) (readBuffer[5] & 255) << 40)
+ + ((long) (readBuffer[4] & 255) << 32)
+ + ((long) (readBuffer[3] & 255) << 24)
+ + ((readBuffer[2] & 255) << 16) + ((readBuffer[1] & 255) << 8) + ((readBuffer[0] & 255) << 0));
+ }
+
+ /**
+ * See the general contract of the readFloat
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next four bytes of this input stream, interpreted as a
+ * float
.
+ * @throws EOFException if this input stream reaches the end before reading four
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see java.io.DataInputStream#readInt()
+ * @see Float#intBitsToFloat(int)
+ */
+ public final float readFloat() throws IOException {
+ return Float.intBitsToFloat(readInt());
+ }
+
+ /**
+ * See the general contract of the readDouble
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return the next eight bytes of this input stream, interpreted as a
+ * double
.
+ * @throws EOFException if this input stream reaches the end before reading eight
+ * bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @see java.io.DataInputStream#readLong()
+ * @see Double#longBitsToDouble(long)
+ */
+ public final double readDouble() throws IOException {
+ return Double.longBitsToDouble(readLong());
+ }
+
+ /**
+ * See the general contract of the readUTF
method of
+ * DataInput
.
+ *
+ * Bytes for this operation are read from the contained input stream.
+ *
+ * @return a Unicode string.
+ * @throws EOFException if this input stream reaches the end before reading all
+ * the bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @throws UTFDataFormatException if the bytes do not represent a valid modified UTF-8
+ * encoding of a string.
+ * @see java.io.DataInputStream#readUTF(DataInput)
+ */
+ public final String readUTF() throws IOException {
+ return readUTF(this);
+ }
+
+ /**
+ * working arrays initialized on demand by readUTF
+ */
+ private byte bytearr[] = new byte[80];
+ private char chararr[] = new char[80];
+
+ /**
+ * Reads from the stream in
a representation of a Unicode
+ * character string encoded in modified UTF-8 format; this
+ * string of characters is then returned as a String
. The
+ * details of the modified UTF-8 representation are exactly the same as for
+ * the readUTF
method of DataInput
.
+ *
+ * @param in a data input stream.
+ * @return a Unicode string.
+ * @throws EOFException if the input stream reaches the end before all the bytes.
+ * @throws IOException the stream has been closed and the contained input stream
+ * does not support reading after close, or another I/O error
+ * occurs.
+ * @throws UTFDataFormatException if the bytes do not represent a valid modified UTF-8
+ * encoding of a Unicode string.
+ * @see java.io.DataInputStream#readUnsignedShort()
+ */
+ public final static String readUTF(DataInput in) throws IOException {
+ int utflen = in.readUnsignedShort();
+ byte[] bytearr = null;
+ char[] chararr = null;
+ if (in instanceof LittleEndianDataInputStream) {
+ LittleEndianDataInputStream dis = (LittleEndianDataInputStream) in;
+ if (dis.bytearr.length < utflen) {
+ dis.bytearr = new byte[utflen * 2];
+ dis.chararr = new char[utflen * 2];
+ }
+ chararr = dis.chararr;
+ bytearr = dis.bytearr;
+ } else {
+ bytearr = new byte[utflen];
+ chararr = new char[utflen];
+ }
+
+ int c, char2, char3;
+ int count = 0;
+ int chararr_count = 0;
+
+ in.readFully(bytearr, 0, utflen);
+
+ while (count < utflen) {
+ c = (int) bytearr[count] & 0xff;
+ if (c > 127)
+ break;
+ count++;
+ chararr[chararr_count++] = (char) c;
+ }
+
+ while (count < utflen) {
+ c = (int) bytearr[count] & 0xff;
+ switch (c >> 4) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ /* 0xxxxxxx */
+ count++;
+ chararr[chararr_count++] = (char) c;
+ break;
+ case 12:
+ case 13:
+ /* 110x xxxx 10xx xxxx */
+ count += 2;
+ if (count > utflen)
+ throw new UTFDataFormatException(
+ "malformed input: partial character at end");
+ char2 = (int) bytearr[count - 1];
+ if ((char2 & 0xC0) != 0x80)
+ throw new UTFDataFormatException(
+ "malformed input around byte " + count);
+ chararr[chararr_count++] = (char) (((c & 0x1F) << 6) | (char2 & 0x3F));
+ break;
+ case 14:
+ /* 1110 xxxx 10xx xxxx 10xx xxxx */
+ count += 3;
+ if (count > utflen)
+ throw new UTFDataFormatException(
+ "malformed input: partial character at end");
+ char2 = (int) bytearr[count - 2];
+ char3 = (int) bytearr[count - 1];
+ if (((char2 & 0xC0) != 0x80) || ((char3 & 0xC0) != 0x80))
+ throw new UTFDataFormatException(
+ "malformed input around byte " + (count - 1));
+ chararr[chararr_count++] = (char) (((c & 0x0F) << 12)
+ | ((char2 & 0x3F) << 6) | ((char3 & 0x3F) << 0));
+ break;
+ default:
+ /* 10xx xxxx, 1111 xxxx */
+ throw new UTFDataFormatException("malformed input around byte "
+ + count);
+ }
+ }
+ // The number of chars produced may be less than utflen
+ return new String(chararr, 0, chararr_count);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/levin/util/LittleEndianDataOutputStream.java b/app/src/main/java/com/m2049r/levin/util/LittleEndianDataOutputStream.java
new file mode 100644
index 0000000..fbf7e0c
--- /dev/null
+++ b/app/src/main/java/com/m2049r/levin/util/LittleEndianDataOutputStream.java
@@ -0,0 +1,403 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.levin.util;
+
+import java.io.DataOutput;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UTFDataFormatException;
+
+/**
+ * A little endian java.io.DataOutputStream
+ */
+
+public class LittleEndianDataOutputStream extends FilterOutputStream implements
+ DataOutput {
+
+ /**
+ * The number of bytes written to the data output stream so far. If this
+ * counter overflows, it will be wrapped to Integer.MAX_VALUE.
+ */
+ protected int written;
+
+ /**
+ * Creates a new data output stream to write data to the specified
+ * underlying output stream. The counter written
is set to
+ * zero.
+ *
+ * @param out the underlying output stream, to be saved for later use.
+ * @see FilterOutputStream#out
+ */
+ public LittleEndianDataOutputStream(OutputStream out) {
+ super(out);
+ }
+
+ /**
+ * Increases the written counter by the specified value until it reaches
+ * Integer.MAX_VALUE.
+ */
+ private void incCount(int value) {
+ int temp = written + value;
+ if (temp < 0) {
+ temp = Integer.MAX_VALUE;
+ }
+ written = temp;
+ }
+
+ /**
+ * Writes the specified byte (the low eight bits of the argument
+ * b
) to the underlying output stream. If no exception is
+ * thrown, the counter written
is incremented by 1
+ * .
+ *
+ * Implements the write
method of OutputStream
.
+ *
+ * @param b the byte
to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public synchronized void write(int b) throws IOException {
+ out.write(b);
+ incCount(1);
+ }
+
+ /**
+ * Writes len
bytes from the specified byte array starting at
+ * offset off
to the underlying output stream. If no exception
+ * is thrown, the counter written
is incremented by
+ * len
.
+ *
+ * @param b the data.
+ * @param off the start offset in the data.
+ * @param len the number of bytes to write.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public synchronized void write(byte b[], int off, int len)
+ throws IOException {
+ out.write(b, off, len);
+ incCount(len);
+ }
+
+ /**
+ * Flushes this data output stream. This forces any buffered output bytes to
+ * be written out to the stream.
+ *
+ * The flush
method of DataOutputStream
calls the
+ * flush
method of its underlying output stream.
+ *
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ * @see OutputStream#flush()
+ */
+ public void flush() throws IOException {
+ out.flush();
+ }
+
+ /**
+ * Writes a boolean
to the underlying output stream as a 1-byte
+ * value. The value true
is written out as the value
+ * (byte)1
; the value false
is written out as the
+ * value (byte)0
. If no exception is thrown, the counter
+ * written
is incremented by 1
.
+ *
+ * @param v a boolean
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeBoolean(boolean v) throws IOException {
+ out.write(v ? 1 : 0);
+ incCount(1);
+ }
+
+ /**
+ * Writes out a byte
to the underlying output stream as a
+ * 1-byte value. If no exception is thrown, the counter written
+ * is incremented by 1
.
+ *
+ * @param v a byte
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeByte(int v) throws IOException {
+ out.write(v);
+ incCount(1);
+ }
+
+ /**
+ * Writes a short
to the underlying output stream as two bytes,
+ * low byte first. If no exception is thrown, the counter
+ * written
is incremented by 2
.
+ *
+ * @param v a short
to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeShort(int v) throws IOException {
+ out.write((v >>> 0) & 0xFF);
+ out.write((v >>> 8) & 0xFF);
+ incCount(2);
+ }
+
+ /**
+ * Writes a char
to the underlying output stream as a 2-byte
+ * value, low byte first. If no exception is thrown, the counter
+ * written
is incremented by 2
.
+ *
+ * @param v a char
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeChar(int v) throws IOException {
+ out.write((v >>> 0) & 0xFF);
+ out.write((v >>> 8) & 0xFF);
+ incCount(2);
+ }
+
+ /**
+ * Writes an int
to the underlying output stream as four bytes,
+ * low byte first. If no exception is thrown, the counter
+ * written
is incremented by 4
.
+ *
+ * @param v an int
to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeInt(int v) throws IOException {
+ out.write((v >>> 0) & 0xFF);
+ out.write((v >>> 8) & 0xFF);
+ out.write((v >>> 16) & 0xFF);
+ out.write((v >>> 24) & 0xFF);
+ incCount(4);
+ }
+
+ private byte writeBuffer[] = new byte[8];
+
+ /**
+ * Writes a long
to the underlying output stream as eight
+ * bytes, low byte first. In no exception is thrown, the counter
+ * written
is incremented by 8
.
+ *
+ * @param v a long
to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeLong(long v) throws IOException {
+ writeBuffer[7] = (byte) (v >>> 56);
+ writeBuffer[6] = (byte) (v >>> 48);
+ writeBuffer[5] = (byte) (v >>> 40);
+ writeBuffer[4] = (byte) (v >>> 32);
+ writeBuffer[3] = (byte) (v >>> 24);
+ writeBuffer[2] = (byte) (v >>> 16);
+ writeBuffer[1] = (byte) (v >>> 8);
+ writeBuffer[0] = (byte) (v >>> 0);
+ out.write(writeBuffer, 0, 8);
+ incCount(8);
+ }
+
+ /**
+ * Converts the float argument to an int
using the
+ * floatToIntBits
method in class Float
, and then
+ * writes that int
value to the underlying output stream as a
+ * 4-byte quantity, low byte first. If no exception is thrown, the counter
+ * written
is incremented by 4
.
+ *
+ * @param v a float
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ * @see Float#floatToIntBits(float)
+ */
+ public final void writeFloat(float v) throws IOException {
+ writeInt(Float.floatToIntBits(v));
+ }
+
+ /**
+ * Converts the double argument to a long
using the
+ * doubleToLongBits
method in class Double
, and
+ * then writes that long
value to the underlying output stream
+ * as an 8-byte quantity, low byte first. If no exception is thrown, the
+ * counter written
is incremented by 8
.
+ *
+ * @param v a double
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ * @see Double#doubleToLongBits(double)
+ */
+ public final void writeDouble(double v) throws IOException {
+ writeLong(Double.doubleToLongBits(v));
+ }
+
+ /**
+ * Writes out the string to the underlying output stream as a sequence of
+ * bytes. Each character in the string is written out, in sequence, by
+ * discarding its high eight bits. If no exception is thrown, the counter
+ * written
is incremented by the length of s
.
+ *
+ * @param s a string of bytes to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see FilterOutputStream#out
+ */
+ public final void writeBytes(String s) throws IOException {
+ int len = s.length();
+ for (int i = 0; i < len; i++) {
+ out.write((byte) s.charAt(i));
+ }
+ incCount(len);
+ }
+
+ /**
+ * Writes a string to the underlying output stream as a sequence of
+ * characters. Each character is written to the data output stream as if by
+ * the writeChar
method. If no exception is thrown, the counter
+ * written
is incremented by twice the length of s
+ * .
+ *
+ * @param s a String
value to be written.
+ * @throws IOException if an I/O error occurs.
+ * @see java.io.DataOutputStream#writeChar(int)
+ * @see FilterOutputStream#out
+ */
+ public final void writeChars(String s) throws IOException {
+ int len = s.length();
+ for (int i = 0; i < len; i++) {
+ int v = s.charAt(i);
+ out.write((v >>> 0) & 0xFF);
+ out.write((v >>> 8) & 0xFF);
+ }
+ incCount(len * 2);
+ }
+
+ /**
+ * Writes a string to the underlying output stream using modified UTF-8 encoding in a
+ * machine-independent manner.
+ *
+ * First, two bytes are written to the output stream as if by the
+ * writeShort
method giving the number of bytes to follow. This
+ * value is the number of bytes actually written out, not the length of the
+ * string. Following the length, each character of the string is output, in
+ * sequence, using the modified UTF-8 encoding for the character. If no
+ * exception is thrown, the counter written
is incremented by
+ * the total number of bytes written to the output stream. This will be at
+ * least two plus the length of str
, and at most two plus
+ * thrice the length of str
.
+ *
+ * @param str a string to be written.
+ * @throws IOException if an I/O error occurs.
+ */
+ public final void writeUTF(String str) throws IOException {
+ writeUTF(str, this);
+ }
+
+ /**
+ * bytearr is initialized on demand by writeUTF
+ */
+ private byte[] bytearr = null;
+
+ /**
+ * Writes a string to the specified DataOutput using modified UTF-8 encoding in a
+ * machine-independent manner.
+ *
+ * First, two bytes are written to out as if by the writeShort
+ * method giving the number of bytes to follow. This value is the number of
+ * bytes actually written out, not the length of the string. Following the
+ * length, each character of the string is output, in sequence, using the
+ * modified UTF-8 encoding for the character. If no exception is thrown, the
+ * counter written
is incremented by the total number of bytes
+ * written to the output stream. This will be at least two plus the length
+ * of str
, and at most two plus thrice the length of
+ * str
.
+ *
+ * @param str a string to be written.
+ * @param out destination to write to
+ * @return The number of bytes written out.
+ * @throws IOException if an I/O error occurs.
+ */
+ static int writeUTF(String str, DataOutput out) throws IOException {
+ int strlen = str.length();
+ int utflen = 0;
+ int c, count = 0;
+
+ /* use charAt instead of copying String to char array */
+ for (int i = 0; i < strlen; i++) {
+ c = str.charAt(i);
+ if ((c >= 0x0001) && (c <= 0x007F)) {
+ utflen++;
+ } else if (c > 0x07FF) {
+ utflen += 3;
+ } else {
+ utflen += 2;
+ }
+ }
+
+ if (utflen > 65535)
+ throw new UTFDataFormatException("encoded string too long: "
+ + utflen + " bytes");
+
+ byte[] bytearr = null;
+ if (out instanceof LittleEndianDataOutputStream) {
+ LittleEndianDataOutputStream dos = (LittleEndianDataOutputStream) out;
+ if (dos.bytearr == null || (dos.bytearr.length < (utflen + 2)))
+ dos.bytearr = new byte[(utflen * 2) + 2];
+ bytearr = dos.bytearr;
+ } else {
+ bytearr = new byte[utflen + 2];
+ }
+
+ bytearr[count++] = (byte) ((utflen >>> 8) & 0xFF);
+ bytearr[count++] = (byte) ((utflen >>> 0) & 0xFF);
+
+ int i = 0;
+ for (i = 0; i < strlen; i++) {
+ c = str.charAt(i);
+ if (!((c >= 0x0001) && (c <= 0x007F)))
+ break;
+ bytearr[count++] = (byte) c;
+ }
+
+ for (; i < strlen; i++) {
+ c = str.charAt(i);
+ if ((c >= 0x0001) && (c <= 0x007F)) {
+ bytearr[count++] = (byte) c;
+
+ } else if (c > 0x07FF) {
+ bytearr[count++] = (byte) (0xE0 | ((c >> 12) & 0x0F));
+ bytearr[count++] = (byte) (0x80 | ((c >> 6) & 0x3F));
+ bytearr[count++] = (byte) (0x80 | ((c >> 0) & 0x3F));
+ } else {
+ bytearr[count++] = (byte) (0xC0 | ((c >> 6) & 0x1F));
+ bytearr[count++] = (byte) (0x80 | ((c >> 0) & 0x3F));
+ }
+ }
+ out.write(bytearr, 0, utflen + 2);
+ return utflen + 2;
+ }
+
+ /**
+ * Returns the current value of the counter written
, the number
+ * of bytes written to this data output stream so far. If the counter
+ * overflows, it will be wrapped to Integer.MAX_VALUE.
+ *
+ * @return the value of the written
field.
+ * @see java.io.DataOutputStream#written
+ */
+ public final int size() {
+ return written;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/BaseActivity.java b/app/src/main/java/com/m2049r/xmrwallet/BaseActivity.java
new file mode 100644
index 0000000..1f1f9b1
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/BaseActivity.java
@@ -0,0 +1,313 @@
+/*
+ * Copyright (c) 2017-2020 m2049r et al.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.nfc.FormatException;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.nfc.tech.Ndef;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.PowerManager;
+import android.widget.Toast;
+
+import androidx.annotation.CallSuper;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.dialog.ProgressDialog;
+import com.m2049r.xmrwallet.fragment.send.SendFragment;
+import com.m2049r.xmrwallet.ledger.Ledger;
+import com.m2049r.xmrwallet.ledger.LedgerProgressDialog;
+
+import java.io.IOException;
+
+import timber.log.Timber;
+
+public class BaseActivity extends SecureActivity
+ implements GenerateReviewFragment.ProgressListener, SubaddressFragment.ProgressListener {
+
+ ProgressDialog progressDialog = null;
+
+ private class SimpleProgressDialog extends ProgressDialog {
+
+ SimpleProgressDialog(Context context, int msgId) {
+ super(context);
+ setCancelable(false);
+ setMessage(context.getString(msgId));
+ }
+
+ @Override
+ public void onBackPressed() {
+ // prevent back button
+ }
+ }
+
+ @Override
+ public void showProgressDialog(int msgId) {
+ showProgressDialog(msgId, 250); // don't show dialog for fast operations
+ }
+
+ public void showProgressDialog(int msgId, long delayMillis) {
+ dismissProgressDialog(); // just in case
+ progressDialog = new SimpleProgressDialog(BaseActivity.this, msgId);
+ if (delayMillis > 0) {
+ Handler handler = new Handler();
+ handler.postDelayed(new Runnable() {
+ public void run() {
+ if (progressDialog != null) progressDialog.show();
+ }
+ }, delayMillis);
+ } else {
+ progressDialog.show();
+ }
+ }
+
+ @Override
+ public void showLedgerProgressDialog(int mode) {
+ dismissProgressDialog(); // just in case
+ progressDialog = new LedgerProgressDialog(BaseActivity.this, mode);
+ Ledger.setListener((Ledger.Listener) progressDialog);
+ progressDialog.show();
+ }
+
+ @Override
+ public void dismissProgressDialog() {
+ if (progressDialog == null) return; // nothing to do
+ if (progressDialog instanceof Ledger.Listener) {
+ Ledger.unsetListener((Ledger.Listener) progressDialog);
+ }
+ if (progressDialog.isShowing()) {
+ progressDialog.dismiss();
+ }
+ progressDialog = null;
+ }
+
+ static final int RELEASE_WAKE_LOCK_DELAY = 5000; // millisconds
+
+ private PowerManager.WakeLock wl = null;
+
+ void acquireWakeLock() {
+ if ((wl != null) && wl.isHeld()) return;
+ PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ this.wl = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK, getString(R.string.app_name));
+ try {
+ wl.acquire();
+ Timber.d("WakeLock acquired");
+ } catch (SecurityException ex) {
+ Timber.w("WakeLock NOT acquired: %s", ex.getLocalizedMessage());
+ wl = null;
+ }
+ }
+
+ void releaseWakeLock(int delayMillis) {
+ Handler handler = new Handler(Looper.getMainLooper());
+ handler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ releaseWakeLock();
+ }
+ }, delayMillis);
+ }
+
+ void releaseWakeLock() {
+ if ((wl == null) || !wl.isHeld()) return;
+ wl.release();
+ wl = null;
+ Timber.d("WakeLock released");
+ }
+
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ initNfc();
+ }
+
+ @Override
+ protected void onPostResume() {
+ super.onPostResume();
+ if (nfcAdapter != null) {
+ nfcAdapter.enableForegroundDispatch(this, nfcPendingIntent, null, null);
+ // intercept all techs so we can tell the user their tag is no good
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ Timber.d("onPause()");
+ if (nfcAdapter != null)
+ nfcAdapter.disableForegroundDispatch(this);
+ super.onPause();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ processNfcIntent(intent);
+ }
+
+ // NFC stuff
+ private NfcAdapter nfcAdapter;
+ private PendingIntent nfcPendingIntent;
+
+ public void initNfc() {
+ nfcAdapter = NfcAdapter.getDefaultAdapter(this);
+ if (nfcAdapter == null) // no NFC support
+ return;
+ nfcPendingIntent = PendingIntent.getActivity(this, 0,
+ new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
+ }
+
+ private void processNfcIntent(Intent intent) {
+ String action = intent.getAction();
+ Timber.d("ACTION=%s", action);
+ if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)
+ || NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)
+ || NfcAdapter.ACTION_TECH_DISCOVERED.equals(action)) {
+ Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
+ Ndef ndef = Ndef.get(tag);
+ if (ndef == null) {
+ Toast.makeText(this, getString(R.string.nfc_tag_unsupported), Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ Fragment f = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (f instanceof ReceiveFragment) {
+ // We want to write a Tag from the ReceiveFragment
+ BarcodeData bc = ((ReceiveFragment) f).getBarcodeData();
+ if (bc != null) {
+ new AsyncWriteTag(ndef, bc.getUri()).execute();
+ } // else wallet is not loaded yet or receive is otherwise not ready - ignore
+ } else if (f instanceof SendFragment) {
+ // We want to read a Tag for the SendFragment
+ NdefMessage ndefMessage = ndef.getCachedNdefMessage();
+ if (ndefMessage == null) {
+ Toast.makeText(this, getString(R.string.nfc_tag_read_undef), Toast.LENGTH_LONG).show();
+ return;
+ }
+ NdefRecord firstRecord = ndefMessage.getRecords()[0];
+ Uri uri = firstRecord.toUri(); // we insist on the first record
+ if (uri == null) {
+ Toast.makeText(this, getString(R.string.nfc_tag_read_undef), Toast.LENGTH_LONG).show();
+ } else {
+ BarcodeData bc = BarcodeData.fromString(uri.toString());
+ if (bc == null)
+ Toast.makeText(this, getString(R.string.nfc_tag_read_undef), Toast.LENGTH_LONG).show();
+ else
+ onUriScanned(bc);
+ }
+ }
+ }
+ }
+
+ // this gets called only if we get data
+ @CallSuper
+ void onUriScanned(BarcodeData barcodeData) {
+ // do nothing by default yet
+ }
+
+ private BarcodeData barcodeData = null;
+
+ private BarcodeData popBarcodeData() {
+ BarcodeData popped = barcodeData;
+ barcodeData = null;
+ return popped;
+ }
+
+ private class AsyncWriteTag extends AsyncTask {
+
+ Ndef ndef;
+ Uri uri;
+ String errorMessage = null;
+
+ AsyncWriteTag(Ndef ndef, Uri uri) {
+ this.ndef = ndef;
+ this.uri = uri;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ showProgressDialog(R.string.progress_nfc_write);
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ if (params.length != 0) return false;
+ try {
+ writeNdef(ndef, uri);
+ return true;
+ } catch (IOException | FormatException ex) {
+ Timber.e(ex);
+ } catch (IllegalArgumentException ex) {
+ errorMessage = ex.getMessage();
+ Timber.d(errorMessage);
+ } finally {
+ try {
+ ndef.close();
+ } catch (IOException ex) {
+ Timber.e(ex);
+ }
+ }
+ return false;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (isDestroyed()) {
+ return;
+ }
+ dismissProgressDialog();
+ if (!result) {
+ if (errorMessage != null)
+ Toast.makeText(getApplicationContext(), errorMessage, Toast.LENGTH_LONG).show();
+ else
+ Toast.makeText(getApplicationContext(), getString(R.string.nfc_write_failed), Toast.LENGTH_LONG).show();
+ } else {
+ Toast.makeText(getApplicationContext(), getString(R.string.nfc_write_successful), Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+
+ void writeNdef(Ndef ndef, Uri uri) throws IOException, FormatException {
+ NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(this);
+ if (nfcAdapter == null) return; // no NFC support here
+
+ NdefRecord recordNFC = NdefRecord.createUri(uri);
+ NdefMessage message = new NdefMessage(recordNFC);
+ ndef.connect();
+ int tagSize = ndef.getMaxSize();
+ int msgSize = message.getByteArrayLength();
+ Timber.d("tagSize=%d, msgSIze=%d, uriSize=%d", tagSize, msgSize, uri.toString().length());
+ if (tagSize < msgSize)
+ throw new IllegalArgumentException(getString(R.string.nfc_tag_size, tagSize, msgSize));
+ ndef.writeNdefMessage(message);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/GenerateFragment.java b/app/src/main/java/com/m2049r/xmrwallet/GenerateFragment.java
new file mode 100644
index 0000000..6370e99
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/GenerateFragment.java
@@ -0,0 +1,615 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import androidx.annotation.NonNull;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+import android.widget.Button;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.FingerprintHelper;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.KeyStoreHelper;
+import com.m2049r.xmrwallet.util.RestoreHeight;
+import com.m2049r.xmrwallet.util.ledger.Monero;
+import com.m2049r.xmrwallet.widget.PasswordEntryView;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.io.File;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+
+import timber.log.Timber;
+
+public class GenerateFragment extends Fragment {
+
+ static final String TYPE = "type";
+ static final String TYPE_NEW = "new";
+ static final String TYPE_KEY = "key";
+ static final String TYPE_SEED = "seed";
+ static final String TYPE_LEDGER = "ledger";
+ static final String TYPE_VIEWONLY = "view";
+
+ private TextInputLayout etWalletName;
+ private PasswordEntryView etWalletPassword;
+ private LinearLayout llFingerprintAuth;
+ private TextInputLayout etWalletAddress;
+ private TextInputLayout etWalletMnemonic;
+ private TextInputLayout etWalletViewKey;
+ private TextInputLayout etWalletSpendKey;
+ private TextInputLayout etWalletRestoreHeight;
+ private Button bGenerate;
+
+ private Button bSeedOffset;
+ private TextInputLayout etSeedOffset;
+
+ private String type = null;
+
+ private void clearErrorOnTextEntry(final TextInputLayout textInputLayout) {
+ textInputLayout.getEditText().addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable editable) {
+ textInputLayout.setError(null);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ });
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Bundle args = getArguments();
+ this.type = args.getString(TYPE);
+
+ View view = inflater.inflate(R.layout.fragment_generate, container, false);
+
+ etWalletName = view.findViewById(R.id.etWalletName);
+ etWalletPassword = view.findViewById(R.id.etWalletPassword);
+ llFingerprintAuth = view.findViewById(R.id.llFingerprintAuth);
+ etWalletMnemonic = view.findViewById(R.id.etWalletMnemonic);
+ etWalletAddress = view.findViewById(R.id.etWalletAddress);
+ etWalletViewKey = view.findViewById(R.id.etWalletViewKey);
+ etWalletSpendKey = view.findViewById(R.id.etWalletSpendKey);
+ etWalletRestoreHeight = view.findViewById(R.id.etWalletRestoreHeight);
+ bGenerate = view.findViewById(R.id.bGenerate);
+ bSeedOffset = view.findViewById(R.id.bSeedOffset);
+ etSeedOffset = view.findViewById(R.id.etSeedOffset);
+
+ etWalletAddress.getEditText().setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ etWalletViewKey.getEditText().setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ etWalletSpendKey.getEditText().setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+
+ etWalletName.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ checkName();
+ }
+ });
+ clearErrorOnTextEntry(etWalletName);
+
+ etWalletMnemonic.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ checkMnemonic();
+ }
+ });
+ clearErrorOnTextEntry(etWalletMnemonic);
+
+ etWalletAddress.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ checkAddress();
+ }
+ });
+ clearErrorOnTextEntry(etWalletAddress);
+
+ etWalletViewKey.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ checkViewKey();
+ }
+ });
+ clearErrorOnTextEntry(etWalletViewKey);
+
+ etWalletSpendKey.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ checkSpendKey();
+ }
+ });
+ clearErrorOnTextEntry(etWalletSpendKey);
+
+ Helper.showKeyboard(requireActivity());
+
+ etWalletName.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ if (checkName()) {
+ etWalletPassword.requestFocus();
+ } // otherwise ignore
+ return true;
+ }
+ return false;
+ });
+
+ if (FingerprintHelper.isDeviceSupported(getContext())) {
+ llFingerprintAuth.setVisibility(View.VISIBLE);
+
+ final SwitchMaterial swFingerprintAllowed = (SwitchMaterial) llFingerprintAuth.getChildAt(0);
+ swFingerprintAllowed.setOnClickListener(view1 -> {
+ if (!swFingerprintAllowed.isChecked()) return;
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+ builder.setMessage(Html.fromHtml(getString(R.string.generate_fingerprint_warn)))
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok), null)
+ .setNegativeButton(getString(R.string.label_cancel), (dialogInterface, i) -> swFingerprintAllowed.setChecked(false))
+ .show();
+ });
+ }
+
+ switch (type) {
+ case TYPE_NEW:
+ etWalletPassword.getEditText().setImeOptions(EditorInfo.IME_ACTION_UNSPECIFIED);
+ break;
+ case TYPE_LEDGER:
+ etWalletPassword.getEditText().setImeOptions(EditorInfo.IME_ACTION_DONE);
+ etWalletPassword.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ etWalletRestoreHeight.requestFocus();
+ return true;
+ }
+ return false;
+ });
+ break;
+ case TYPE_SEED:
+ etWalletPassword.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ etWalletMnemonic.requestFocus();
+ return true;
+ }
+ return false;
+ });
+ etWalletMnemonic.setVisibility(View.VISIBLE);
+ etWalletMnemonic.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ if (checkMnemonic()) {
+ etWalletRestoreHeight.requestFocus();
+ }
+ return true;
+ }
+ return false;
+ });
+ bSeedOffset.setVisibility(View.VISIBLE);
+ bSeedOffset.setOnClickListener(v -> toggleSeedOffset());
+ break;
+ case TYPE_KEY:
+ case TYPE_VIEWONLY:
+ etWalletPassword.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ etWalletAddress.requestFocus();
+ return true;
+ }
+ return false;
+ });
+ etWalletAddress.setVisibility(View.VISIBLE);
+ etWalletAddress.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ if (checkAddress()) {
+ etWalletViewKey.requestFocus();
+ }
+ return true;
+ }
+ return false;
+ });
+ etWalletViewKey.setVisibility(View.VISIBLE);
+ etWalletViewKey.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ if (checkViewKey()) {
+ if (type.equals(TYPE_KEY)) {
+ etWalletSpendKey.requestFocus();
+ } else {
+ etWalletRestoreHeight.requestFocus();
+ }
+ }
+ return true;
+ }
+ return false;
+ });
+ break;
+ }
+ if (type.equals(TYPE_KEY)) {
+ etWalletSpendKey.setVisibility(View.VISIBLE);
+ etWalletSpendKey.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_NEXT)) {
+ if (checkSpendKey()) {
+ etWalletRestoreHeight.requestFocus();
+ }
+ return true;
+ }
+ return false;
+ });
+ }
+ if (!type.equals(TYPE_NEW)) {
+ etWalletRestoreHeight.setVisibility(View.VISIBLE);
+ etWalletRestoreHeight.getEditText().setImeOptions(EditorInfo.IME_ACTION_UNSPECIFIED);
+ }
+ bGenerate.setOnClickListener(v -> {
+ Helper.hideKeyboard(getActivity());
+ generateWallet();
+ });
+
+ etWalletName.requestFocus();
+
+ return view;
+ }
+
+ void toggleSeedOffset() {
+ if (etSeedOffset.getVisibility() == View.VISIBLE) {
+ etSeedOffset.getEditText().getText().clear();
+ etSeedOffset.setVisibility(View.GONE);
+ bSeedOffset.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_down, 0, 0, 0);
+ } else {
+ etSeedOffset.setVisibility(View.VISIBLE);
+ bSeedOffset.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_up, 0, 0, 0);
+ etSeedOffset.requestFocusFromTouch();
+ }
+ }
+
+ private boolean checkName() {
+ String name = etWalletName.getEditText().getText().toString();
+ boolean ok = true;
+ if (name.length() == 0) {
+ etWalletName.setError(getString(R.string.generate_wallet_name));
+ ok = false;
+ } else if (name.charAt(0) == '.') {
+ etWalletName.setError(getString(R.string.generate_wallet_dot));
+ ok = false;
+ } else {
+ File walletFile = Helper.getWalletFile(getActivity(), name);
+ if (WalletManager.getInstance().walletExists(walletFile)) {
+ etWalletName.setError(getString(R.string.generate_wallet_exists));
+ ok = false;
+ }
+ }
+ if (ok) {
+ etWalletName.setError(null);
+ }
+ return ok;
+ }
+
+ private boolean checkHeight() {
+ long height = !type.equals(TYPE_NEW) ? getHeight() : 0;
+ boolean ok = true;
+ if (height < 0) {
+ etWalletRestoreHeight.setError(getString(R.string.generate_restoreheight_error));
+ ok = false;
+ }
+ if (ok) {
+ etWalletRestoreHeight.setError(null);
+ }
+ return ok;
+ }
+
+ private long getHeight() {
+ long height = -1;
+
+ String restoreHeight = etWalletRestoreHeight.getEditText().getText().toString().trim();
+ if (restoreHeight.isEmpty()) return -1;
+ try {
+ // is it a date?
+ SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd");
+ parser.setLenient(false);
+ height = RestoreHeight.getInstance().getHeight(parser.parse(restoreHeight));
+ } catch (ParseException ignored) {
+ }
+ if ((height < 0) && (restoreHeight.length() == 8))
+ try {
+ // is it a date without dashes?
+ SimpleDateFormat parser = new SimpleDateFormat("yyyyMMdd");
+ parser.setLenient(false);
+ height = RestoreHeight.getInstance().getHeight(parser.parse(restoreHeight));
+ } catch (ParseException ignored) {
+ }
+ if (height < 0)
+ try {
+ // or is it a height?
+ height = Long.parseLong(restoreHeight);
+ } catch (NumberFormatException ex) {
+ return -1;
+ }
+ Timber.d("Using Restore Height = %d", height);
+ return height;
+ }
+
+ private boolean checkMnemonic() {
+ String seed = etWalletMnemonic.getEditText().getText().toString();
+ boolean ok = (seed.split("\\s").length == 25); // 25 words
+ if (!ok) {
+ etWalletMnemonic.setError(getString(R.string.generate_check_mnemonic));
+ } else {
+ etWalletMnemonic.setError(null);
+ }
+ return ok;
+ }
+
+ private boolean checkAddress() {
+ String address = etWalletAddress.getEditText().getText().toString();
+ boolean ok = Wallet.isAddressValid(address);
+ if (!ok) {
+ etWalletAddress.setError(getString(R.string.generate_check_address));
+ } else {
+ etWalletAddress.setError(null);
+ }
+ return ok;
+ }
+
+ private boolean checkViewKey() {
+ String viewKey = etWalletViewKey.getEditText().getText().toString();
+ boolean ok = (viewKey.length() == 64) && (viewKey.matches("^[0-9a-fA-F]+$"));
+ if (!ok) {
+ etWalletViewKey.setError(getString(R.string.generate_check_key));
+ } else {
+ etWalletViewKey.setError(null);
+ }
+ return ok;
+ }
+
+ private boolean checkSpendKey() {
+ String spendKey = etWalletSpendKey.getEditText().getText().toString();
+ boolean ok = ((spendKey.length() == 0) || ((spendKey.length() == 64) && (spendKey.matches("^[0-9a-fA-F]+$"))));
+ if (!ok) {
+ etWalletSpendKey.setError(getString(R.string.generate_check_key));
+ } else {
+ etWalletSpendKey.setError(null);
+ }
+ return ok;
+ }
+
+ private void generateWallet() {
+ if (!checkName()) return;
+ if (!checkHeight()) return;
+
+ String name = etWalletName.getEditText().getText().toString();
+ String password = etWalletPassword.getEditText().getText().toString();
+ boolean fingerprintAuthAllowed = ((SwitchMaterial) llFingerprintAuth.getChildAt(0)).isChecked();
+
+ // create the real wallet password
+ String crazyPass = KeyStoreHelper.getCrazyPass(getActivity(), password);
+
+ long height = getHeight();
+ if (height < 0) height = 0;
+
+ switch (type) {
+ case TYPE_NEW:
+ bGenerate.setEnabled(false);
+ if (fingerprintAuthAllowed) {
+ KeyStoreHelper.saveWalletUserPass(requireActivity(), name, password);
+ }
+ activityCallback.onGenerate(name, crazyPass);
+ break;
+ case TYPE_SEED:
+ if (!checkMnemonic()) return;
+ final String seed = etWalletMnemonic.getEditText().getText().toString();
+ bGenerate.setEnabled(false);
+ if (fingerprintAuthAllowed) {
+ KeyStoreHelper.saveWalletUserPass(requireActivity(), name, password);
+ }
+ final String offset = etSeedOffset.getEditText().getText().toString();
+ activityCallback.onGenerate(name, crazyPass, seed, offset, height);
+ break;
+ case TYPE_LEDGER:
+ bGenerate.setEnabled(false);
+ if (fingerprintAuthAllowed) {
+ KeyStoreHelper.saveWalletUserPass(requireActivity(), name, password);
+ }
+ activityCallback.onGenerateLedger(name, crazyPass, height);
+ break;
+ case TYPE_KEY:
+ case TYPE_VIEWONLY:
+ if (checkAddress() && checkViewKey() && checkSpendKey()) {
+ bGenerate.setEnabled(false);
+ String address = etWalletAddress.getEditText().getText().toString();
+ String viewKey = etWalletViewKey.getEditText().getText().toString();
+ String spendKey = "";
+ if (type.equals(TYPE_KEY)) {
+ spendKey = etWalletSpendKey.getEditText().getText().toString();
+ }
+ if (fingerprintAuthAllowed) {
+ KeyStoreHelper.saveWalletUserPass(requireActivity(), name, password);
+ }
+ activityCallback.onGenerate(name, crazyPass, address, viewKey, spendKey, height);
+ }
+ break;
+ }
+ }
+
+ public void walletGenerateError() {
+ bGenerate.setEnabled(true);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setTitle(getString(R.string.generate_title) + " - " + getType());
+ activityCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+
+ }
+
+ String getType() {
+ switch (type) {
+ case TYPE_KEY:
+ return getString(R.string.generate_wallet_type_key);
+ case TYPE_NEW:
+ return getString(R.string.generate_wallet_type_new);
+ case TYPE_SEED:
+ return getString(R.string.generate_wallet_type_seed);
+ case TYPE_LEDGER:
+ return getString(R.string.generate_wallet_type_ledger);
+ case TYPE_VIEWONLY:
+ return getString(R.string.generate_wallet_type_view);
+ default:
+ Timber.e("unknown type %s", type);
+ return "?";
+ }
+ }
+
+ GenerateFragment.Listener activityCallback;
+
+ public interface Listener {
+ void onGenerate(String name, String password);
+
+ void onGenerate(String name, String password, String seed, String offset, long height);
+
+ void onGenerate(String name, String password, String address, String viewKey, String spendKey, long height);
+
+ void onGenerateLedger(String name, String password, long height);
+
+ void setTitle(String title);
+
+ void setToolbarButton(int type);
+
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof GenerateFragment.Listener) {
+ this.activityCallback = (GenerateFragment.Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ switch (type) {
+ case TYPE_KEY:
+ inflater.inflate(R.menu.create_wallet_keys, menu);
+ break;
+ case TYPE_NEW:
+ inflater.inflate(R.menu.create_wallet_new, menu);
+ break;
+ case TYPE_SEED:
+ inflater.inflate(R.menu.create_wallet_seed, menu);
+ break;
+ case TYPE_LEDGER:
+ inflater.inflate(R.menu.create_wallet_ledger, menu);
+ break;
+ case TYPE_VIEWONLY:
+ inflater.inflate(R.menu.create_wallet_view, menu);
+ break;
+ default:
+ }
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ AlertDialog ledgerDialog = null;
+
+ public void convertLedgerSeed() {
+ if (ledgerDialog != null) return;
+ final Activity activity = requireActivity();
+ View promptsView = getLayoutInflater().inflate(R.layout.prompt_ledger_seed, null);
+ MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(activity);
+ alertDialogBuilder.setView(promptsView);
+
+ final TextInputLayout etSeed = promptsView.findViewById(R.id.etSeed);
+ final TextInputLayout etPassphrase = promptsView.findViewById(R.id.etPassphrase);
+
+ clearErrorOnTextEntry(etSeed);
+
+ alertDialogBuilder
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok), null)
+ .setNegativeButton(getString(R.string.label_cancel),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ Helper.hideKeyboardAlways(activity);
+ etWalletMnemonic.getEditText().getText().clear();
+ dialog.cancel();
+ ledgerDialog = null;
+ }
+ });
+
+ ledgerDialog = alertDialogBuilder.create();
+
+ ledgerDialog.setOnShowListener(dialog -> {
+ Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE);
+ button.setOnClickListener(view -> {
+ String ledgerSeed = etSeed.getEditText().getText().toString();
+ String ledgerPassphrase = etPassphrase.getEditText().getText().toString();
+ String moneroSeed = Monero.convert(ledgerSeed, ledgerPassphrase);
+ if (moneroSeed != null) {
+ etWalletMnemonic.getEditText().setText(moneroSeed);
+ ledgerDialog.dismiss();
+ ledgerDialog = null;
+ } else {
+ etSeed.setError(getString(R.string.bad_ledger_seed));
+ }
+ });
+ });
+
+ if (Helper.preventScreenshot()) {
+ ledgerDialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+
+ ledgerDialog.show();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/GenerateReviewFragment.java b/app/src/main/java/com/m2049r/xmrwallet/GenerateReviewFragment.java
new file mode 100644
index 0000000..cb40b84
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/GenerateReviewFragment.java
@@ -0,0 +1,711 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.xmrwallet.ledger.Ledger;
+import com.m2049r.xmrwallet.ledger.LedgerProgressDialog;
+import com.m2049r.xmrwallet.model.NetworkType;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.FingerprintHelper;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.KeyStoreHelper;
+import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
+import com.m2049r.xmrwallet.widget.PasswordEntryView;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.text.NumberFormat;
+
+import timber.log.Timber;
+
+public class GenerateReviewFragment extends Fragment {
+ static final public String VIEW_TYPE_DETAILS = "details";
+ static final public String VIEW_TYPE_ACCEPT = "accept";
+ static final public String VIEW_TYPE_WALLET = "wallet";
+
+ public static final String REQUEST_TYPE = "type";
+ public static final String REQUEST_PATH = "path";
+ public static final String REQUEST_PASSWORD = "password";
+
+ private ScrollView scrollview;
+
+ private ProgressBar pbProgress;
+ private TextView tvWalletPassword;
+ private TextView tvWalletAddress;
+ private FrameLayout flWalletMnemonic;
+ private TextView tvWalletMnemonic;
+ private TextView tvWalletHeight;
+ private TextView tvWalletViewKey;
+ private TextView tvWalletSpendKey;
+ private ImageButton bCopyAddress;
+ private LinearLayout llAdvancedInfo;
+ private LinearLayout llPassword;
+ private LinearLayout llMnemonic;
+ private LinearLayout llSpendKey;
+ private LinearLayout llViewKey;
+ private Button bAdvancedInfo;
+ private Button bAccept;
+
+ private Button bSeedOffset;
+ private TextInputLayout etSeedOffset;
+
+ private String walletPath;
+ private String walletName;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ View view = inflater.inflate(R.layout.fragment_review, container, false);
+
+ scrollview = view.findViewById(R.id.scrollview);
+ pbProgress = view.findViewById(R.id.pbProgress);
+ tvWalletPassword = view.findViewById(R.id.tvWalletPassword);
+ tvWalletAddress = view.findViewById(R.id.tvWalletAddress);
+ tvWalletViewKey = view.findViewById(R.id.tvWalletViewKey);
+ tvWalletSpendKey = view.findViewById(R.id.tvWalletSpendKey);
+ tvWalletMnemonic = view.findViewById(R.id.tvWalletMnemonic);
+ flWalletMnemonic = view.findViewById(R.id.flWalletMnemonic);
+ tvWalletHeight = view.findViewById(R.id.tvWalletHeight);
+ bCopyAddress = view.findViewById(R.id.bCopyAddress);
+ bAdvancedInfo = view.findViewById(R.id.bAdvancedInfo);
+ llAdvancedInfo = view.findViewById(R.id.llAdvancedInfo);
+ llPassword = view.findViewById(R.id.llPassword);
+ llMnemonic = view.findViewById(R.id.llMnemonic);
+ llSpendKey = view.findViewById(R.id.llSpendKey);
+ llViewKey = view.findViewById(R.id.llViewKey);
+
+ etSeedOffset = view.findViewById(R.id.etSeedOffset);
+ bSeedOffset = view.findViewById(R.id.bSeedOffset);
+
+ bAccept = view.findViewById(R.id.bAccept);
+
+ boolean allowCopy = WalletManager.getInstance().getNetworkType() != NetworkType.NetworkType_Mainnet;
+ tvWalletMnemonic.setTextIsSelectable(allowCopy);
+ tvWalletSpendKey.setTextIsSelectable(allowCopy);
+ tvWalletPassword.setTextIsSelectable(allowCopy);
+
+ bAccept.setOnClickListener(v -> acceptWallet());
+ view.findViewById(R.id.bCopyViewKey).setOnClickListener(v -> copyViewKey());
+ bCopyAddress.setEnabled(false);
+ bCopyAddress.setOnClickListener(v -> copyAddress());
+ bAdvancedInfo.setOnClickListener(v -> toggleAdvancedInfo());
+
+ bSeedOffset.setOnClickListener(v -> toggleSeedOffset());
+ etSeedOffset.getEditText().addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable s) {
+ showSeed();
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ }
+ });
+
+ Bundle args = getArguments();
+ type = args.getString(REQUEST_TYPE);
+ walletPath = args.getString(REQUEST_PATH);
+ localPassword = args.getString(REQUEST_PASSWORD);
+ showDetails();
+ return view;
+ }
+
+ String getSeedOffset() {
+ return etSeedOffset.getEditText().getText().toString();
+ }
+
+ boolean seedOffsetInProgress = false;
+
+ void showSeed() {
+ synchronized (this) {
+ if (seedOffsetInProgress) return;
+ seedOffsetInProgress = true;
+ }
+ new AsyncShowSeed().executeOnExecutor(MoneroThreadPoolExecutor.MONERO_THREAD_POOL_EXECUTOR, walletPath);
+ }
+
+ void showDetails() {
+ tvWalletPassword.setText(null);
+ new AsyncShow().executeOnExecutor(MoneroThreadPoolExecutor.MONERO_THREAD_POOL_EXECUTOR, walletPath);
+ }
+
+ void copyViewKey() {
+ Helper.clipBoardCopy(requireActivity(), getString(R.string.label_copy_viewkey), tvWalletViewKey.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_viewkey), Toast.LENGTH_SHORT).show();
+ }
+
+ void copyAddress() {
+ Helper.clipBoardCopy(requireActivity(), getString(R.string.label_copy_address), tvWalletAddress.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_address), Toast.LENGTH_SHORT).show();
+ }
+
+ void nocopy() {
+ Toast.makeText(getActivity(), getString(R.string.message_nocopy), Toast.LENGTH_SHORT).show();
+ }
+
+ void toggleAdvancedInfo() {
+ if (llAdvancedInfo.getVisibility() == View.VISIBLE) {
+ llAdvancedInfo.setVisibility(View.GONE);
+ bAdvancedInfo.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_down, 0, 0, 0);
+ } else {
+ llAdvancedInfo.setVisibility(View.VISIBLE);
+ bAdvancedInfo.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_up, 0, 0, 0);
+ scrollview.post(() -> scrollview.fullScroll(ScrollView.FOCUS_DOWN));
+ }
+ }
+
+ void toggleSeedOffset() {
+ if (etSeedOffset.getVisibility() == View.VISIBLE) {
+ etSeedOffset.getEditText().getText().clear();
+ etSeedOffset.setVisibility(View.GONE);
+ bSeedOffset.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_down, 0, 0, 0);
+ } else {
+ etSeedOffset.setVisibility(View.VISIBLE);
+ bSeedOffset.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_keyboard_arrow_up, 0, 0, 0);
+ etSeedOffset.requestFocusFromTouch();
+ }
+ }
+
+ String type;
+
+ private void acceptWallet() {
+ bAccept.setEnabled(false);
+ acceptCallback.onAccept(walletName, getPassword());
+ }
+
+ private class AsyncShow extends AsyncTask {
+ String name;
+ String address;
+ long height;
+ String seed;
+ String viewKey;
+ String spendKey;
+ Wallet.Status walletStatus;
+
+ boolean dialogOpened = false;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ showProgress();
+ if ((walletPath != null)
+ && (WalletManager.getInstance().queryWalletDevice(walletPath + ".keys", getPassword())
+ == Wallet.Device.Device_Ledger)
+ && (progressCallback != null)) {
+ progressCallback.showLedgerProgressDialog(LedgerProgressDialog.TYPE_RESTORE);
+ dialogOpened = true;
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(String... params) {
+ if (params.length != 1) return false;
+ String walletPath = params[0];
+
+ Wallet wallet;
+ boolean closeWallet;
+ if (type.equals(GenerateReviewFragment.VIEW_TYPE_WALLET)) {
+ wallet = GenerateReviewFragment.this.walletCallback.getWallet();
+ closeWallet = false;
+ } else {
+ wallet = WalletManager.getInstance().openWallet(walletPath, getPassword());
+ closeWallet = true;
+ }
+ name = wallet.getName();
+ walletStatus = wallet.getStatus();
+ if (!walletStatus.isOk()) {
+ if (closeWallet) wallet.close();
+ return false;
+ }
+
+ address = wallet.getAddress();
+ height = wallet.getRestoreHeight();
+ seed = wallet.getSeed(getSeedOffset());
+ switch (wallet.getDeviceType()) {
+ case Device_Ledger:
+ viewKey = Ledger.Key();
+ break;
+ case Device_Software:
+ viewKey = wallet.getSecretViewKey();
+ break;
+ default:
+ throw new IllegalStateException("Hardware backing not supported. At all!");
+ }
+ spendKey = wallet.isWatchOnly() ? getActivity().getString(R.string.label_watchonly) : wallet.getSecretSpendKey();
+ if (closeWallet) wallet.close();
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (dialogOpened)
+ progressCallback.dismissProgressDialog();
+ if (!isAdded()) return; // never mind
+ walletName = name;
+ if (result) {
+ if (type.equals(GenerateReviewFragment.VIEW_TYPE_ACCEPT)) {
+ bAccept.setVisibility(View.VISIBLE);
+ bAccept.setEnabled(true);
+ }
+ llPassword.setVisibility(View.VISIBLE);
+ tvWalletPassword.setText(getPassword());
+ tvWalletAddress.setText(address);
+ tvWalletHeight.setText(NumberFormat.getInstance().format(height));
+ if (!seed.isEmpty()) {
+ llMnemonic.setVisibility(View.VISIBLE);
+ tvWalletMnemonic.setText(seed);
+ }
+ boolean showAdvanced = false;
+ if (isKeyValid(viewKey)) {
+ llViewKey.setVisibility(View.VISIBLE);
+ tvWalletViewKey.setText(viewKey);
+ showAdvanced = true;
+ }
+ if (isKeyValid(spendKey)) {
+ llSpendKey.setVisibility(View.VISIBLE);
+ tvWalletSpendKey.setText(spendKey);
+ showAdvanced = true;
+ }
+ if (showAdvanced) bAdvancedInfo.setVisibility(View.VISIBLE);
+ bCopyAddress.setEnabled(true);
+ activityCallback.setTitle(name, getString(R.string.details_title));
+ activityCallback.setToolbarButton(
+ GenerateReviewFragment.VIEW_TYPE_ACCEPT.equals(type) ? Toolbar.BUTTON_NONE : Toolbar.BUTTON_BACK);
+ } else {
+ // TODO show proper error message and/or end the fragment?
+ tvWalletAddress.setText(walletStatus.toString());
+ tvWalletHeight.setText(walletStatus.toString());
+ tvWalletMnemonic.setText(walletStatus.toString());
+ tvWalletViewKey.setText(walletStatus.toString());
+ tvWalletSpendKey.setText(walletStatus.toString());
+ }
+ hideProgress();
+ }
+ }
+
+ Listener activityCallback = null;
+ ProgressListener progressCallback = null;
+ AcceptListener acceptCallback = null;
+ ListenerWithWallet walletCallback = null;
+ PasswordChangedListener passwordCallback = null;
+
+ public interface Listener {
+ void setTitle(String title, String subtitle);
+
+ void setToolbarButton(int type);
+ }
+
+ public interface ProgressListener {
+ void showProgressDialog(int msgId);
+
+ void showLedgerProgressDialog(int mode);
+
+ void dismissProgressDialog();
+ }
+
+ public interface AcceptListener {
+ void onAccept(String name, String password);
+ }
+
+ public interface ListenerWithWallet {
+ Wallet getWallet();
+ }
+
+ public interface PasswordChangedListener {
+ void onPasswordChanged(String newPassword);
+
+ String getPassword();
+ }
+
+ private String localPassword = null;
+
+ private String getPassword() {
+ if (passwordCallback != null) return passwordCallback.getPassword();
+ return localPassword;
+ }
+
+ private void setPassword(String password) {
+ if (passwordCallback != null) passwordCallback.onPasswordChanged(password);
+ else localPassword = password;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.activityCallback = (Listener) context;
+ }
+ if (context instanceof ProgressListener) {
+ this.progressCallback = (ProgressListener) context;
+ }
+ if (context instanceof AcceptListener) {
+ this.acceptCallback = (AcceptListener) context;
+ }
+ if (context instanceof ListenerWithWallet) {
+ this.walletCallback = (ListenerWithWallet) context;
+ }
+ if (context instanceof PasswordChangedListener) {
+ this.passwordCallback = (PasswordChangedListener) context;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setTitle(walletName, getString(R.string.details_title));
+ activityCallback.setToolbarButton(
+ GenerateReviewFragment.VIEW_TYPE_ACCEPT.equals(type) ? Toolbar.BUTTON_NONE : Toolbar.BUTTON_BACK);
+ }
+
+ public void showProgress() {
+ pbProgress.setVisibility(View.VISIBLE);
+ }
+
+ public void hideProgress() {
+ pbProgress.setVisibility(View.INVISIBLE);
+ }
+
+ boolean backOk() {
+ return !type.equals(GenerateReviewFragment.VIEW_TYPE_ACCEPT);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+ String type = getArguments().getString(REQUEST_TYPE); // intance variable not set yet
+ if (GenerateReviewFragment.VIEW_TYPE_ACCEPT.equals(type)) {
+ inflater.inflate(R.menu.wallet_details_help_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ } else {
+ inflater.inflate(R.menu.wallet_details_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+ }
+
+ boolean changeWalletPassword(String newPassword) {
+ Wallet wallet;
+ boolean closeWallet;
+ if (type.equals(GenerateReviewFragment.VIEW_TYPE_WALLET)) {
+ wallet = GenerateReviewFragment.this.walletCallback.getWallet();
+ closeWallet = false;
+ } else {
+ wallet = WalletManager.getInstance().openWallet(walletPath, getPassword());
+ closeWallet = true;
+ }
+
+ boolean ok = false;
+ Wallet.Status walletStatus = wallet.getStatus();
+ if (walletStatus.isOk()) {
+ wallet.setPassword(newPassword);
+ ok = true;
+ }
+ if (closeWallet) wallet.close();
+ return ok;
+ }
+
+ private class AsyncChangePassword extends AsyncTask {
+ String newPassword;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (progressCallback != null)
+ progressCallback.showProgressDialog(R.string.changepw_progress);
+ }
+
+ @Override
+ protected Boolean doInBackground(String... params) {
+ if (params.length != 2) return false;
+ final String userPassword = params[0];
+ final boolean fingerPassValid = Boolean.parseBoolean(params[1]);
+ newPassword = KeyStoreHelper.getCrazyPass(getActivity(), userPassword);
+ final boolean success = changeWalletPassword(newPassword);
+ if (success) {
+ Context ctx = getActivity();
+ if (ctx != null)
+ if (fingerPassValid) {
+ KeyStoreHelper.saveWalletUserPass(ctx, walletName, userPassword);
+ } else {
+ KeyStoreHelper.removeWalletUserPass(ctx, walletName);
+ }
+ }
+ return success;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if ((getActivity() == null) || getActivity().isDestroyed()) {
+ return;
+ }
+ if (progressCallback != null)
+ progressCallback.dismissProgressDialog();
+ if (result) {
+ Toast.makeText(getActivity(), getString(R.string.changepw_success), Toast.LENGTH_SHORT).show();
+ setPassword(newPassword);
+ showDetails();
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.changepw_failed), Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ AlertDialog openDialog = null; // for preventing opening of multiple dialogs
+
+ public AlertDialog createChangePasswordDialog() {
+ if (openDialog != null) return null; // we are already open
+ LayoutInflater li = LayoutInflater.from(getActivity());
+ View promptsView = li.inflate(R.layout.prompt_changepw, null);
+
+ AlertDialog.Builder alertDialogBuilder = new MaterialAlertDialogBuilder(requireActivity());
+ alertDialogBuilder.setView(promptsView);
+
+ final PasswordEntryView etPasswordA = promptsView.findViewById(R.id.etWalletPasswordA);
+ etPasswordA.setHint(getString(R.string.prompt_changepw, walletName));
+
+ final TextInputLayout etPasswordB = promptsView.findViewById(R.id.etWalletPasswordB);
+ etPasswordB.setHint(getString(R.string.prompt_changepwB, walletName));
+
+ LinearLayout llFingerprintAuth = promptsView.findViewById(R.id.llFingerprintAuth);
+ final SwitchMaterial swFingerprintAllowed = (SwitchMaterial) llFingerprintAuth.getChildAt(0);
+ if (FingerprintHelper.isDeviceSupported(getActivity())) {
+ llFingerprintAuth.setVisibility(View.VISIBLE);
+
+ swFingerprintAllowed.setOnClickListener(view -> {
+ if (!swFingerprintAllowed.isChecked()) return;
+
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireActivity());
+ builder.setMessage(Html.fromHtml(getString(R.string.generate_fingerprint_warn)))
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok), null)
+ .setNegativeButton(getString(R.string.label_cancel), new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ swFingerprintAllowed.setChecked(false);
+ }
+ })
+ .show();
+ });
+
+ swFingerprintAllowed.setChecked(FingerprintHelper.isFingerPassValid(getActivity(), walletName));
+ }
+
+ etPasswordA.getEditText().addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (etPasswordB.getError() != null) {
+ etPasswordB.setError(null);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ }
+ });
+
+ etPasswordB.getEditText().addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (etPasswordB.getError() != null) {
+ etPasswordB.setError(null);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ }
+ });
+
+ // set dialog message
+ alertDialogBuilder
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok), null)
+ .setNegativeButton(getString(R.string.label_cancel),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ Helper.hideKeyboardAlways(requireActivity());
+ dialog.cancel();
+ openDialog = null;
+ }
+ });
+
+ openDialog = alertDialogBuilder.create();
+ openDialog.setOnShowListener(dialog -> {
+ Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE);
+ button.setOnClickListener(view -> {
+ String newPasswordA = etPasswordA.getEditText().getText().toString();
+ String newPasswordB = etPasswordB.getEditText().getText().toString();
+ if (!newPasswordA.equals(newPasswordB)) {
+ etPasswordB.setError(getString(R.string.generate_bad_passwordB));
+ } else {
+ new AsyncChangePassword().execute(newPasswordA, Boolean.toString(swFingerprintAllowed.isChecked()));
+ Helper.hideKeyboardAlways(requireActivity());
+ openDialog.dismiss();
+ openDialog = null;
+ }
+ });
+ });
+
+ // accept keyboard "ok"
+ etPasswordB.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ String newPasswordA = etPasswordA.getEditText().getText().toString();
+ String newPasswordB = etPasswordB.getEditText().getText().toString();
+ // disallow empty passwords
+ if (!newPasswordA.equals(newPasswordB)) {
+ etPasswordB.setError(getString(R.string.generate_bad_passwordB));
+ } else {
+ new AsyncChangePassword().execute(newPasswordA, Boolean.toString(swFingerprintAllowed.isChecked()));
+ Helper.hideKeyboardAlways(requireActivity());
+ openDialog.dismiss();
+ openDialog = null;
+ }
+ return true;
+ }
+ return false;
+ });
+
+ if (Helper.preventScreenshot()) {
+ openDialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+
+ return openDialog;
+ }
+
+ private boolean isKeyValid(String key) {
+ return (key != null) && (key.length() == 64)
+ && !key.equals("0000000000000000000000000000000000000000000000000000000000000000")
+ && !key.toLowerCase().equals("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
+ // ledger implmenetation returns the spend key as f's
+ }
+
+ private class AsyncShowSeed extends AsyncTask {
+ String seed;
+ String offset;
+ Wallet.Status walletStatus;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ offset = getSeedOffset();
+ flWalletMnemonic.setAlpha(0.1f);
+ }
+
+ @Override
+ protected Boolean doInBackground(String... params) {
+ if (params.length != 1) return false;
+ String walletPath = params[0];
+
+ Wallet wallet;
+ boolean closeWallet;
+ if (type.equals(GenerateReviewFragment.VIEW_TYPE_WALLET)) {
+ wallet = GenerateReviewFragment.this.walletCallback.getWallet();
+ closeWallet = false;
+ } else {
+ wallet = WalletManager.getInstance().openWallet(walletPath, getPassword());
+ closeWallet = true;
+ }
+ walletStatus = wallet.getStatus();
+ if (!walletStatus.isOk()) {
+ if (closeWallet) wallet.close();
+ return false;
+ }
+
+ seed = wallet.getSeed(offset);
+ if (closeWallet) wallet.close();
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (!isAdded()) return; // never mind
+ if (result) {
+ if (!seed.isEmpty()) {
+ llMnemonic.setVisibility(View.VISIBLE);
+ tvWalletMnemonic.setText(seed);
+ }
+ } else {
+ tvWalletMnemonic.setText(walletStatus.toString());
+ }
+ seedOffsetInProgress = false;
+ if (!getSeedOffset().equals(offset)) { // make sure we have encrypted with the correct offset
+ showSeed(); // seed has changed in the meantime - recalc
+ } else
+ flWalletMnemonic.setAlpha(1);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/LoginActivity.java b/app/src/main/java/com/m2049r/xmrwallet/LoginActivity.java
new file mode 100644
index 0000000..0cb2991
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/LoginActivity.java
@@ -0,0 +1,1469 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbManager;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+
+import com.google.android.material.checkbox.MaterialCheckBox;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.m2049r.xmrwallet.data.DefaultNodes;
+import com.m2049r.xmrwallet.data.Node;
+import com.m2049r.xmrwallet.data.NodeInfo;
+import com.m2049r.xmrwallet.dialog.CreditsFragment;
+import com.m2049r.xmrwallet.dialog.HelpFragment;
+import com.m2049r.xmrwallet.ledger.Ledger;
+import com.m2049r.xmrwallet.ledger.LedgerProgressDialog;
+import com.m2049r.xmrwallet.model.NetworkType;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.service.WalletService;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.KeyStoreHelper;
+import com.m2049r.xmrwallet.util.LegacyStorageHelper;
+import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
+import com.m2049r.xmrwallet.util.NetCipherHelper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.util.ZipBackup;
+import com.m2049r.xmrwallet.util.ZipRestore;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import timber.log.Timber;
+
+public class LoginActivity extends BaseActivity
+ implements LoginFragment.Listener, GenerateFragment.Listener,
+ GenerateReviewFragment.Listener, GenerateReviewFragment.AcceptListener,
+ NodeFragment.Listener, SettingsFragment.Listener {
+ private static final String GENERATE_STACK = "gen";
+
+ private static final String NODES_PREFS_NAME = "nodes";
+ private static final String SELECTED_NODE_PREFS_NAME = "selected_node";
+ private static final String PREF_DAEMON_TESTNET = "daemon_testnet";
+ private static final String PREF_DAEMON_STAGENET = "daemon_stagenet";
+ private static final String PREF_DAEMON_MAINNET = "daemon_mainnet";
+
+ private NodeInfo node = null;
+
+ Set favouriteNodes = new HashSet<>();
+
+ @Override
+ public NodeInfo getNode() {
+ return node;
+ }
+
+ @Override
+ public void setNode(NodeInfo node) {
+ setNode(node, true);
+ }
+
+ private void setNode(NodeInfo node, boolean save) {
+ if (node != this.node) {
+ if ((node != null) && (node.getNetworkType() != WalletManager.getInstance().getNetworkType()))
+ throw new IllegalArgumentException("network type does not match");
+ this.node = node;
+ for (NodeInfo nodeInfo : favouriteNodes) {
+ nodeInfo.setSelected(nodeInfo == node);
+ }
+ WalletManager.getInstance().setDaemon(node);
+ if (save)
+ saveSelectedNode();
+ }
+ }
+
+ @Override
+ public Set getFavouriteNodes() {
+ return favouriteNodes;
+ }
+
+ @Override
+ public Set getOrPopulateFavourites() {
+ if (favouriteNodes.isEmpty()) {
+ for (DefaultNodes node : DefaultNodes.values()) {
+ NodeInfo nodeInfo = NodeInfo.fromString(node.getUri());
+ if (nodeInfo != null) {
+ nodeInfo.setFavourite(true);
+ favouriteNodes.add(nodeInfo);
+ }
+ }
+ saveFavourites();
+ }
+ return favouriteNodes;
+ }
+
+ @Override
+ public void setFavouriteNodes(Collection nodes) {
+ Timber.d("adding %d nodes", nodes.size());
+ favouriteNodes.clear();
+ for (NodeInfo node : nodes) {
+ Timber.d("adding %s %b", node, node.isFavourite());
+ if (node.isFavourite())
+ favouriteNodes.add(node);
+ }
+ saveFavourites();
+ }
+
+ private void loadFavouritesWithNetwork() {
+ Helper.runWithNetwork(() -> {
+ loadFavourites();
+ return true;
+ });
+ }
+
+ private void loadFavourites() {
+ Timber.d("loadFavourites");
+ favouriteNodes.clear();
+ final String selectedNodeId = getSelectedNodeId();
+ Map storedNodes = getSharedPreferences(NODES_PREFS_NAME, Context.MODE_PRIVATE).getAll();
+ for (Map.Entry nodeEntry : storedNodes.entrySet()) {
+ if (nodeEntry != null) { // just in case, ignore possible future errors
+ final String nodeId = (String) nodeEntry.getValue();
+ final NodeInfo addedNode = addFavourite(nodeId);
+ if (addedNode != null) {
+ if (nodeId.equals(selectedNodeId)) {
+ addedNode.setSelected(true);
+ }
+ }
+ }
+ }
+ if (storedNodes.isEmpty()) { // try to load legacy list & remove it (i.e. migrate the data once)
+ SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE);
+ switch (WalletManager.getInstance().getNetworkType()) {
+ case NetworkType_Mainnet:
+ loadLegacyList(sharedPref.getString(PREF_DAEMON_MAINNET, null));
+ sharedPref.edit().remove(PREF_DAEMON_MAINNET).apply();
+ break;
+ case NetworkType_Stagenet:
+ loadLegacyList(sharedPref.getString(PREF_DAEMON_STAGENET, null));
+ sharedPref.edit().remove(PREF_DAEMON_STAGENET).apply();
+ break;
+ case NetworkType_Testnet:
+ loadLegacyList(sharedPref.getString(PREF_DAEMON_TESTNET, null));
+ sharedPref.edit().remove(PREF_DAEMON_TESTNET).apply();
+ break;
+ default:
+ throw new IllegalStateException("unsupported net " + WalletManager.getInstance().getNetworkType());
+ }
+ }
+ }
+
+ private void saveFavourites() {
+ Timber.d("SAVE");
+ SharedPreferences.Editor editor = getSharedPreferences(NODES_PREFS_NAME, Context.MODE_PRIVATE).edit();
+ editor.clear();
+ int i = 1;
+ for (Node info : favouriteNodes) {
+ final String nodeString = info.toNodeString();
+ editor.putString(Integer.toString(i), nodeString);
+ Timber.d("saved %d:%s", i, nodeString);
+ i++;
+ }
+ editor.apply();
+ }
+
+ private NodeInfo addFavourite(String nodeString) {
+ final NodeInfo nodeInfo = NodeInfo.fromString(nodeString);
+ if (nodeInfo != null) {
+ nodeInfo.setFavourite(true);
+ favouriteNodes.add(nodeInfo);
+ } else
+ Timber.w("nodeString invalid: %s", nodeString);
+ return nodeInfo;
+ }
+
+ private void loadLegacyList(final String legacyListString) {
+ if (legacyListString == null) return;
+ final String[] nodeStrings = legacyListString.split(";");
+ for (final String nodeString : nodeStrings) {
+ addFavourite(nodeString);
+ }
+ }
+
+ private void saveSelectedNode() { // save only if changed
+ final NodeInfo nodeInfo = getNode();
+ final String selectedNodeId = getSelectedNodeId();
+ if (nodeInfo != null) {
+ if (!nodeInfo.toNodeString().equals(selectedNodeId))
+ saveSelectedNode(nodeInfo);
+ } else {
+ if (selectedNodeId != null)
+ saveSelectedNode(null);
+ }
+ }
+
+ private void saveSelectedNode(NodeInfo nodeInfo) {
+ SharedPreferences.Editor editor = getSharedPreferences(SELECTED_NODE_PREFS_NAME, Context.MODE_PRIVATE).edit();
+ if (nodeInfo == null) {
+ editor.clear();
+ } else {
+ editor.putString("0", getNode().toNodeString());
+ }
+ editor.apply();
+ }
+
+ private String getSelectedNodeId() {
+ return getSharedPreferences(SELECTED_NODE_PREFS_NAME, Context.MODE_PRIVATE)
+ .getString("0", null);
+ }
+
+
+ private Toolbar toolbar;
+
+ @Override
+ public void setToolbarButton(int type) {
+ toolbar.setButton(type);
+ }
+
+ @Override
+ public void setTitle(String title) {
+ toolbar.setTitle(title);
+ }
+
+ @Override
+ public void setSubtitle(String subtitle) {
+ toolbar.setSubtitle(subtitle);
+ }
+
+ @Override
+ public void setTitle(String title, String subtitle) {
+ toolbar.setTitle(title, subtitle);
+ }
+
+ @Override
+ public boolean hasLedger() {
+ return Ledger.isConnected();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Timber.d("onCreate()");
+ ThemeHelper.setPreferred(this);
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_login);
+ toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ getSupportActionBar().setDisplayShowTitleEnabled(false);
+
+ toolbar.setOnButtonListener(type -> {
+ switch (type) {
+ case Toolbar.BUTTON_BACK:
+ onBackPressed();
+ break;
+ case Toolbar.BUTTON_CLOSE:
+ finish();
+ break;
+ case Toolbar.BUTTON_SETTINGS:
+ startSettingsFragment();
+ break;
+ case Toolbar.BUTTON_NONE:
+ break;
+ default:
+ Timber.e("Button " + type + "pressed - how can this be?");
+ }
+ });
+
+ loadFavouritesWithNetwork();
+
+ LegacyStorageHelper.migrateWallets(this);
+
+ if (savedInstanceState == null) startLoginFragment();
+
+ // try intents
+ Intent intent = getIntent();
+ if (!processUsbIntent(intent))
+ processUriIntent(intent);
+ }
+
+ boolean checkServiceRunning() {
+ if (WalletService.Running) {
+ Toast.makeText(this, getString(R.string.service_busy), Toast.LENGTH_SHORT).show();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onWalletSelected(String walletName, boolean streetmode) {
+ if (node == null) {
+ Toast.makeText(this, getString(R.string.prompt_daemon_missing), Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ if (checkServiceRunning()) return false;
+ try {
+ new AsyncOpenWallet(walletName, node, streetmode).execute();
+ } catch (IllegalArgumentException ex) {
+ Timber.e(ex.getLocalizedMessage());
+ Toast.makeText(this, ex.getLocalizedMessage(), Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onWalletDetails(final String walletName) {
+ Timber.d("details for wallet .%s.", walletName);
+ if (checkServiceRunning()) return;
+ DialogInterface.OnClickListener dialogClickListener = (dialog, which) -> {
+ switch (which) {
+ case DialogInterface.BUTTON_POSITIVE:
+ final File walletFile = Helper.getWalletFile(LoginActivity.this, walletName);
+ if (WalletManager.getInstance().walletExists(walletFile)) {
+ Helper.promptPassword(LoginActivity.this, walletName, true, new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName1, String password, boolean fingerprintUsed) {
+ if (checkDevice(walletName1, password))
+ startDetails(walletFile, password, GenerateReviewFragment.VIEW_TYPE_DETAILS);
+ }
+
+ @Override
+ public void fail(String walletName) {
+ }
+ });
+ } else { // this cannot really happen as we prefilter choices
+ Timber.e("Wallet missing: %s", walletName);
+ Toast.makeText(LoginActivity.this, getString(R.string.bad_wallet), Toast.LENGTH_SHORT).show();
+ }
+ break;
+
+ case DialogInterface.BUTTON_NEGATIVE:
+ // do nothing
+ break;
+ }
+ };
+
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
+ builder.setMessage(getString(R.string.details_alert_message))
+ .setPositiveButton(getString(R.string.details_alert_yes), dialogClickListener)
+ .setNegativeButton(getString(R.string.details_alert_no), dialogClickListener)
+ .show();
+ }
+
+ private void renameWallet(String oldName, String newName) {
+ File walletFile = Helper.getWalletFile(this, oldName);
+ boolean success = renameWallet(walletFile, newName);
+ if (success) {
+ reloadWalletList();
+ } else {
+ Toast.makeText(LoginActivity.this, getString(R.string.rename_failed), Toast.LENGTH_LONG).show();
+ }
+ }
+
+ // copy + delete seems safer than rename because we can rollback easily
+ boolean renameWallet(File walletFile, String newName) {
+ if (copyWallet(walletFile, new File(walletFile.getParentFile(), newName), false, true)) {
+ try {
+ KeyStoreHelper.copyWalletUserPass(this, walletFile.getName(), newName);
+ } catch (KeyStoreHelper.BrokenPasswordStoreException ex) {
+ Timber.w(ex);
+ }
+ deleteWallet(walletFile); // also deletes the keystore entry
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void onWalletRename(final String walletName) {
+ Timber.d("rename for wallet ." + walletName + ".");
+ if (checkServiceRunning()) return;
+ LayoutInflater li = LayoutInflater.from(this);
+ View promptsView = li.inflate(R.layout.prompt_rename, null);
+
+ AlertDialog.Builder alertDialogBuilder = new MaterialAlertDialogBuilder(this);
+ alertDialogBuilder.setView(promptsView);
+
+ final EditText etRename = promptsView.findViewById(R.id.etRename);
+ final TextView tvRenameLabel = promptsView.findViewById(R.id.tvRenameLabel);
+
+ tvRenameLabel.setText(getString(R.string.prompt_rename, walletName));
+
+ // set dialog message
+ alertDialogBuilder
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok),
+ (dialog, id) -> {
+ Helper.hideKeyboardAlways(LoginActivity.this);
+ String newName = etRename.getText().toString();
+ renameWallet(walletName, newName);
+ })
+ .setNegativeButton(getString(R.string.label_cancel),
+ (dialog, id) -> {
+ Helper.hideKeyboardAlways(LoginActivity.this);
+ dialog.cancel();
+ });
+
+ final AlertDialog dialog = alertDialogBuilder.create();
+ Helper.showKeyboard(dialog);
+
+ // accept keyboard "ok"
+ etRename.setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ Helper.hideKeyboardAlways(LoginActivity.this);
+ String newName = etRename.getText().toString();
+ dialog.cancel();
+ renameWallet(walletName, newName);
+ return false;
+ }
+ return false;
+ });
+
+ dialog.show();
+ }
+
+ private static final int CREATE_BACKUP_INTENT = 4711;
+ private static final int RESTORE_BACKUP_INTENT = 4712;
+ private ZipBackup zipBackup;
+
+ @Override
+ public void onWalletBackup(String walletName) {
+ Timber.d("backup for wallet ." + walletName + ".");
+ // overwrite any pending backup request
+ zipBackup = new ZipBackup(this, walletName);
+
+ Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("application/zip");
+ intent.putExtra(Intent.EXTRA_TITLE, zipBackup.getBackupName());
+ startActivityForResult(intent, CREATE_BACKUP_INTENT);
+ }
+
+ @Override
+ public void onWalletRestore() {
+ Timber.d("restore wallet");
+
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("application/zip");
+ startActivityForResult(intent, RESTORE_BACKUP_INTENT);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == CREATE_BACKUP_INTENT) {
+ if (data == null) {
+ // nothing selected
+ Toast.makeText(this, getString(R.string.backup_failed), Toast.LENGTH_LONG).show();
+ zipBackup = null;
+ return;
+ }
+ try {
+ if (zipBackup == null) return; // ignore unsolicited request
+ zipBackup.writeTo(data.getData());
+ Toast.makeText(this, getString(R.string.backup_success), Toast.LENGTH_SHORT).show();
+ } catch (IOException ex) {
+ Timber.e(ex);
+ Toast.makeText(this, getString(R.string.backup_failed), Toast.LENGTH_LONG).show();
+ } finally {
+ zipBackup = null;
+ }
+ } else if (requestCode == RESTORE_BACKUP_INTENT) {
+ if (data == null) {
+ // nothing selected
+ Toast.makeText(this, getString(R.string.restore_failed), Toast.LENGTH_LONG).show();
+ return;
+ }
+ try {
+ ZipRestore zipRestore = new ZipRestore(this, data.getData());
+ Toast.makeText(this, getString(R.string.menu_restore), Toast.LENGTH_SHORT).show();
+ if (zipRestore.restore()) {
+ reloadWalletList();
+ } else {
+ Toast.makeText(this, getString(R.string.restore_failed), Toast.LENGTH_LONG).show();
+ }
+ } catch (IOException ex) {
+ Timber.e(ex);
+ Toast.makeText(this, getString(R.string.restore_failed), Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onWalletDelete(final String walletName) {
+ Timber.d("delete for wallet ." + walletName + ".");
+ if (checkServiceRunning()) return;
+ DialogInterface.OnClickListener dialogClickListener = (dialog, which) -> {
+ switch (which) {
+ case DialogInterface.BUTTON_POSITIVE:
+ if (deleteWallet(Helper.getWalletFile(LoginActivity.this, walletName))) {
+ reloadWalletList();
+ } else {
+ Toast.makeText(LoginActivity.this, getString(R.string.delete_failed), Toast.LENGTH_LONG).show();
+ }
+ break;
+ case DialogInterface.BUTTON_NEGATIVE:
+ // do nothing
+ break;
+ }
+ };
+
+ final AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
+ final AlertDialog confirm = builder.setMessage(getString(R.string.delete_alert_message))
+ .setTitle(walletName)
+ .setPositiveButton(getString(R.string.delete_alert_yes), dialogClickListener)
+ .setNegativeButton(getString(R.string.delete_alert_no), dialogClickListener)
+ .setView(View.inflate(builder.getContext(), R.layout.checkbox_confirm, null))
+ .show();
+ confirm.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
+ final MaterialCheckBox checkBox = confirm.findViewById(R.id.checkbox);
+ checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ confirm.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(isChecked);
+ });
+ }
+
+ @Override
+ public void onWalletDeleteCache(final String walletName) {
+ Timber.d("delete cache for wallet ." + walletName + ".");
+ if (checkServiceRunning()) return;
+ DialogInterface.OnClickListener dialogClickListener = (dialog, which) -> {
+ switch (which) {
+ case DialogInterface.BUTTON_POSITIVE:
+ if (!deleteWalletCache(Helper.getWalletFile(LoginActivity.this, walletName))) {
+ Toast.makeText(LoginActivity.this, getString(R.string.delete_failed), Toast.LENGTH_LONG).show();
+ }
+ break;
+ case DialogInterface.BUTTON_NEGATIVE:
+ // do nothing
+ break;
+ }
+ };
+
+ final AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
+ final AlertDialog confirm = builder.setMessage(getString(R.string.deletecache_alert_message))
+ .setTitle(walletName)
+ .setPositiveButton(getString(R.string.delete_alert_yes), dialogClickListener)
+ .setNegativeButton(getString(R.string.delete_alert_no), dialogClickListener)
+ .setView(View.inflate(builder.getContext(), R.layout.checkbox_confirm, null))
+ .show();
+ confirm.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
+ final MaterialCheckBox checkBox = confirm.findViewById(R.id.checkbox);
+ checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ confirm.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(isChecked);
+ });
+ }
+
+ void reloadWalletList() {
+ Timber.d("reloadWalletList()");
+ try {
+ LoginFragment loginFragment = (LoginFragment)
+ getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (loginFragment != null) {
+ loginFragment.loadList();
+ }
+ } catch (ClassCastException ex) {
+ Timber.w(ex);
+ }
+ }
+
+ public void onWalletChangePassword() {//final String walletName, final String walletPassword) {
+ try {
+ GenerateReviewFragment detailsFragment = (GenerateReviewFragment)
+ getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ AlertDialog dialog = detailsFragment.createChangePasswordDialog();
+ if (dialog != null) {
+ Helper.showKeyboard(dialog);
+ dialog.show();
+ }
+ } catch (ClassCastException ex) {
+ Timber.w("onWalletChangePassword() called, but no GenerateReviewFragment active");
+ }
+ }
+
+ @Override
+ public void onAddWallet(String type) {
+ if (checkServiceRunning()) return;
+ startGenerateFragment(type);
+ }
+
+ @Override
+ public void onNodePrefs() {
+ Timber.d("node prefs");
+ if (checkServiceRunning()) return;
+ startNodeFragment();
+ }
+
+ ////////////////////////////////////////
+ // LoginFragment.Listener
+ ////////////////////////////////////////
+
+ @Override
+ public File getStorageRoot() {
+ return Helper.getWalletRoot(getApplicationContext());
+ }
+
+ ////////////////////////////////////////
+ ////////////////////////////////////////
+
+ @Override
+ public void showNet() {
+ showNet(WalletManager.getInstance().getNetworkType());
+ }
+
+ private void showNet(NetworkType net) {
+ switch (net) {
+ case NetworkType_Mainnet:
+ toolbar.setSubtitle(null);
+ toolbar.setBackgroundResource(R.drawable.backgound_toolbar_mainnet);
+ break;
+ case NetworkType_Testnet:
+ toolbar.setSubtitle(getString(R.string.connect_testnet));
+ toolbar.setBackgroundResource(ThemeHelper.getThemedResourceId(this, R.attr.colorPrimaryDark));
+ break;
+ case NetworkType_Stagenet:
+ toolbar.setSubtitle(getString(R.string.connect_stagenet));
+ toolbar.setBackgroundResource(ThemeHelper.getThemedResourceId(this, R.attr.colorPrimaryDark));
+ break;
+ default:
+ throw new IllegalStateException("NetworkType unknown: " + net);
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ Timber.d("onPause()");
+ super.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ Timber.d("onDestroy");
+ dismissProgressDialog();
+ unregisterDetachReceiver();
+ Ledger.disconnect();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ // wait for WalletService to finish
+ if (WalletService.Running && (progressDialog == null)) {
+ // and show a progress dialog, but only if there isn't one already
+ new AsyncWaitForService().execute();
+ }
+ if (!Ledger.isConnected()) attachLedger();
+ registerTor();
+ }
+
+ private class AsyncWaitForService extends AsyncTask {
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ showProgressDialog(R.string.service_progress);
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ while (WalletService.Running & !isCancelled()) {
+ Thread.sleep(250);
+ }
+ } catch (InterruptedException ex) {
+ // oh well ...
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ if (isDestroyed()) {
+ return;
+ }
+ dismissProgressDialog();
+ }
+ }
+
+ void startWallet(String walletName, String walletPassword,
+ boolean fingerprintUsed, boolean streetmode) {
+ Timber.d("startWallet()");
+ Intent intent = new Intent(getApplicationContext(), WalletActivity.class);
+ intent.putExtra(WalletActivity.REQUEST_ID, walletName);
+ intent.putExtra(WalletActivity.REQUEST_PW, walletPassword);
+ intent.putExtra(WalletActivity.REQUEST_FINGERPRINT_USED, fingerprintUsed);
+ intent.putExtra(WalletActivity.REQUEST_STREETMODE, streetmode);
+ if (uri != null) {
+ intent.putExtra(WalletActivity.REQUEST_URI, uri);
+ uri = null; // use only once
+ }
+ startActivity(intent);
+ }
+
+ void startDetails(File walletFile, String password, String type) {
+ Timber.d("startDetails()");
+ Bundle b = new Bundle();
+ b.putString("path", walletFile.getAbsolutePath());
+ b.putString("password", password);
+ b.putString("type", type);
+ startReviewFragment(b);
+ }
+
+ void startLoginFragment() {
+ // we set these here because we cannot be ceratin we have permissions for storage before
+ Helper.setMoneroHome(this);
+ Helper.initLogger(this);
+ Fragment fragment = new LoginFragment();
+ getSupportFragmentManager().beginTransaction()
+ .add(R.id.fragment_container, fragment).commit();
+ Timber.d("LoginFragment added");
+ }
+
+ void startGenerateFragment(String type) {
+ Bundle extras = new Bundle();
+ extras.putString(GenerateFragment.TYPE, type);
+ replaceFragment(new GenerateFragment(), GENERATE_STACK, extras);
+ Timber.d("GenerateFragment placed");
+ }
+
+ void startReviewFragment(Bundle extras) {
+ replaceFragment(new GenerateReviewFragment(), null, extras);
+ Timber.d("GenerateReviewFragment placed");
+ }
+
+ void startNodeFragment() {
+ replaceFragment(new NodeFragment(), null, null);
+ Timber.d("NodeFragment placed");
+ }
+
+ void startSettingsFragment() {
+ replaceFragment(new SettingsFragment(), null, null);
+ Timber.d("SettingsFragment placed");
+ }
+
+ void replaceFragment(Fragment newFragment, String stackName, Bundle extras) {
+ if (extras != null) {
+ newFragment.setArguments(extras);
+ }
+ FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
+ transaction.replace(R.id.fragment_container, newFragment);
+ transaction.addToBackStack(stackName);
+ transaction.commit();
+ }
+
+ void popFragmentStack(String name) {
+ getSupportFragmentManager().popBackStack(name, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ }
+
+ //////////////////////////////////////////
+ // GenerateFragment.Listener
+ //////////////////////////////////////////
+ static final String MNEMONIC_LANGUAGE = "English"; // see mnemonics/electrum-words.cpp for more
+
+ private class AsyncCreateWallet extends AsyncTask {
+ final String walletName;
+ final String walletPassword;
+ final WalletCreator walletCreator;
+
+ File newWalletFile;
+
+ AsyncCreateWallet(final String name, final String password,
+ final WalletCreator walletCreator) {
+ super();
+ this.walletName = name;
+ this.walletPassword = password;
+ this.walletCreator = walletCreator;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ acquireWakeLock();
+ if (walletCreator.isLedger()) {
+ showLedgerProgressDialog(LedgerProgressDialog.TYPE_RESTORE);
+ } else {
+ showProgressDialog(R.string.generate_wallet_creating);
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ // check if the wallet we want to create already exists
+ File walletFolder = getStorageRoot();
+ if (!walletFolder.isDirectory()) {
+ Timber.e("Wallet dir " + walletFolder.getAbsolutePath() + "is not a directory");
+ return false;
+ }
+ File cacheFile = new File(walletFolder, walletName);
+ File keysFile = new File(walletFolder, walletName + ".keys");
+ File addressFile = new File(walletFolder, walletName + ".address.txt");
+
+ if (cacheFile.exists() || keysFile.exists() || addressFile.exists()) {
+ Timber.e("Some wallet files already exist for %s", cacheFile.getAbsolutePath());
+ return false;
+ }
+
+ newWalletFile = new File(walletFolder, walletName);
+ boolean success = walletCreator.createWallet(newWalletFile, walletPassword);
+ if (success) {
+ return true;
+ } else {
+ Timber.e("Could not create new wallet in %s", newWalletFile.getAbsolutePath());
+ return false;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ releaseWakeLock(RELEASE_WAKE_LOCK_DELAY);
+ if (isDestroyed()) {
+ return;
+ }
+ dismissProgressDialog();
+ if (result) {
+ startDetails(newWalletFile, walletPassword, GenerateReviewFragment.VIEW_TYPE_ACCEPT);
+ } else {
+ walletGenerateError();
+ }
+ }
+ }
+
+ public void createWallet(final String name, final String password,
+ final WalletCreator walletCreator) {
+ new AsyncCreateWallet(name, password, walletCreator)
+ .executeOnExecutor(MoneroThreadPoolExecutor.MONERO_THREAD_POOL_EXECUTOR);
+ }
+
+ void walletGenerateError() {
+ try {
+ GenerateFragment genFragment = (GenerateFragment)
+ getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ genFragment.walletGenerateError();
+ } catch (ClassCastException ex) {
+ Timber.e("walletGenerateError() but not in GenerateFragment");
+ }
+ }
+
+ interface WalletCreator {
+ boolean createWallet(File aFile, String password);
+
+ boolean isLedger();
+
+ }
+
+ boolean checkAndCloseWallet(Wallet aWallet) {
+ Wallet.Status walletStatus = aWallet.getStatus();
+ if (!walletStatus.isOk()) {
+ Timber.e(walletStatus.getErrorString());
+ toast(walletStatus.getErrorString());
+ }
+ aWallet.close();
+ return walletStatus.isOk();
+ }
+
+ @Override
+ public void onGenerate(final String name, final String password) {
+ createWallet(name, password,
+ new WalletCreator() {
+ @Override
+ public boolean isLedger() {
+ return false;
+ }
+
+ @Override
+ public boolean createWallet(File aFile, String password) {
+ NodeInfo currentNode = getNode();
+ // get it from the connected node if we have one
+ final long restoreHeight =
+ (currentNode != null) ? currentNode.getHeight() : -1;
+ Wallet newWallet = WalletManager.getInstance()
+ .createWallet(aFile, password, MNEMONIC_LANGUAGE, restoreHeight);
+ return checkAndCloseWallet(newWallet);
+ }
+ });
+ }
+
+ @Override
+ public void onGenerate(final String name, final String password,
+ final String seed, final String offset,
+ final long restoreHeight) {
+ createWallet(name, password,
+ new WalletCreator() {
+ @Override
+ public boolean isLedger() {
+ return false;
+ }
+
+ @Override
+ public boolean createWallet(File aFile, String password) {
+ Wallet newWallet = WalletManager.getInstance()
+ .recoveryWallet(aFile, password, seed, offset, restoreHeight);
+ return checkAndCloseWallet(newWallet);
+ }
+ });
+ }
+
+ @Override
+ public void onGenerateLedger(final String name, final String password,
+ final long restoreHeight) {
+ createWallet(name, password,
+ new WalletCreator() {
+ @Override
+ public boolean isLedger() {
+ return true;
+ }
+
+ @Override
+ public boolean createWallet(File aFile, String password) {
+ Wallet newWallet = WalletManager.getInstance()
+ .createWalletFromDevice(aFile, password,
+ restoreHeight, "Ledger");
+ return checkAndCloseWallet(newWallet);
+ }
+ });
+ }
+
+ @Override
+ public void onGenerate(final String name, final String password,
+ final String address, final String viewKey, final String spendKey,
+ final long restoreHeight) {
+ createWallet(name, password,
+ new WalletCreator() {
+ @Override
+ public boolean isLedger() {
+ return false;
+ }
+
+ @Override
+ public boolean createWallet(File aFile, String password) {
+ Wallet newWallet = WalletManager.getInstance()
+ .createWalletWithKeys(aFile, password, MNEMONIC_LANGUAGE, restoreHeight,
+ address, viewKey, spendKey);
+ return checkAndCloseWallet(newWallet);
+ }
+ });
+ }
+
+ private void toast(final String msg) {
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this, msg, Toast.LENGTH_LONG).show());
+ }
+
+ private void toast(final int msgId) {
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this, getString(msgId), Toast.LENGTH_LONG).show());
+ }
+
+ @Override
+ public void onAccept(final String name, final String password) {
+ File walletFolder = getStorageRoot();
+ File walletFile = new File(walletFolder, name);
+ Timber.d("New Wallet %s", walletFile.getAbsolutePath());
+ walletFile.delete(); // when recovering wallets, the cache seems corrupt - so remove it
+
+ popFragmentStack(GENERATE_STACK);
+ Toast.makeText(LoginActivity.this,
+ getString(R.string.generate_wallet_created), Toast.LENGTH_SHORT).show();
+ }
+
+ boolean walletExists(File walletFile, boolean any) {
+ File dir = walletFile.getParentFile();
+ String name = walletFile.getName();
+ if (any) {
+ return new File(dir, name).exists()
+ || new File(dir, name + ".keys").exists()
+ || new File(dir, name + ".address.txt").exists();
+ } else {
+ return new File(dir, name).exists()
+ && new File(dir, name + ".keys").exists()
+ && new File(dir, name + ".address.txt").exists();
+ }
+ }
+
+ boolean copyWallet(File srcWallet, File dstWallet, boolean overwrite, boolean ignoreCacheError) {
+ if (walletExists(dstWallet, true) && !overwrite) return false;
+ boolean success = false;
+ File srcDir = srcWallet.getParentFile();
+ String srcName = srcWallet.getName();
+ File dstDir = dstWallet.getParentFile();
+ String dstName = dstWallet.getName();
+ try {
+ copyFile(new File(srcDir, srcName + ".keys"), new File(dstDir, dstName + ".keys"));
+ try { // cache & address.txt are optional files
+ copyFile(new File(srcDir, srcName), new File(dstDir, dstName));
+ copyFile(new File(srcDir, srcName + ".address.txt"), new File(dstDir, dstName + ".address.txt"));
+ } catch (IOException ex) {
+ Timber.d("CACHE %s", ignoreCacheError);
+ if (!ignoreCacheError) { // ignore cache backup error if backing up (can be resynced)
+ throw ex;
+ }
+ }
+ success = true;
+ } catch (IOException ex) {
+ Timber.e("wallet copy failed: %s", ex.getMessage());
+ // try to rollback
+ deleteWallet(dstWallet);
+ }
+ return success;
+ }
+
+ // do our best to delete as much as possible of the wallet files
+ boolean deleteWallet(File walletFile) {
+ Timber.d("deleteWallet %s", walletFile.getAbsolutePath());
+ File dir = walletFile.getParentFile();
+ String name = walletFile.getName();
+ boolean success = true;
+ File cacheFile = new File(dir, name);
+ if (cacheFile.exists()) {
+ success = cacheFile.delete();
+ }
+ success = new File(dir, name + ".keys").delete() && success;
+ File addressFile = new File(dir, name + ".address.txt");
+ if (addressFile.exists()) {
+ success = addressFile.delete() && success;
+ }
+ Timber.d("deleteWallet is %s", success);
+ KeyStoreHelper.removeWalletUserPass(this, walletFile.getName());
+ return success;
+ }
+
+ boolean deleteWalletCache(File walletFile) {
+ Timber.d("deleteWalletCache %s", walletFile.getAbsolutePath());
+ File dir = walletFile.getParentFile();
+ String name = walletFile.getName();
+ boolean success = true;
+ File cacheFile = new File(dir, name);
+ if (cacheFile.exists()) {
+ success = cacheFile.delete();
+ }
+ return success;
+ }
+
+ void copyFile(File src, File dst) throws IOException {
+ try (FileChannel inChannel = new FileInputStream(src).getChannel();
+ FileChannel outChannel = new FileOutputStream(dst).getChannel()) {
+ inChannel.transferTo(0, inChannel.size(), outChannel);
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ Fragment f = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (f instanceof GenerateReviewFragment) {
+ if (((GenerateReviewFragment) f).backOk()) {
+ super.onBackPressed();
+ }
+ } else if (f instanceof NodeFragment) {
+ if (!((NodeFragment) f).isRefreshing()) {
+ super.onBackPressed();
+ } else {
+ Toast.makeText(LoginActivity.this, getString(R.string.node_refresh_wait), Toast.LENGTH_LONG).show();
+ }
+ } else if (f instanceof LoginFragment) {
+ if (((LoginFragment) f).isFabOpen()) {
+ ((LoginFragment) f).animateFAB();
+ } else {
+ super.onBackPressed();
+ }
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ final int id = item.getItemId();
+ if (id == R.id.action_create_help_new) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_create_new);
+ return true;
+ } else if (id == R.id.action_create_help_keys) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_create_keys);
+ return true;
+ } else if (id == R.id.action_create_help_view) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_create_view);
+ return true;
+ } else if (id == R.id.action_create_help_seed) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_create_seed);
+ return true;
+ } else if (id == R.id.action_create_help_ledger) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_create_ledger);
+ return true;
+ } else if (id == R.id.action_details_help) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_details);
+ return true;
+ } else if (id == R.id.action_details_changepw) {
+ onWalletChangePassword();
+ return true;
+ } else if (id == R.id.action_help_list) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_list);
+ return true;
+ } else if (id == R.id.action_help_node) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_node);
+ return true;
+ } else if (id == R.id.action_default_nodes) {
+ Fragment f = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if ((WalletManager.getInstance().getNetworkType() == NetworkType.NetworkType_Mainnet) &&
+ (f instanceof NodeFragment)) {
+ ((NodeFragment) f).restoreDefaultNodes();
+ }
+ return true;
+ } else if (id == R.id.action_ledger_seed) {
+ Fragment f = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (f instanceof GenerateFragment) {
+ ((GenerateFragment) f).convertLedgerSeed();
+ }
+ return true;
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ // an AsyncTask which tests the node before trying to open the wallet
+ private class AsyncOpenWallet extends AsyncTask {
+ final static int OK = 0;
+ final static int TIMEOUT = 1;
+ final static int INVALID = 2;
+ final static int IOEX = 3;
+
+ private final String walletName;
+ private final NodeInfo node;
+ private final boolean streetmode;
+
+ AsyncOpenWallet(String walletName, NodeInfo node, boolean streetmode) {
+ this.walletName = walletName;
+ this.node = node;
+ this.streetmode = streetmode;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ Timber.d("checking %s", node.getAddress());
+ return node.testRpcService();
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (isDestroyed()) {
+ return;
+ }
+ if (result) {
+ Timber.d("selected wallet is .%s.", node.getName());
+ // now it's getting real, onValidateFields if wallet exists
+ promptAndStart(walletName, streetmode);
+ } else {
+ if (node.getResponseCode() == 0) { // IOException
+ Toast.makeText(LoginActivity.this, getString(R.string.status_wallet_node_invalid), Toast.LENGTH_LONG).show();
+ } else { // connected but broken
+ Toast.makeText(LoginActivity.this, getString(R.string.status_wallet_connect_ioex), Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ }
+
+ boolean checkDevice(String walletName, String password) {
+ String keyPath = new File(Helper.getWalletRoot(LoginActivity.this),
+ walletName + ".keys").getAbsolutePath();
+ // check if we need connected hardware
+ Wallet.Device device = WalletManager.getInstance().queryWalletDevice(keyPath, password);
+ if (device == Wallet.Device.Device_Ledger) {
+ if (!hasLedger()) {
+ toast(R.string.open_wallet_ledger_missing);
+ } else {
+ return true;
+ }
+ } else {// device could be undefined meaning the password is wrong
+ // this gets dealt with later
+ return true;
+ }
+ return false;
+ }
+
+ void promptAndStart(String walletName, final boolean streetmode) {
+ File walletFile = Helper.getWalletFile(this, walletName);
+ if (WalletManager.getInstance().walletExists(walletFile)) {
+ Helper.promptPassword(LoginActivity.this, walletName, false,
+ new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName, String password, boolean fingerprintUsed) {
+ if (checkDevice(walletName, password))
+ startWallet(walletName, password, fingerprintUsed, streetmode);
+ }
+
+ @Override
+ public void fail(String walletName) {
+ }
+
+ });
+ } else { // this cannot really happen as we prefilter choices
+ Toast.makeText(this, getString(R.string.bad_wallet), Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ // USB Stuff - (Ledger)
+
+ private static final String ACTION_USB_PERMISSION = "com.m2049r.xmrwallet.USB_PERMISSION";
+
+ void attachLedger() {
+ final UsbManager usbManager = getUsbManager();
+ UsbDevice device = Ledger.findDevice(usbManager);
+ if (device != null) {
+ if (usbManager.hasPermission(device)) {
+ connectLedger(usbManager, device);
+ } else {
+ registerReceiver(usbPermissionReceiver, new IntentFilter(ACTION_USB_PERMISSION));
+ usbManager.requestPermission(device,
+ PendingIntent.getBroadcast(this, 0,
+ new Intent(ACTION_USB_PERMISSION),
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+ } else {
+ Timber.d("no ledger device found");
+ }
+ }
+
+ private final BroadcastReceiver usbPermissionReceiver = new BroadcastReceiver() {
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (ACTION_USB_PERMISSION.equals(action)) {
+ unregisterReceiver(usbPermissionReceiver);
+ synchronized (this) {
+ UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
+ if (device != null) {
+ connectLedger(getUsbManager(), device);
+ }
+ } else {
+ Timber.w("User denied permission for device %s", device.getProductName());
+ }
+ }
+ }
+ }
+ };
+
+ private void connectLedger(UsbManager usbManager, final UsbDevice usbDevice) {
+ if (Ledger.ENABLED)
+ try {
+ Ledger.connect(usbManager, usbDevice);
+ if (!Ledger.check()) {
+ Ledger.disconnect();
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this,
+ getString(R.string.toast_ledger_start_app, usbDevice.getProductName()),
+ Toast.LENGTH_SHORT)
+ .show());
+ } else {
+ registerDetachReceiver();
+ onLedgerAction();
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this,
+ getString(R.string.toast_ledger_attached, usbDevice.getProductName()),
+ Toast.LENGTH_SHORT)
+ .show());
+ }
+ } catch (IOException ex) {
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this,
+ getString(R.string.open_wallet_ledger_missing),
+ Toast.LENGTH_SHORT)
+ .show());
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ processUsbIntent(intent);
+ }
+
+ private boolean processUsbIntent(Intent intent) {
+ String action = intent.getAction();
+ if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) {
+ synchronized (this) {
+ final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ if (device != null) {
+ final UsbManager usbManager = getUsbManager();
+ if (usbManager.hasPermission(device)) {
+ Timber.d("Ledger attached by intent");
+ connectLedger(usbManager, device);
+ }
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private String uri = null;
+
+ private void processUriIntent(Intent intent) {
+ String action = intent.getAction();
+ if (Intent.ACTION_VIEW.equals(action)) {
+ synchronized (this) {
+ uri = intent.getDataString();
+ Timber.d("URI Intent %s", uri);
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_uri);
+ }
+ }
+ }
+
+ BroadcastReceiver detachReceiver;
+
+ private void unregisterDetachReceiver() {
+ if (detachReceiver != null) {
+ unregisterReceiver(detachReceiver);
+ detachReceiver = null;
+ }
+ }
+
+ private void registerDetachReceiver() {
+ detachReceiver = new BroadcastReceiver() {
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
+ unregisterDetachReceiver();
+ final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ Timber.i("Ledger detached!");
+ if (device != null)
+ runOnUiThread(() -> Toast.makeText(LoginActivity.this,
+ getString(R.string.toast_ledger_detached, device.getProductName()),
+ Toast.LENGTH_SHORT)
+ .show());
+ Ledger.disconnect();
+ onLedgerAction();
+ }
+ }
+ };
+
+ registerReceiver(detachReceiver, new IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED));
+ }
+
+ public void onLedgerAction() {
+ Fragment f = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (f instanceof GenerateFragment) {
+ onBackPressed();
+ } else if (f instanceof LoginFragment) {
+ if (((LoginFragment) f).isFabOpen()) {
+ ((LoginFragment) f).animateFAB();
+ }
+ }
+ }
+
+ // get UsbManager or die trying
+ @NonNull
+ private UsbManager getUsbManager() {
+ final UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
+ if (usbManager == null) {
+ throw new IllegalStateException("no USB_SERVICE");
+ }
+ return usbManager;
+ }
+
+ //
+ // Tor (Orbot) stuff
+ //
+
+ void torNotify() {
+ final Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (fragment == null) return;
+
+ if (fragment instanceof LoginFragment) {
+ runOnUiThread(((LoginFragment) fragment)::showNetwork);
+ }
+ }
+
+ private void deregisterTor() {
+ NetCipherHelper.deregister();
+ }
+
+ private void registerTor() {
+ NetCipherHelper.register(new NetCipherHelper.OnStatusChangedListener() {
+ @Override
+ public void connected() {
+ Timber.d("CONNECTED");
+ WalletManager.getInstance().setProxy(NetCipherHelper.getProxy());
+ torNotify();
+ if (waitingUiTask != null) {
+ Timber.d("RUN");
+ runOnUiThread(waitingUiTask);
+ waitingUiTask = null;
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ Timber.d("DISCONNECTED");
+ WalletManager.getInstance().setProxy("");
+ torNotify();
+ }
+
+ @Override
+ public void notInstalled() {
+ Timber.d("NOT INSTALLED");
+ WalletManager.getInstance().setProxy("");
+ torNotify();
+ }
+
+ @Override
+ public void notEnabled() {
+ Timber.d("NOT ENABLED");
+ notInstalled();
+ }
+ });
+ }
+
+ private Runnable waitingUiTask;
+
+ @Override
+ public void runOnNetCipher(Runnable uiTask) {
+ if (waitingUiTask != null) throw new IllegalStateException("only one tor task at a time");
+ if (NetCipherHelper.hasClient()) {
+ runOnUiThread(uiTask);
+ } else {
+ waitingUiTask = uiTask;
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/LoginFragment.java b/app/src/main/java/com/m2049r/xmrwallet/LoginFragment.java
new file mode 100644
index 0000000..21b85be
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/LoginFragment.java
@@ -0,0 +1,563 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.progressindicator.CircularProgressIndicator;
+import com.m2049r.xmrwallet.data.NodeInfo;
+import com.m2049r.xmrwallet.dialog.HelpFragment;
+import com.m2049r.xmrwallet.layout.WalletInfoAdapter;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.KeyStoreHelper;
+import com.m2049r.xmrwallet.util.NetCipherHelper;
+import com.m2049r.xmrwallet.util.NodePinger;
+import com.m2049r.xmrwallet.util.Notice;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import timber.log.Timber;
+
+public class LoginFragment extends Fragment implements WalletInfoAdapter.OnInteractionListener,
+ View.OnClickListener {
+
+ private WalletInfoAdapter adapter;
+
+ private final List walletList = new ArrayList<>();
+
+ private View tvGuntherSays;
+ private ImageView ivGunther;
+ private TextView tvNodeName;
+ private TextView tvNodeInfo;
+ private ImageButton ibNetwork;
+ private CircularProgressIndicator pbNetwork;
+
+ private Listener activityCallback;
+
+ // Container Activity must implement this interface
+ public interface Listener {
+ File getStorageRoot();
+
+ boolean onWalletSelected(String wallet, boolean streetmode);
+
+ void onWalletDetails(String wallet);
+
+ void onWalletRename(String name);
+
+ void onWalletBackup(String name);
+
+ void onWalletRestore();
+
+ void onWalletDelete(String walletName);
+
+ void onWalletDeleteCache(String walletName);
+
+ void onAddWallet(String type);
+
+ void onNodePrefs();
+
+ void showNet();
+
+ void setToolbarButton(int type);
+
+ void setTitle(String title);
+
+ void setNode(NodeInfo node);
+
+ NodeInfo getNode();
+
+ Set getFavouriteNodes();
+
+ Set getOrPopulateFavourites();
+
+ boolean hasLedger();
+
+ void runOnNetCipher(Runnable runnable);
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.activityCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onPause() {
+ Timber.d("onPause()");
+ torStatus = null;
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume() %s", activityCallback.getFavouriteNodes().size());
+ activityCallback.setTitle(null);
+ activityCallback.setToolbarButton(Toolbar.BUTTON_SETTINGS);
+ activityCallback.showNet();
+ showNetwork();
+ //activityCallback.runOnNetCipher(this::pingSelectedNode);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Timber.d("onCreateView");
+ View view = inflater.inflate(R.layout.fragment_login, container, false);
+
+ tvGuntherSays = view.findViewById(R.id.tvGuntherSays);
+ ivGunther = view.findViewById(R.id.ivGunther);
+ fabScreen = view.findViewById(R.id.fabScreen);
+ fab = view.findViewById(R.id.fab);
+ fabNew = view.findViewById(R.id.fabNew);
+ fabView = view.findViewById(R.id.fabView);
+ fabKey = view.findViewById(R.id.fabKey);
+ fabSeed = view.findViewById(R.id.fabSeed);
+ fabImport = view.findViewById(R.id.fabImport);
+ fabLedger = view.findViewById(R.id.fabLedger);
+
+ fabNewL = view.findViewById(R.id.fabNewL);
+ fabViewL = view.findViewById(R.id.fabViewL);
+ fabKeyL = view.findViewById(R.id.fabKeyL);
+ fabSeedL = view.findViewById(R.id.fabSeedL);
+ fabImportL = view.findViewById(R.id.fabImportL);
+ fabLedgerL = view.findViewById(R.id.fabLedgerL);
+
+ fab_pulse = AnimationUtils.loadAnimation(getContext(), R.anim.fab_pulse);
+ fab_open_screen = AnimationUtils.loadAnimation(getContext(), R.anim.fab_open_screen);
+ fab_close_screen = AnimationUtils.loadAnimation(getContext(), R.anim.fab_close_screen);
+ fab_open = AnimationUtils.loadAnimation(getContext(), R.anim.fab_open);
+ fab_close = AnimationUtils.loadAnimation(getContext(), R.anim.fab_close);
+ rotate_forward = AnimationUtils.loadAnimation(getContext(), R.anim.rotate_forward);
+ rotate_backward = AnimationUtils.loadAnimation(getContext(), R.anim.rotate_backward);
+ fab.setOnClickListener(this);
+ fabNew.setOnClickListener(this);
+ fabView.setOnClickListener(this);
+ fabKey.setOnClickListener(this);
+ fabSeed.setOnClickListener(this);
+ fabImport.setOnClickListener(this);
+ fabLedger.setOnClickListener(this);
+ fabScreen.setOnClickListener(this);
+
+ RecyclerView recyclerView = view.findViewById(R.id.list);
+ registerForContextMenu(recyclerView);
+ this.adapter = new WalletInfoAdapter(getActivity(), this);
+ recyclerView.setAdapter(adapter);
+
+ ViewGroup llNotice = view.findViewById(R.id.llNotice);
+ Notice.showAll(llNotice, ".*_login");
+
+ view.findViewById(R.id.llNode).setOnClickListener(v -> startNodePrefs());
+ tvNodeName = view.findViewById(R.id.tvNodeName);
+ tvNodeInfo = view.findViewById(R.id.tvInfo);
+ view.findViewById(R.id.ibRenew).setOnClickListener(v -> findBestNode());
+ ibNetwork = view.findViewById(R.id.ibNetwork);
+ ibNetwork.setOnClickListener(v -> changeNetwork());
+ ibNetwork.setEnabled(false);
+ pbNetwork = view.findViewById(R.id.pbNetwork);
+
+ Helper.hideKeyboard(getActivity());
+
+ loadList();
+
+ return view;
+ }
+
+ // Callbacks from WalletInfoAdapter
+
+ // Wallet touched
+ @Override
+ public void onInteraction(final View view, final WalletManager.WalletInfo infoItem) {
+ openWallet(infoItem.getName(), false);
+ }
+
+ private void openWallet(String name, boolean streetmode) {
+ activityCallback.onWalletSelected(name, streetmode);
+ }
+
+ @Override
+ public boolean onContextInteraction(MenuItem item, WalletManager.WalletInfo listItem) {
+ final int id = item.getItemId();
+ if (id == R.id.action_streetmode) {
+ openWallet(listItem.getName(), true);
+ } else if (id == R.id.action_info) {
+ showInfo(listItem.getName());
+ } else if (id == R.id.action_rename) {
+ activityCallback.onWalletRename(listItem.getName());
+ } else if (id == R.id.action_backup) {
+ activityCallback.onWalletBackup(listItem.getName());
+ } else if (id == R.id.action_archive) {
+ activityCallback.onWalletDelete(listItem.getName());
+ } else if (id == R.id.action_deletecache) {
+ activityCallback.onWalletDeleteCache(listItem.getName());
+ } else {
+ return super.onContextItemSelected(item);
+ }
+ return true;
+ }
+
+ public void loadList() {
+ Timber.d("loadList()");
+ WalletManager mgr = WalletManager.getInstance();
+ walletList.clear();
+ walletList.addAll(mgr.findWallets(activityCallback.getStorageRoot()));
+ adapter.setInfos(walletList);
+
+ // deal with Gunther & FAB animation
+ if (walletList.isEmpty()) {
+ fab.startAnimation(fab_pulse);
+ if (ivGunther.getDrawable() == null) {
+ ivGunther.setImageResource(R.drawable.ic_emptygunther);
+ tvGuntherSays.setVisibility(View.VISIBLE);
+ }
+ } else {
+ fab.clearAnimation();
+ if (ivGunther.getDrawable() != null) {
+ ivGunther.setImageDrawable(null);
+ }
+ tvGuntherSays.setVisibility(View.GONE);
+ }
+
+ // remove information of non-existent wallet
+ Set removedWallets = getActivity()
+ .getSharedPreferences(KeyStoreHelper.SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE)
+ .getAll().keySet();
+ for (WalletManager.WalletInfo s : walletList) {
+ removedWallets.remove(s.getName());
+ }
+ for (String name : removedWallets) {
+ KeyStoreHelper.removeWalletUserPass(getActivity(), name);
+ }
+ }
+
+ private void showInfo(@NonNull String name) {
+ activityCallback.onWalletDetails(name);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.list_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ private boolean isFabOpen = false;
+ private FloatingActionButton fab, fabNew, fabView, fabKey, fabSeed, fabImport, fabLedger;
+ private RelativeLayout fabScreen;
+ private RelativeLayout fabNewL, fabViewL, fabKeyL, fabSeedL, fabImportL, fabLedgerL;
+ private Animation fab_open, fab_close, rotate_forward, rotate_backward, fab_open_screen, fab_close_screen;
+ private Animation fab_pulse;
+
+ public boolean isFabOpen() {
+ return isFabOpen;
+ }
+
+ public void animateFAB() {
+ if (isFabOpen) { // close the fab
+ fabScreen.setClickable(false);
+ fabScreen.startAnimation(fab_close_screen);
+ fab.startAnimation(rotate_backward);
+ if (fabLedgerL.getVisibility() == View.VISIBLE) {
+ fabLedgerL.startAnimation(fab_close);
+ fabLedger.setClickable(false);
+ } else {
+ fabNewL.startAnimation(fab_close);
+ fabNew.setClickable(false);
+ fabViewL.startAnimation(fab_close);
+ fabView.setClickable(false);
+ fabKeyL.startAnimation(fab_close);
+ fabKey.setClickable(false);
+ fabSeedL.startAnimation(fab_close);
+ fabSeed.setClickable(false);
+ fabImportL.startAnimation(fab_close);
+ fabImport.setClickable(false);
+ }
+ isFabOpen = false;
+ } else { // open the fab
+ fabScreen.setClickable(true);
+ fabScreen.startAnimation(fab_open_screen);
+ fab.startAnimation(rotate_forward);
+ if (activityCallback.hasLedger()) {
+ fabLedgerL.setVisibility(View.VISIBLE);
+ fabNewL.setVisibility(View.GONE);
+ fabViewL.setVisibility(View.GONE);
+ fabKeyL.setVisibility(View.GONE);
+ fabSeedL.setVisibility(View.GONE);
+ fabImportL.setVisibility(View.GONE);
+
+ fabLedgerL.startAnimation(fab_open);
+ fabLedger.setClickable(true);
+ } else {
+ fabLedgerL.setVisibility(View.GONE);
+ fabNewL.setVisibility(View.VISIBLE);
+ fabViewL.setVisibility(View.VISIBLE);
+ fabKeyL.setVisibility(View.VISIBLE);
+ fabSeedL.setVisibility(View.VISIBLE);
+ fabImportL.setVisibility(View.VISIBLE);
+
+ fabNewL.startAnimation(fab_open);
+ fabNew.setClickable(true);
+ fabViewL.startAnimation(fab_open);
+ fabView.setClickable(true);
+ fabKeyL.startAnimation(fab_open);
+ fabKey.setClickable(true);
+ fabSeedL.startAnimation(fab_open);
+ fabSeed.setClickable(true);
+ fabImportL.startAnimation(fab_open);
+ fabImport.setClickable(true);
+ }
+ isFabOpen = true;
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ final int id = v.getId();
+ Timber.d("onClick %d/%d", id, R.id.fabLedger);
+ if (id == R.id.fab) {
+ animateFAB();
+ } else if (id == R.id.fabNew) {
+ fabScreen.setVisibility(View.INVISIBLE);
+ isFabOpen = false;
+ activityCallback.onAddWallet(GenerateFragment.TYPE_NEW);
+ } else if (id == R.id.fabView) {
+ animateFAB();
+ activityCallback.onAddWallet(GenerateFragment.TYPE_VIEWONLY);
+ } else if (id == R.id.fabKey) {
+ animateFAB();
+ activityCallback.onAddWallet(GenerateFragment.TYPE_KEY);
+ } else if (id == R.id.fabSeed) {
+ animateFAB();
+ activityCallback.onAddWallet(GenerateFragment.TYPE_SEED);
+ } else if (id == R.id.fabImport) {
+ animateFAB();
+ activityCallback.onWalletRestore();
+ } else if (id == R.id.fabLedger) {
+ Timber.d("FAB_LEDGER");
+ animateFAB();
+ activityCallback.onAddWallet(GenerateFragment.TYPE_LEDGER);
+ } else if (id == R.id.fabScreen) {
+ animateFAB();
+ }
+ }
+
+ public void findBestNode() {
+ new AsyncFindBestNode().execute(AsyncFindBestNode.FIND_BEST);
+ }
+
+ public void pingSelectedNode() {
+ new AsyncFindBestNode().execute(AsyncFindBestNode.PING_SELECTED);
+ }
+
+ private NodeInfo autoselect(Set nodes) {
+ if (nodes.isEmpty()) return null;
+ NodePinger.execute(nodes, null);
+ List nodeList = new ArrayList<>(nodes);
+ Collections.sort(nodeList, NodeInfo.BestNodeComparator);
+ return nodeList.get(0);
+ }
+
+ private void setSubtext(String status) {
+ final Context ctx = getContext();
+ final Spanned text = Html.fromHtml(ctx.getString(R.string.status,
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, R.attr.positiveColor) & 0xFFFFFF),
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, android.R.attr.colorBackground) & 0xFFFFFF),
+ status, ""));
+ tvNodeInfo.setText(text);
+ }
+
+ private class AsyncFindBestNode extends AsyncTask {
+ final static int PING_SELECTED = 0;
+ final static int FIND_BEST = 1;
+
+ private boolean netState;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ tvNodeName.setVisibility(View.GONE);
+ pbNetwork.setVisibility(View.VISIBLE);
+ netState = ibNetwork.isClickable();
+ ibNetwork.setClickable(false);
+ setSubtext(getString(R.string.node_waiting));
+ }
+
+ @Override
+ protected NodeInfo doInBackground(Integer... params) {
+ Set favourites = activityCallback.getOrPopulateFavourites();
+ NodeInfo selectedNode;
+ if (params[0] == FIND_BEST) {
+ selectedNode = autoselect(favourites);
+ } else if (params[0] == PING_SELECTED) {
+ selectedNode = activityCallback.getNode();
+ if (!activityCallback.getFavouriteNodes().contains(selectedNode))
+ selectedNode = null; // it's not in the favourites (any longer)
+ if (selectedNode == null)
+ for (NodeInfo node : favourites) {
+ if (node.isSelected()) {
+ selectedNode = node;
+ break;
+ }
+ }
+ if (selectedNode == null) { // autoselect
+ selectedNode = autoselect(favourites);
+ } else {
+ selectedNode.testRpcService();
+ }
+ } else throw new IllegalStateException();
+ if ((selectedNode != null) && selectedNode.isValid()) {
+ activityCallback.setNode(selectedNode);
+ return selectedNode;
+ } else {
+ activityCallback.setNode(null);
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(NodeInfo result) {
+ if (!isAdded()) return;
+ tvNodeName.setVisibility(View.VISIBLE);
+ pbNetwork.setVisibility(View.INVISIBLE);
+ ibNetwork.setClickable(netState);
+ if (result != null) {
+ Timber.d("found a good node %s", result.toString());
+ showNode(result);
+ } else {
+ tvNodeName.setText(getResources().getText(R.string.node_create_hint));
+ tvNodeName.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ tvNodeInfo.setText(null);
+ tvNodeInfo.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ protected void onCancelled(NodeInfo result) { //TODO: cancel this on exit from fragment
+ Timber.d("cancelled with %s", result);
+ }
+ }
+
+ private void showNode(NodeInfo nodeInfo) {
+ tvNodeName.setText(nodeInfo.getName());
+ nodeInfo.showInfo(tvNodeInfo);
+ tvNodeInfo.setVisibility(View.VISIBLE);
+ }
+
+ private void startNodePrefs() {
+ activityCallback.onNodePrefs();
+ }
+
+ // Network (Tor) stuff
+
+ private void changeNetwork() {
+ Timber.d("S: %s", NetCipherHelper.getStatus());
+ final NetCipherHelper.Status status = NetCipherHelper.getStatus();
+ if (status == NetCipherHelper.Status.NOT_INSTALLED) {
+ HelpFragment.display(requireActivity().getSupportFragmentManager(), R.string.help_tor);
+ } else if (status == NetCipherHelper.Status.NOT_ENABLED) {
+ Toast.makeText(getActivity(), getString(R.string.tor_enable_background), Toast.LENGTH_LONG).show();
+ } else {
+ pbNetwork.setVisibility(View.VISIBLE);
+ ibNetwork.setEnabled(false);
+ NetCipherHelper.getInstance().toggle();
+ }
+ }
+
+ private NetCipherHelper.Status torStatus = null;
+
+ void showNetwork() {
+ final NetCipherHelper.Status status = NetCipherHelper.getStatus();
+ Timber.d("SHOW %s", status);
+ if (status == torStatus) return;
+ torStatus = status;
+ switch (status) {
+ case ENABLED:
+ ibNetwork.setImageResource(R.drawable.ic_network_tor_on);
+ ibNetwork.setEnabled(true);
+ ibNetwork.setClickable(true);
+ pbNetwork.setVisibility(View.INVISIBLE);
+ break;
+ case NOT_ENABLED:
+ case DISABLED:
+ ibNetwork.setImageResource(R.drawable.ic_network_clearnet);
+ ibNetwork.setEnabled(true);
+ ibNetwork.setClickable(true);
+ pbNetwork.setVisibility(View.INVISIBLE);
+ break;
+ case STARTING:
+ ibNetwork.setImageResource(R.drawable.ic_network_clearnet);
+ ibNetwork.setEnabled(false);
+ pbNetwork.setVisibility(View.VISIBLE);
+ break;
+ case STOPPING:
+ ibNetwork.setImageResource(R.drawable.ic_network_clearnet);
+ ibNetwork.setEnabled(false);
+ pbNetwork.setVisibility(View.VISIBLE);
+ break;
+ case NOT_INSTALLED:
+ ibNetwork.setEnabled(true);
+ ibNetwork.setClickable(true);
+ pbNetwork.setVisibility(View.INVISIBLE);
+ ibNetwork.setImageResource(R.drawable.ic_network_clearnet);
+ break;
+ default:
+ return;
+ }
+ activityCallback.runOnNetCipher(this::pingSelectedNode);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/MainActivity.java b/app/src/main/java/com/m2049r/xmrwallet/MainActivity.java
new file mode 100644
index 0000000..5c7cc75
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/MainActivity.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2018-2020 EarlOfEgo, m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.m2049r.xmrwallet.onboarding.OnBoardingActivity;
+import com.m2049r.xmrwallet.onboarding.OnBoardingManager;
+
+public class MainActivity extends BaseActivity {
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (OnBoardingManager.shouldShowOnBoarding(getApplicationContext())) {
+ startActivity(new Intent(this, OnBoardingActivity.class));
+ } else {
+ startActivity(new Intent(this, LoginActivity.class));
+ }
+ finish();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/NodeFragment.java b/app/src/main/java/com/m2049r/xmrwallet/NodeFragment.java
new file mode 100644
index 0000000..0bbc5a4
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/NodeFragment.java
@@ -0,0 +1,589 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.levin.scanner.Dispatcher;
+import com.m2049r.xmrwallet.data.DefaultNodes;
+import com.m2049r.xmrwallet.data.Node;
+import com.m2049r.xmrwallet.data.NodeInfo;
+import com.m2049r.xmrwallet.layout.NodeInfoAdapter;
+import com.m2049r.xmrwallet.model.NetworkType;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.NodePinger;
+import com.m2049r.xmrwallet.util.Notice;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.io.File;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.text.NumberFormat;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+import timber.log.Timber;
+
+public class NodeFragment extends Fragment
+ implements NodeInfoAdapter.OnInteractionListener, View.OnClickListener {
+
+ static private int NODES_TO_FIND = 10;
+
+ static private NumberFormat FORMATTER = NumberFormat.getInstance();
+
+ private SwipeRefreshLayout pullToRefresh;
+ private TextView tvPull;
+ private View fab;
+
+ private Set nodeList = new HashSet<>();
+
+ private NodeInfoAdapter nodesAdapter;
+
+ private Listener activityCallback;
+
+ public interface Listener {
+ File getStorageRoot();
+
+ void setToolbarButton(int type);
+
+ void setSubtitle(String title);
+
+ Set getFavouriteNodes();
+
+ Set getOrPopulateFavourites();
+
+ void setFavouriteNodes(Collection favouriteNodes);
+
+ void setNode(NodeInfo node);
+ }
+
+ void filterFavourites() {
+ for (Iterator iter = nodeList.iterator(); iter.hasNext(); ) {
+ Node node = iter.next();
+ if (!node.isFavourite()) iter.remove();
+ }
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.activityCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onPause() {
+ Timber.d("onPause() %d", nodeList.size());
+ if (asyncFindNodes != null)
+ asyncFindNodes.cancel(true);
+ if (activityCallback != null)
+ activityCallback.setFavouriteNodes(nodeList);
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setSubtitle(getString(R.string.label_nodes));
+ updateRefreshElements();
+ }
+
+ boolean isRefreshing() {
+ return asyncFindNodes != null;
+ }
+
+ void updateRefreshElements() {
+ if (isRefreshing()) {
+ activityCallback.setToolbarButton(Toolbar.BUTTON_NONE);
+ fab.setVisibility(View.GONE);
+ } else {
+ activityCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+ fab.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Timber.d("onCreateView");
+ View view = inflater.inflate(R.layout.fragment_node, container, false);
+
+ fab = view.findViewById(R.id.fab);
+ fab.setOnClickListener(this);
+
+ RecyclerView recyclerView = view.findViewById(R.id.list);
+ nodesAdapter = new NodeInfoAdapter(getActivity(), this);
+ recyclerView.setAdapter(nodesAdapter);
+
+ tvPull = view.findViewById(R.id.tvPull);
+
+ pullToRefresh = view.findViewById(R.id.pullToRefresh);
+ pullToRefresh.setOnRefreshListener(() -> {
+ if (WalletManager.getInstance().getNetworkType() == NetworkType.NetworkType_Mainnet) {
+ refresh(AsyncFindNodes.SCAN);
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.node_wrong_net), Toast.LENGTH_LONG).show();
+ pullToRefresh.setRefreshing(false);
+ }
+ });
+
+ Helper.hideKeyboard(getActivity());
+
+ nodeList = new HashSet<>(activityCallback.getFavouriteNodes());
+ nodesAdapter.setNodes(nodeList);
+
+ ViewGroup llNotice = view.findViewById(R.id.llNotice);
+ Notice.showAll(llNotice, ".*_nodes");
+
+ refresh(AsyncFindNodes.PING); // start connection tests
+
+ return view;
+ }
+
+ private AsyncFindNodes asyncFindNodes = null;
+
+ private boolean refresh(int type) {
+ if (asyncFindNodes != null) return false; // ignore refresh request as one is ongoing
+ asyncFindNodes = new AsyncFindNodes();
+ updateRefreshElements();
+ asyncFindNodes.execute(type);
+ return true;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.node_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ // Callbacks from NodeInfoAdapter
+ @Override
+ public void onInteraction(final View view, final NodeInfo nodeItem) {
+ Timber.d("onInteraction");
+ if (!nodeItem.isFavourite()) {
+ nodeItem.setFavourite(true);
+ activityCallback.setFavouriteNodes(nodeList);
+ }
+ AsyncTask.execute(() -> {
+ activityCallback.setNode(nodeItem); // this marks it as selected & saves it as well
+ nodeItem.setSelecting(false);
+ try {
+ requireActivity().runOnUiThread(() -> nodesAdapter.allowClick(true));
+ } catch (IllegalStateException ex) {
+ // it's ok
+ }
+ });
+ }
+
+ // open up edit dialog
+ @Override
+ public boolean onLongInteraction(final View view, final NodeInfo nodeItem) {
+ Timber.d("onLongInteraction");
+ EditDialog diag = createEditDialog(nodeItem);
+ if (diag != null) {
+ diag.show();
+ }
+ return true;
+ }
+
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.fab) {
+ EditDialog diag = createEditDialog(null);
+ if (diag != null) {
+ diag.show();
+ }
+ }
+ }
+
+ private class AsyncFindNodes extends AsyncTask
+ implements NodePinger.Listener {
+ final static int SCAN = 0;
+ final static int RESTORE_DEFAULTS = 1;
+ final static int PING = 2;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ filterFavourites();
+ nodesAdapter.setNodes(null);
+ nodesAdapter.allowClick(false);
+ tvPull.setText(getString(R.string.node_scanning));
+ }
+
+ @Override
+ protected Boolean doInBackground(Integer... params) {
+ if (params[0] == RESTORE_DEFAULTS) { // true = restore defaults
+ for (DefaultNodes node : DefaultNodes.values()) {
+ NodeInfo nodeInfo = NodeInfo.fromString(node.getUri());
+ if (nodeInfo != null) {
+ nodeInfo.setFavourite(true);
+ nodeList.add(nodeInfo);
+ }
+ }
+ NodePinger.execute(nodeList, this);
+ return true;
+ } else if (params[0] == PING) {
+ NodePinger.execute(nodeList, this);
+ return true;
+ } else if (params[0] == SCAN) {
+ // otherwise scan the network
+ Timber.d("scanning");
+ Set seedList = new HashSet<>();
+ seedList.addAll(nodeList);
+ nodeList.clear();
+ Timber.d("seed %d", seedList.size());
+ Dispatcher d = new Dispatcher(info -> publishProgress(info));
+ d.seedPeers(seedList);
+ d.awaitTermination(NODES_TO_FIND);
+
+ // we didn't find enough because we didn't ask around enough? ask more!
+ if ((d.getRpcNodes().size() < NODES_TO_FIND) &&
+ (d.getPeerCount() < NODES_TO_FIND + seedList.size())) {
+ // try again
+ publishProgress((NodeInfo[]) null);
+ d = new Dispatcher(new Dispatcher.Listener() {
+ @Override
+ public void onGet(NodeInfo info) {
+ publishProgress(info);
+ }
+ });
+ // also seed with monero seed nodes (see p2p/net_node.inl:410 in monero src)
+ seedList.add(new NodeInfo(new InetSocketAddress("107.152.130.98", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("212.83.175.67", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("5.9.100.248", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("163.172.182.165", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("161.67.132.39", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("198.74.231.92", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("195.154.123.123", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("212.83.172.165", 18080)));
+ seedList.add(new NodeInfo(new InetSocketAddress("192.110.160.146", 18080)));
+ d.seedPeers(seedList);
+ d.awaitTermination(NODES_TO_FIND);
+ }
+ // final (filtered) result
+ nodeList.addAll(d.getRpcNodes());
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected void onProgressUpdate(NodeInfo... values) {
+ Timber.d("onProgressUpdate");
+ if (!isCancelled())
+ if (values != null)
+ nodesAdapter.addNode(values[0]);
+ else
+ nodesAdapter.setNodes(null);
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ Timber.d("done scanning");
+ complete();
+ }
+
+ @Override
+ protected void onCancelled(Boolean result) {
+ Timber.d("cancelled scanning");
+ complete();
+ }
+
+ private void complete() {
+ asyncFindNodes = null;
+ if (!isAdded()) return;
+ //if (isCancelled()) return;
+ tvPull.setText(getString(R.string.node_pull_hint));
+ pullToRefresh.setRefreshing(false);
+ nodesAdapter.setNodes(nodeList);
+ nodesAdapter.allowClick(true);
+ updateRefreshElements();
+ }
+
+ public void publish(NodeInfo nodeInfo) {
+ publishProgress(nodeInfo);
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ Timber.d("detached");
+ super.onDetach();
+ }
+
+ private EditDialog editDialog = null; // for preventing opening of multiple dialogs
+
+ private EditDialog createEditDialog(final NodeInfo nodeInfo) {
+ if (editDialog != null) return null; // we are already open
+ editDialog = new EditDialog(nodeInfo);
+ return editDialog;
+ }
+
+ class EditDialog {
+ final NodeInfo nodeInfo;
+ final NodeInfo nodeBackup;
+
+ private boolean applyChanges() {
+ nodeInfo.clear();
+ showTestResult();
+
+ final String portString = etNodePort.getEditText().getText().toString().trim();
+ int port;
+ if (portString.isEmpty()) {
+ port = Node.getDefaultRpcPort();
+ } else {
+ try {
+ port = Integer.parseInt(portString);
+ } catch (NumberFormatException ex) {
+ etNodePort.setError(getString(R.string.node_port_numeric));
+ return false;
+ }
+ }
+ etNodePort.setError(null);
+ if ((port <= 0) || (port > 65535)) {
+ etNodePort.setError(getString(R.string.node_port_range));
+ return false;
+ }
+
+ final String host = etNodeHost.getEditText().getText().toString().trim();
+ if (host.isEmpty()) {
+ etNodeHost.setError(getString(R.string.node_host_empty));
+ return false;
+ }
+ final boolean setHostSuccess = Helper.runWithNetwork(() -> {
+ try {
+ nodeInfo.setHost(host);
+ return true;
+ } catch (UnknownHostException ex) {
+ return false;
+ }
+ });
+ if (!setHostSuccess) {
+ etNodeHost.setError(getString(R.string.node_host_unresolved));
+ return false;
+ }
+ etNodeHost.setError(null);
+ nodeInfo.setRpcPort(port);
+ // setName() may trigger reverse DNS
+ Helper.runWithNetwork(new Helper.Action() {
+ @Override
+ public boolean run() {
+ nodeInfo.setName(etNodeName.getEditText().getText().toString().trim());
+ return true;
+ }
+ });
+ nodeInfo.setUsername(etNodeUser.getEditText().getText().toString().trim());
+ nodeInfo.setPassword(etNodePass.getEditText().getText().toString()); // no trim for pw
+ return true;
+ }
+
+ private boolean shutdown = false;
+
+ private void apply() {
+ if (applyChanges()) {
+ closeDialog();
+ if (nodeBackup == null) { // this is a (FAB) new node
+ nodeInfo.setFavourite(true);
+ nodeList.add(nodeInfo);
+ }
+ shutdown = true;
+ new AsyncTestNode().execute();
+ }
+ }
+
+ private void closeDialog() {
+ if (editDialog == null) throw new IllegalStateException();
+ Helper.hideKeyboardAlways(getActivity());
+ editDialog.dismiss();
+ editDialog = null;
+ NodeFragment.this.editDialog = null;
+ }
+
+ private void show() {
+ editDialog.show();
+ }
+
+ private void test() {
+ if (applyChanges())
+ new AsyncTestNode().execute();
+ }
+
+ private void showKeyboard() {
+ Helper.showKeyboard(editDialog);
+ }
+
+ AlertDialog editDialog = null;
+
+ TextInputLayout etNodeName;
+ TextInputLayout etNodeHost;
+ TextInputLayout etNodePort;
+ TextInputLayout etNodeUser;
+ TextInputLayout etNodePass;
+ TextView tvResult;
+
+ void showTestResult() {
+ if (nodeInfo.isSuccessful()) {
+ tvResult.setText(getString(R.string.node_result,
+ FORMATTER.format(nodeInfo.getHeight()), nodeInfo.getMajorVersion(),
+ nodeInfo.getResponseTime(), nodeInfo.getHostAddress()));
+ } else {
+ tvResult.setText(NodeInfoAdapter.getResponseErrorText(getActivity(), nodeInfo.getResponseCode()));
+ }
+ }
+
+ EditDialog(final NodeInfo nodeInfo) {
+ AlertDialog.Builder alertDialogBuilder = new MaterialAlertDialogBuilder(getActivity());
+ LayoutInflater li = LayoutInflater.from(alertDialogBuilder.getContext());
+ View promptsView = li.inflate(R.layout.prompt_editnode, null);
+ alertDialogBuilder.setView(promptsView);
+
+ etNodeName = promptsView.findViewById(R.id.etNodeName);
+ etNodeHost = promptsView.findViewById(R.id.etNodeHost);
+ etNodePort = promptsView.findViewById(R.id.etNodePort);
+ etNodeUser = promptsView.findViewById(R.id.etNodeUser);
+ etNodePass = promptsView.findViewById(R.id.etNodePass);
+ tvResult = promptsView.findViewById(R.id.tvResult);
+
+ if (nodeInfo != null) {
+ this.nodeInfo = nodeInfo;
+ nodeBackup = new NodeInfo(nodeInfo);
+ etNodeName.getEditText().setText(nodeInfo.getName());
+ etNodeHost.getEditText().setText(nodeInfo.getHost());
+ etNodePort.getEditText().setText(Integer.toString(nodeInfo.getRpcPort()));
+ etNodeUser.getEditText().setText(nodeInfo.getUsername());
+ etNodePass.getEditText().setText(nodeInfo.getPassword());
+ showTestResult();
+ } else {
+ this.nodeInfo = new NodeInfo();
+ nodeBackup = null;
+ }
+
+ // set dialog message
+ alertDialogBuilder
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok), null)
+ .setNeutralButton(getString(R.string.label_test), null)
+ .setNegativeButton(getString(R.string.label_cancel),
+ (dialog, id) -> {
+ closeDialog();
+ nodesAdapter.setNodes(); // to refresh test results
+ });
+
+ editDialog = alertDialogBuilder.create();
+ // these need to be here, since we don't always close the dialog
+ editDialog.setOnShowListener(new DialogInterface.OnShowListener() {
+ @Override
+ public void onShow(final DialogInterface dialog) {
+ Button testButton = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_NEUTRAL);
+ testButton.setOnClickListener(view -> test());
+
+ Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE);
+ button.setOnClickListener(view -> apply());
+ }
+ });
+
+ if (Helper.preventScreenshot()) {
+ editDialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+
+ etNodePass.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ editDialog.getButton(DialogInterface.BUTTON_NEUTRAL).requestFocus();
+ test();
+ return true;
+ }
+ return false;
+ });
+ }
+
+ private class AsyncTestNode extends AsyncTask {
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ tvResult.setText(getString(R.string.node_testing, nodeInfo.getHostAddress()));
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ nodeInfo.testRpcService();
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (editDialog != null) {
+ showTestResult();
+ }
+ if (shutdown) {
+ if (nodeBackup == null) {
+ nodesAdapter.addNode(nodeInfo);
+ } else {
+ nodesAdapter.setNodes();
+ }
+ nodesAdapter.notifyItemChanged(nodeInfo);
+ }
+ }
+ }
+ }
+
+ void restoreDefaultNodes() {
+ if (WalletManager.getInstance().getNetworkType() == NetworkType.NetworkType_Mainnet) {
+ if (!refresh(AsyncFindNodes.RESTORE_DEFAULTS)) {
+ Toast.makeText(getActivity(), getString(R.string.toast_default_nodes), Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.node_wrong_net), Toast.LENGTH_LONG).show();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/OnBackPressedListener.java b/app/src/main/java/com/m2049r/xmrwallet/OnBackPressedListener.java
new file mode 100644
index 0000000..eb09125
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/OnBackPressedListener.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+public interface OnBackPressedListener {
+ boolean onBackPressed();
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/OnBlockUpdateListener.java b/app/src/main/java/com/m2049r/xmrwallet/OnBlockUpdateListener.java
new file mode 100644
index 0000000..242bea0
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/OnBlockUpdateListener.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import com.m2049r.xmrwallet.model.Wallet;
+
+public interface OnBlockUpdateListener {
+ void onBlockUpdate(final Wallet wallet);
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/OnUriScannedListener.java b/app/src/main/java/com/m2049r/xmrwallet/OnUriScannedListener.java
new file mode 100644
index 0000000..34fa1c5
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/OnUriScannedListener.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import com.m2049r.xmrwallet.data.BarcodeData;
+
+public interface OnUriScannedListener {
+ boolean onUriScanned(BarcodeData barcodeData);
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/ReceiveFragment.java b/app/src/main/java/com/m2049r/xmrwallet/ReceiveFragment.java
new file mode 100644
index 0000000..06621ff
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/ReceiveFragment.java
@@ -0,0 +1,469 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
+import android.nfc.NfcManager;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.InputType;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.FileProvider;
+import androidx.fragment.app.Fragment;
+
+import com.google.android.material.textfield.TextInputLayout;
+import com.google.android.material.transition.MaterialContainerTransform;
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.Crypto;
+import com.m2049r.xmrwallet.data.Subaddress;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.ExchangeView;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import timber.log.Timber;
+
+public class ReceiveFragment extends Fragment {
+
+ private ProgressBar pbProgress;
+ private TextView tvAddress;
+ private TextInputLayout etNotes;
+ private ExchangeView evAmount;
+ private TextView tvQrCode;
+ private ImageView ivQrCode;
+ private ImageView ivQrCodeFull;
+ private EditText etDummy;
+ private ImageButton bCopyAddress;
+ private MenuItem shareItem;
+
+ private Wallet wallet = null;
+ private boolean isMyWallet = false;
+
+ public interface Listener {
+ void setToolbarButton(int type);
+
+ void setTitle(String title);
+
+ void setSubtitle(String subtitle);
+
+ void showSubaddresses(boolean managerMode);
+
+ Subaddress getSelectedSubaddress();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ View view = inflater.inflate(R.layout.fragment_receive, container, false);
+
+ pbProgress = view.findViewById(R.id.pbProgress);
+ tvAddress = view.findViewById(R.id.tvAddress);
+ etNotes = view.findViewById(R.id.etNotes);
+ evAmount = view.findViewById(R.id.evAmount);
+ ivQrCode = view.findViewById(R.id.qrCode);
+ tvQrCode = view.findViewById(R.id.tvQrCode);
+ ivQrCodeFull = view.findViewById(R.id.qrCodeFull);
+ etDummy = view.findViewById(R.id.etDummy);
+ bCopyAddress = view.findViewById(R.id.bCopyAddress);
+
+ etDummy.setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+
+ bCopyAddress.setOnClickListener(v -> copyAddress());
+
+ evAmount.setOnNewAmountListener(xmr -> {
+ Timber.d("new amount = %s", xmr);
+ generateQr();
+ if (shareRequested && (xmr != null)) share();
+ });
+
+ evAmount.setOnFailedExchangeListener(() -> {
+ if (isAdded()) {
+ clearQR();
+ Toast.makeText(getActivity(), getString(R.string.message_exchange_failed), Toast.LENGTH_LONG).show();
+ }
+ });
+
+ final EditText notesEdit = etNotes.getEditText();
+ notesEdit.setRawInputType(InputType.TYPE_CLASS_TEXT);
+ notesEdit.setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ generateQr();
+ return true;
+ }
+ return false;
+ });
+ notesEdit.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ clearQR();
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+ });
+
+ tvAddress.setOnClickListener(v -> {
+ listenerCallback.showSubaddresses(false);
+ });
+
+ view.findViewById(R.id.cvQrCode).setOnClickListener(v -> {
+ Helper.hideKeyboard(getActivity());
+ etDummy.requestFocus();
+ if (qrValid) {
+ ivQrCodeFull.setImageBitmap(((BitmapDrawable) ivQrCode.getDrawable()).getBitmap());
+ ivQrCodeFull.setVisibility(View.VISIBLE);
+ } else {
+ evAmount.doExchange();
+ }
+ });
+
+ ivQrCodeFull.setOnClickListener(v -> {
+ ivQrCodeFull.setImageBitmap(null);
+ ivQrCodeFull.setVisibility(View.GONE);
+ });
+
+ showProgress();
+ clearQR();
+
+ if (getActivity() instanceof GenerateReviewFragment.ListenerWithWallet) {
+ wallet = ((GenerateReviewFragment.ListenerWithWallet) getActivity()).getWallet();
+ show();
+ } else {
+ throw new IllegalStateException("no wallet info");
+ }
+
+ View tvNfc = view.findViewById(R.id.tvNfc);
+ NfcManager manager = (NfcManager) getContext().getSystemService(Context.NFC_SERVICE);
+ if ((manager != null) && (manager.getDefaultAdapter() != null))
+ tvNfc.setVisibility(View.VISIBLE);
+
+ return view;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ final MaterialContainerTransform transform = new MaterialContainerTransform();
+ transform.setDrawingViewId(R.id.fragment_container);
+ transform.setDuration(getResources().getInteger(R.integer.tx_item_transition_duration));
+ transform.setAllContainerColors(ThemeHelper.getThemedColor(getContext(), android.R.attr.colorBackground));
+ setSharedElementEnterTransition(transform);
+ }
+
+ private boolean shareRequested = false;
+
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, final MenuInflater inflater) {
+ inflater.inflate(R.menu.receive_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+
+ shareItem = menu.findItem(R.id.menu_item_share);
+ shareItem.setOnMenuItemClickListener(item -> {
+ if (shareRequested) return true;
+ shareRequested = true;
+ if (!qrValid) {
+ evAmount.doExchange();
+ } else {
+ share();
+ }
+ return true;
+ });
+ }
+
+ private void share() {
+ shareRequested = false;
+ if (saveQrCode()) {
+ final Intent sendIntent = getSendIntent();
+ if (sendIntent != null)
+ startActivity(Intent.createChooser(sendIntent, null));
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.message_qr_failed), Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private boolean saveQrCode() {
+ if (!qrValid) throw new IllegalStateException("trying to save null qr code!");
+
+ File cachePath = new File(getActivity().getCacheDir(), "images");
+ if (!cachePath.exists())
+ if (!cachePath.mkdirs()) throw new IllegalStateException("cannot create images folder");
+ File png = new File(cachePath, "QR.png");
+ try {
+ FileOutputStream stream = new FileOutputStream(png);
+ Bitmap qrBitmap = ((BitmapDrawable) ivQrCode.getDrawable()).getBitmap();
+ qrBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
+ stream.close();
+ return true;
+ } catch (IOException ex) {
+ Timber.e(ex);
+ // make sure we don't share an old qr code
+ if (!png.delete()) throw new IllegalStateException("cannot delete old qr code");
+ // if we manage to delete it, the URI points to nothing and the user gets a toast with the error
+ }
+ return false;
+ }
+
+ private Intent getSendIntent() {
+ File imagePath = new File(requireActivity().getCacheDir(), "images");
+ File png = new File(imagePath, "QR.png");
+ Uri contentUri = FileProvider.getUriForFile(requireActivity(), BuildConfig.APPLICATION_ID + ".fileprovider", png);
+ if (contentUri != null) {
+ Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // temp permission for receiving app to read this file
+ shareIntent.setTypeAndNormalize("image/png");
+ shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
+ if (bcData != null)
+ shareIntent.putExtra(Intent.EXTRA_TEXT, bcData.getUriString());
+ return shareIntent;
+ }
+ return null;
+ }
+
+ void copyAddress() {
+ Helper.clipBoardCopy(requireActivity(), getString(R.string.label_copy_address), subaddress.getAddress());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_address), Toast.LENGTH_SHORT).show();
+ }
+
+ private boolean qrValid = false;
+
+ void clearQR() {
+ if (qrValid) {
+ ivQrCode.setImageBitmap(null);
+ qrValid = false;
+ if (isLoaded)
+ tvQrCode.setVisibility(View.VISIBLE);
+ }
+ }
+
+ void setQR(Bitmap qr) {
+ ivQrCode.setImageBitmap(qr);
+ qrValid = true;
+ tvQrCode.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ listenerCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+ if (wallet != null) {
+ listenerCallback.setTitle(wallet.getName());
+ listenerCallback.setSubtitle(wallet.getAccountLabel());
+ setNewSubaddress();
+ } else {
+ listenerCallback.setSubtitle(getString(R.string.status_wallet_loading));
+ clearQR();
+ }
+ }
+
+ private boolean isLoaded = false;
+
+ private void show() {
+ Timber.d("name=%s", wallet.getName());
+ isLoaded = true;
+ hideProgress();
+ }
+
+ public BarcodeData getBarcodeData() {
+ if (qrValid)
+ return bcData;
+ else
+ return null;
+ }
+
+ private BarcodeData bcData = null;
+
+ private void generateQr() {
+ Timber.d("GENQR");
+ String address = subaddress.getAddress();
+ String notes = etNotes.getEditText().getText().toString();
+ String xmrAmount = evAmount.getAmount();
+ Timber.d("%s/%s/%s", xmrAmount, notes, address);
+ if ((xmrAmount == null) || !Wallet.isAddressValid(address)) {
+ clearQR();
+ Timber.d("CLEARQR");
+ return;
+ }
+ bcData = new BarcodeData(Crypto.XMR, address, notes, xmrAmount);
+ int size = Math.max(ivQrCode.getWidth(), ivQrCode.getHeight());
+ Bitmap qr = generate(bcData.getUriString(), size, size);
+ if (qr != null) {
+ setQR(qr);
+ Timber.d("SETQR");
+ etDummy.requestFocus();
+ Helper.hideKeyboard(getActivity());
+ }
+ }
+
+ public Bitmap generate(String text, int width, int height) {
+ if ((width <= 0) || (height <= 0)) return null;
+ Map hints = new HashMap<>();
+ hints.put(EncodeHintType.CHARACTER_SET, "utf-8");
+ hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
+ try {
+ BitMatrix bitMatrix = new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, width, height, hints);
+ int[] pixels = new int[width * height];
+ for (int i = 0; i < height; i++) {
+ for (int j = 0; j < width; j++) {
+ if (bitMatrix.get(j, i)) {
+ pixels[i * width + j] = 0x00000000;
+ } else {
+ pixels[i * height + j] = 0xffffffff;
+ }
+ }
+ }
+ Bitmap bitmap = Bitmap.createBitmap(pixels, 0, width, width, height, Bitmap.Config.RGB_565);
+ bitmap = addLogo(bitmap);
+ return bitmap;
+ } catch (WriterException ex) {
+ Timber.e(ex);
+ }
+ return null;
+ }
+
+ private Bitmap addLogo(Bitmap qrBitmap) {
+ // addume logo & qrcode are both square
+ Bitmap logo = getMoneroLogo();
+ final int qrSize = qrBitmap.getWidth();
+ final int logoSize = logo.getWidth();
+
+ Bitmap logoBitmap = Bitmap.createBitmap(qrSize, qrSize, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(logoBitmap);
+ canvas.drawBitmap(qrBitmap, 0, 0, null);
+ canvas.save();
+ final float sx = 0.2f * qrSize / logoSize;
+ canvas.scale(sx, sx, qrSize / 2f, qrSize / 2f);
+ canvas.drawBitmap(logo, (qrSize - logoSize) / 2f, (qrSize - logoSize) / 2f, null);
+ canvas.restore();
+ return logoBitmap;
+ }
+
+ private Bitmap logo = null;
+
+ private Bitmap getMoneroLogo() {
+ if (logo == null) {
+ logo = Helper.getBitmap(getContext(), R.drawable.ic_monero_logo_b);
+ }
+ return logo;
+ }
+
+ public void showProgress() {
+ pbProgress.setVisibility(View.VISIBLE);
+ }
+
+ public void hideProgress() {
+ pbProgress.setVisibility(View.GONE);
+ }
+
+ Listener listenerCallback = null;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.listenerCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onPause() {
+ Timber.d("onPause()");
+ Helper.hideKeyboard(getActivity());
+ super.onPause();
+ }
+
+ @Override
+ public void onDetach() {
+ Timber.d("onDetach()");
+ if ((wallet != null) && (isMyWallet)) {
+ wallet.close();
+ wallet = null;
+ isMyWallet = false;
+ }
+ super.onDetach();
+ }
+
+ private Subaddress subaddress = null;
+
+ void setNewSubaddress() {
+ final Subaddress newSubaddress = listenerCallback.getSelectedSubaddress();
+ if (!Objects.equals(subaddress, newSubaddress)) {
+ final Runnable resetSize = () -> tvAddress.animate().setDuration(125).scaleX(1).scaleY(1).start();
+ tvAddress.animate().alpha(1).setDuration(125)
+ .scaleX(1.2f).scaleY(1.2f)
+ .withEndAction(resetSize).start();
+ }
+ subaddress = newSubaddress;
+ final Context context = getContext();
+ Spanned label = Html.fromHtml(context.getString(R.string.receive_subaddress,
+ Integer.toHexString(ThemeHelper.getThemedColor(context, R.attr.positiveColor) & 0xFFFFFF),
+ Integer.toHexString(ThemeHelper.getThemedColor(context, android.R.attr.colorBackground) & 0xFFFFFF),
+ subaddress.getDisplayLabel(), subaddress.getAddress()));
+ tvAddress.setText(label);
+ generateQr();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/ScannerFragment.java b/app/src/main/java/com/m2049r/xmrwallet/ScannerFragment.java
new file mode 100644
index 0000000..32512fd
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/ScannerFragment.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2017 dm77, m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Handler;
+import androidx.fragment.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.Result;
+
+import me.dm7.barcodescanner.zxing.ZXingScannerView;
+import timber.log.Timber;
+
+public class ScannerFragment extends Fragment implements ZXingScannerView.ResultHandler {
+
+ private OnScannedListener onScannedListener;
+
+ public interface OnScannedListener {
+ boolean onScanned(String qrCode);
+ }
+
+ private ZXingScannerView mScannerView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ Timber.d("onCreateView");
+ mScannerView = new ZXingScannerView(getActivity());
+ return mScannerView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume");
+ mScannerView.setResultHandler(this);
+ mScannerView.startCamera();
+ }
+
+ @Override
+ public void handleResult(Result rawResult) {
+ if ((rawResult.getBarcodeFormat() == BarcodeFormat.QR_CODE)) {
+ if (onScannedListener.onScanned(rawResult.getText())) {
+ return;
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.send_qr_address_invalid), Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.send_qr_invalid), Toast.LENGTH_SHORT).show();
+ }
+
+ // Note from dm77:
+ // * Wait 2 seconds to resume the preview.
+ // * On older devices continuously stopping and resuming camera preview can result in freezing the app.
+ // * I don't know why this is the case but I don't have the time to figure out.
+ Handler handler = new Handler();
+ handler.postDelayed(() -> mScannerView.resumeCameraPreview(ScannerFragment.this), 2000);
+ }
+
+ @Override
+ public void onPause() {
+ Timber.d("onPause");
+ mScannerView.stopCamera();
+ super.onPause();
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ if (context instanceof OnScannedListener) {
+ this.onScannedListener = (OnScannedListener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/SecureActivity.java b/app/src/main/java/com/m2049r/xmrwallet/SecureActivity.java
new file mode 100644
index 0000000..238aeea
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/SecureActivity.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.LocaleHelper;
+
+import java.util.Locale;
+
+import static android.view.WindowManager.LayoutParams;
+
+public abstract class SecureActivity extends AppCompatActivity {
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (Helper.preventScreenshot()) {
+ getWindow().setFlags(LayoutParams.FLAG_SECURE, LayoutParams.FLAG_SECURE);
+ }
+ }
+
+ @Override
+ protected void attachBaseContext(Context newBase) {
+ super.attachBaseContext(newBase);
+ applyOverrideConfiguration(new Configuration());
+ }
+
+ @Override
+ public void applyOverrideConfiguration(Configuration newConfig) {
+ super.applyOverrideConfiguration(updateConfigurationIfSupported(newConfig));
+ }
+
+ private Configuration updateConfigurationIfSupported(Configuration config) {
+ // Configuration.getLocales is added after 24 and Configuration.locale is deprecated in 24
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ if (!config.getLocales().isEmpty()) {
+ return config;
+ }
+ } else {
+ if (config.locale != null) {
+ return config;
+ }
+ }
+
+ Locale locale = LocaleHelper.getPreferredLocale(this);
+ if (locale != null) {
+ config.setLocale(locale);
+ }
+ return config;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/SettingsFragment.java b/app/src/main/java/com/m2049r/xmrwallet/SettingsFragment.java
new file mode 100644
index 0000000..6a4bb5a
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/SettingsFragment.java
@@ -0,0 +1,125 @@
+package com.m2049r.xmrwallet;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+
+import androidx.annotation.StyleRes;
+import androidx.preference.ListPreference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceManager;
+
+import com.m2049r.xmrwallet.dialog.AboutFragment;
+import com.m2049r.xmrwallet.dialog.CreditsFragment;
+import com.m2049r.xmrwallet.dialog.PrivacyFragment;
+import com.m2049r.xmrwallet.util.DayNightMode;
+import com.m2049r.xmrwallet.util.LocaleHelper;
+import com.m2049r.xmrwallet.util.NightmodeHelper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Locale;
+
+import timber.log.Timber;
+
+public class SettingsFragment extends PreferenceFragmentCompat
+ implements SharedPreferences.OnSharedPreferenceChangeListener {
+ @Override
+ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+ setPreferencesFromResource(R.xml.root_preferences, rootKey);
+
+ findPreference(getString(R.string.about_info)).setOnPreferenceClickListener(preference -> {
+ AboutFragment.display(getParentFragmentManager());
+ return true;
+ });
+ findPreference(getString(R.string.privacy_info)).setOnPreferenceClickListener(preference -> {
+ PrivacyFragment.display(getParentFragmentManager());
+ return true;
+ });
+ findPreference(getString(R.string.credits_info)).setOnPreferenceClickListener(preference -> {
+ CreditsFragment.display(getParentFragmentManager());
+ return true;
+ });
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (key.equals(getString(R.string.preferred_locale))) {
+ activity.recreate();
+ } else if (key.equals(getString(R.string.preferred_nightmode))) {
+ NightmodeHelper.setNightMode(DayNightMode.valueOf(sharedPreferences.getString(key, "AUTO")));
+ } else if (key.equals(getString(R.string.preferred_theme))) {
+ ThemeHelper.setTheme((Activity) activity, sharedPreferences.getString(key, "Classic"));
+ activity.recreate();
+ }
+ }
+
+ private SettingsFragment.Listener activity;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ if (context instanceof SettingsFragment.Listener) {
+ activity = (SettingsFragment.Listener) context;
+ } else {
+ throw new ClassCastException(context + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activity.setSubtitle(getString(R.string.menu_settings));
+ activity.setToolbarButton(Toolbar.BUTTON_BACK);
+ populateLanguages();
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ public interface Listener {
+ void setToolbarButton(int type);
+
+ void setSubtitle(String title);
+
+ void recreate();
+
+ void setTheme(@StyleRes final int resId);
+ }
+
+ public void populateLanguages() {
+ ListPreference language = findPreference(getString(R.string.preferred_locale));
+ assert language != null;
+
+ final ArrayList availableLocales = LocaleHelper.getAvailableLocales(requireContext());
+ Collections.sort(availableLocales, (locale1, locale2) -> {
+ String localeString1 = LocaleHelper.getDisplayName(locale1, true);
+ String localeString2 = LocaleHelper.getDisplayName(locale2, true);
+ return localeString1.compareTo(localeString2);
+ });
+
+ String[] localeDisplayNames = new String[1 + availableLocales.size()];
+ localeDisplayNames[0] = getString(R.string.language_system_default);
+ for (int i = 1; i < localeDisplayNames.length; i++) {
+ localeDisplayNames[i] = LocaleHelper.getDisplayName(availableLocales.get(i - 1), true);
+ }
+ language.setEntries(localeDisplayNames);
+
+ String[] languageTags = new String[1 + availableLocales.size()];
+ languageTags[0] = "";
+ for (int i = 1; i < languageTags.length; i++) {
+ languageTags[i] = availableLocales.get(i - 1).toLanguageTag();
+ }
+ language.setEntryValues(languageTags);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/SubaddressFragment.java b/app/src/main/java/com/m2049r/xmrwallet/SubaddressFragment.java
new file mode 100644
index 0000000..c42ae7a
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/SubaddressFragment.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.m2049r.xmrwallet.data.Subaddress;
+import com.m2049r.xmrwallet.layout.SubaddressInfoAdapter;
+import com.m2049r.xmrwallet.ledger.LedgerProgressDialog;
+import com.m2049r.xmrwallet.model.TransactionInfo;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.RequiredArgsConstructor;
+import timber.log.Timber;
+
+public class SubaddressFragment extends Fragment implements SubaddressInfoAdapter.OnInteractionListener,
+ View.OnClickListener, OnBlockUpdateListener {
+ static public final String KEY_MODE = "mode";
+ static public final String MODE_MANAGER = "manager";
+
+ private SubaddressInfoAdapter adapter;
+
+ private Listener activityCallback;
+
+ private Wallet wallet;
+
+ // Container Activity must implement this interface
+ public interface Listener {
+ void onSubaddressSelected(Subaddress subaddress);
+
+ void setSubtitle(String title);
+
+ void setToolbarButton(int type);
+
+ void showSubaddress(View view, final int subaddressIndex);
+
+ void saveWallet();
+ }
+
+ public interface ProgressListener {
+ void showProgressDialog(int msgId);
+
+ void showLedgerProgressDialog(int mode);
+
+ void dismissProgressDialog();
+ }
+
+ private ProgressListener progressCallback = null;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof ProgressListener) {
+ progressCallback = (ProgressListener) context;
+ }
+ if (context instanceof Listener) {
+ activityCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onPause() {
+ Timber.d("onPause()");
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ activityCallback.setSubtitle(getString(R.string.subbaddress_title));
+ activityCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Timber.d("onCreateView");
+
+ final Bundle b = getArguments();
+ managerMode = ((b != null) && (MODE_MANAGER.equals(b.getString(KEY_MODE))));
+
+ View view = inflater.inflate(R.layout.fragment_subaddress, container, false);
+ view.findViewById(R.id.fab).setOnClickListener(this);
+
+ if (managerMode) {
+ view.findViewById(R.id.tvInstruction).setVisibility(View.GONE);
+ view.findViewById(R.id.tvHint).setVisibility(View.GONE);
+ }
+
+ final RecyclerView list = view.findViewById(R.id.list);
+ adapter = new SubaddressInfoAdapter(getActivity(), this);
+ list.setAdapter(adapter);
+ adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onItemRangeInserted(int positionStart, int itemCount) {
+ list.scrollToPosition(positionStart);
+ }
+ });
+
+ Helper.hideKeyboard(getActivity());
+
+ wallet = WalletManager.getInstance().getWallet();
+
+ loadList();
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ }
+
+ public void loadList() {
+ Timber.d("loadList()");
+ final int numSubaddresses = wallet.getNumSubaddresses();
+ final List list = new ArrayList<>();
+ for (int i = 0; i < numSubaddresses; i++) {
+ list.add(wallet.getSubaddressObject(i));
+ }
+ adapter.setInfos(list);
+ }
+
+ @Override
+ public void onBlockUpdate(Wallet wallet) {
+ loadList();
+ }
+
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.fab) {
+ getNewSubaddress();
+ }
+ }
+
+ private int lastUsedSubaddress() {
+ int lastUsedSubaddress = 0;
+ for (TransactionInfo info : wallet.getHistory().getAll()) {
+ if (info.addressIndex > lastUsedSubaddress)
+ lastUsedSubaddress = info.addressIndex;
+ }
+ return lastUsedSubaddress;
+ }
+
+ private void getNewSubaddress() {
+ final int maxSubaddresses = lastUsedSubaddress() + wallet.getDeviceType().getSubaddressLookahead();
+ if (wallet.getNumSubaddresses() < maxSubaddresses)
+ new AsyncSubaddress().executeOnExecutor(MoneroThreadPoolExecutor.MONERO_THREAD_POOL_EXECUTOR);
+ else
+ Toast.makeText(getActivity(), getString(R.string.max_subaddress_warning), Toast.LENGTH_LONG).show();
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ @RequiredArgsConstructor
+ private class AsyncSubaddress extends AsyncTask {
+ boolean dialogOpened = false;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if ((wallet.getDeviceType() == Wallet.Device.Device_Ledger) && (progressCallback != null)) {
+ progressCallback.showLedgerProgressDialog(LedgerProgressDialog.TYPE_SUBADDRESS);
+ dialogOpened = true;
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ if (params.length != 0) return false;
+ wallet.getNewSubaddress();
+ if (activityCallback != null) {
+ activityCallback.saveWallet();
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (dialogOpened)
+ progressCallback.dismissProgressDialog();
+ if (!isAdded()) // never mind then
+ return;
+ loadList();
+ }
+ }
+
+ boolean managerMode = false;
+
+ // Callbacks from SubaddressInfoAdapter
+ @Override
+ public void onInteraction(final View view, final Subaddress subaddress) {
+ if (managerMode)
+ activityCallback.showSubaddress(view, subaddress.getAddressIndex());
+ else
+ activityCallback.onSubaddressSelected(subaddress); // also closes the fragment with onBackpressed()
+ }
+
+ @Override
+ public boolean onLongInteraction(View view, Subaddress subaddress) {
+ activityCallback.showSubaddress(view, subaddress.getAddressIndex());
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/SubaddressInfoFragment.java b/app/src/main/java/com/m2049r/xmrwallet/SubaddressInfoFragment.java
new file mode 100644
index 0000000..3b0941a
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/SubaddressInfoFragment.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.transition.Transition;
+import androidx.transition.TransitionInflater;
+
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.xmrwallet.data.Subaddress;
+import com.m2049r.xmrwallet.layout.TransactionInfoAdapter;
+import com.m2049r.xmrwallet.model.TransactionInfo;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import timber.log.Timber;
+
+public class SubaddressInfoFragment extends Fragment
+ implements TransactionInfoAdapter.OnInteractionListener, OnBlockUpdateListener {
+ private TransactionInfoAdapter adapter;
+
+ private Subaddress subaddress;
+
+ private TextInputLayout etName;
+ private TextView tvAddress;
+ private TextView tvTxLabel;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_subaddressinfo, container, false);
+
+ etName = view.findViewById(R.id.etName);
+ tvAddress = view.findViewById(R.id.tvAddress);
+ tvTxLabel = view.findViewById(R.id.tvTxLabel);
+
+ final RecyclerView list = view.findViewById(R.id.list);
+ adapter = new TransactionInfoAdapter(getActivity(), this);
+ list.setAdapter(adapter);
+
+ final Wallet wallet = activityCallback.getWallet();
+
+ Bundle b = getArguments();
+ final int subaddressIndex = b.getInt("subaddressIndex");
+ subaddress = wallet.getSubaddressObject(subaddressIndex);
+
+ etName.getEditText().setText(subaddress.getDisplayLabel());
+ tvAddress.setText(getContext().getString(R.string.subbaddress_info_subtitle,
+ subaddress.getAddressIndex(), subaddress.getSquashedAddress()));
+
+ etName.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ wallet.setSubaddressLabel(subaddressIndex, etName.getEditText().getText().toString());
+ }
+ });
+ etName.getEditText().setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ Helper.hideKeyboard(getActivity());
+ wallet.setSubaddressLabel(subaddressIndex, etName.getEditText().getText().toString());
+ onRefreshed(wallet);
+ return true;
+ }
+ return false;
+ });
+
+ onRefreshed(wallet);
+
+ return view;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Transition transform = TransitionInflater.from(requireContext())
+ .inflateTransition(R.transition.details);
+ setSharedElementEnterTransition(transform);
+ }
+
+ public void onRefreshed(final Wallet wallet) {
+ Timber.d("onRefreshed");
+ List list = new ArrayList<>();
+ for (TransactionInfo info : wallet.getHistory().getAll()) {
+ if (info.addressIndex == subaddress.getAddressIndex())
+ list.add(info);
+ }
+ adapter.setInfos(list);
+ if (list.isEmpty())
+ tvTxLabel.setText(R.string.subaddress_notx_label);
+ else
+ tvTxLabel.setText(R.string.subaddress_tx_label);
+ }
+
+ @Override
+ public void onBlockUpdate(Wallet wallet) {
+ onRefreshed(wallet);
+ }
+
+ // Callbacks from TransactionInfoAdapter
+ @Override
+ public void onInteraction(final View view, final TransactionInfo infoItem) {
+ activityCallback.onTxDetailsRequest(view, infoItem);
+ }
+
+ Listener activityCallback;
+
+ // Container Activity must implement this interface
+ public interface Listener {
+ void onTxDetailsRequest(View view, TransactionInfo info);
+
+ Wallet getWallet();
+
+ void setToolbarButton(int type);
+
+ void setTitle(String title, String subtitle);
+
+ void setSubtitle(String subtitle);
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.activityCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setSubtitle(getString(R.string.subbaddress_title));
+ activityCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/TxFragment.java b/app/src/main/java/com/m2049r/xmrwallet/TxFragment.java
new file mode 100644
index 0000000..82be82b
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/TxFragment.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.InputType;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.transition.Transition;
+import androidx.transition.TransitionInflater;
+
+import com.m2049r.xmrwallet.data.Subaddress;
+import com.m2049r.xmrwallet.data.UserNotes;
+import com.m2049r.xmrwallet.model.TransactionInfo;
+import com.m2049r.xmrwallet.model.Transfer;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.TimeZone;
+
+import timber.log.Timber;
+
+public class TxFragment extends Fragment {
+
+ static public final String ARG_INFO = "info";
+
+ private final SimpleDateFormat TS_FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
+
+ public TxFragment() {
+ super();
+ Calendar cal = Calendar.getInstance();
+ TimeZone tz = cal.getTimeZone(); //get the local time zone.
+ TS_FORMATTER.setTimeZone(tz);
+ }
+
+ private TextView tvAccount;
+ private TextView tvAddress;
+ private TextView tvTxTimestamp;
+ private TextView tvTxId;
+ private TextView tvTxKey;
+ private TextView tvDestination;
+ private TextView tvTxPaymentId;
+ private TextView tvTxBlockheight;
+ private TextView tvTxAmount;
+ private TextView tvTxFee;
+ private TextView tvTxTransfers;
+ private TextView etTxNotes;
+
+ // XMRTO stuff
+ private View cvXmrTo;
+ private TextView tvTxXmrToKey;
+ private TextView tvDestinationBtc;
+ private TextView tvTxAmountBtc;
+ private TextView tvXmrToSupport;
+ private TextView tvXmrToKeyLabel;
+ private ImageView tvXmrToLogo;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_tx_info, container, false);
+
+ cvXmrTo = view.findViewById(R.id.cvXmrTo);
+ tvTxXmrToKey = view.findViewById(R.id.tvTxXmrToKey);
+ tvDestinationBtc = view.findViewById(R.id.tvDestinationBtc);
+ tvTxAmountBtc = view.findViewById(R.id.tvTxAmountBtc);
+ tvXmrToSupport = view.findViewById(R.id.tvXmrToSupport);
+ tvXmrToKeyLabel = view.findViewById(R.id.tvXmrToKeyLabel);
+ tvXmrToLogo = view.findViewById(R.id.tvXmrToLogo);
+
+ tvAccount = view.findViewById(R.id.tvAccount);
+ tvAddress = view.findViewById(R.id.tvAddress);
+ tvTxTimestamp = view.findViewById(R.id.tvTxTimestamp);
+ tvTxId = view.findViewById(R.id.tvTxId);
+ tvTxKey = view.findViewById(R.id.tvTxKey);
+ tvDestination = view.findViewById(R.id.tvDestination);
+ tvTxPaymentId = view.findViewById(R.id.tvTxPaymentId);
+ tvTxBlockheight = view.findViewById(R.id.tvTxBlockheight);
+ tvTxAmount = view.findViewById(R.id.tvTxAmount);
+ tvTxFee = view.findViewById(R.id.tvTxFee);
+ tvTxTransfers = view.findViewById(R.id.tvTxTransfers);
+ etTxNotes = view.findViewById(R.id.etTxNotes);
+
+ etTxNotes.setRawInputType(InputType.TYPE_CLASS_TEXT);
+
+ tvTxXmrToKey.setOnClickListener(v -> {
+ Helper.clipBoardCopy(getActivity(), getString(R.string.label_copy_xmrtokey), tvTxXmrToKey.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_xmrtokey), Toast.LENGTH_SHORT).show();
+ });
+
+ info = getArguments().getParcelable(ARG_INFO);
+ show();
+ return view;
+ }
+
+ void shareTxInfo() {
+ if (this.info == null) return;
+ StringBuffer sb = new StringBuffer();
+
+ sb.append(getString(R.string.tx_timestamp)).append(":\n");
+ sb.append(TS_FORMATTER.format(new Date(info.timestamp * 1000))).append("\n\n");
+
+ sb.append(getString(R.string.tx_amount)).append(":\n");
+ sb.append((info.direction == TransactionInfo.Direction.Direction_In ? "+" : "-"));
+ sb.append(Wallet.getDisplayAmount(info.amount)).append("\n");
+ sb.append(getString(R.string.tx_fee)).append(":\n");
+ sb.append(Wallet.getDisplayAmount(info.fee)).append("\n\n");
+
+ sb.append(getString(R.string.tx_notes)).append(":\n");
+ String oneLineNotes = info.notes.replace("\n", " ; ");
+ sb.append(oneLineNotes.isEmpty() ? "-" : oneLineNotes).append("\n\n");
+
+ sb.append(getString(R.string.tx_destination)).append(":\n");
+ sb.append(tvDestination.getText()).append("\n\n");
+
+ sb.append(getString(R.string.tx_paymentId)).append(":\n");
+ sb.append(info.paymentId).append("\n\n");
+
+ sb.append(getString(R.string.tx_id)).append(":\n");
+ sb.append(info.hash).append("\n");
+ sb.append(getString(R.string.tx_key)).append(":\n");
+ sb.append(info.txKey.isEmpty() ? "-" : info.txKey).append("\n\n");
+
+ sb.append(getString(R.string.tx_blockheight)).append(":\n");
+ if (info.isFailed) {
+ sb.append(getString(R.string.tx_failed)).append("\n");
+ } else if (info.isPending) {
+ sb.append(getString(R.string.tx_pending)).append("\n");
+ } else {
+ sb.append(info.blockheight).append("\n");
+ }
+ sb.append("\n");
+
+ sb.append(getString(R.string.tx_transfers)).append(":\n");
+ if (info.transfers != null) {
+ boolean comma = false;
+ for (Transfer transfer : info.transfers) {
+ if (comma) {
+ sb.append(", ");
+ } else {
+ comma = true;
+ }
+ sb.append(transfer.address).append(": ");
+ sb.append(Wallet.getDisplayAmount(transfer.amount));
+ }
+ } else {
+ sb.append("-");
+ }
+ sb.append("\n\n");
+
+ Intent sendIntent = new Intent();
+ sendIntent.setAction(Intent.ACTION_SEND);
+ sendIntent.putExtra(Intent.EXTRA_TEXT, sb.toString());
+ sendIntent.setType("text/plain");
+ startActivity(Intent.createChooser(sendIntent, null));
+ }
+
+ TransactionInfo info = null;
+ UserNotes userNotes = null;
+
+ void loadNotes() {
+ if ((userNotes == null) || (info.notes == null)) {
+ info.notes = activityCallback.getTxNotes(info.hash);
+ }
+ userNotes = new UserNotes(info.notes);
+ etTxNotes.setText(userNotes.note);
+ }
+
+ private void setTxColour(int clr) {
+ tvTxAmount.setTextColor(clr);
+ tvTxFee.setTextColor(clr);
+ }
+
+ private void showSubaddressLabel() {
+ final Subaddress subaddress = activityCallback.getWalletSubaddress(info.accountIndex, info.addressIndex);
+ final Context ctx = getContext();
+ Spanned label = Html.fromHtml(ctx.getString(R.string.tx_account_formatted,
+ info.accountIndex, info.addressIndex,
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, R.attr.positiveColor) & 0xFFFFFF),
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, android.R.attr.colorBackground) & 0xFFFFFF),
+ subaddress.getDisplayLabel()));
+ tvAccount.setText(label);
+ tvAccount.setOnClickListener(v -> activityCallback.showSubaddress(v, info.addressIndex));
+ }
+
+ private void show() {
+ if (info.txKey == null) {
+ info.txKey = activityCallback.getTxKey(info.hash);
+ }
+ if (info.address == null) {
+ info.address = activityCallback.getTxAddress(info.accountIndex, info.addressIndex);
+ }
+ loadNotes();
+
+ showSubaddressLabel();
+ tvAddress.setText(info.address);
+
+ tvTxTimestamp.setText(TS_FORMATTER.format(new Date(info.timestamp * 1000)));
+ tvTxId.setText(info.hash);
+ tvTxKey.setText(info.txKey.isEmpty() ? "-" : info.txKey);
+ tvTxPaymentId.setText(info.paymentId);
+ if (info.isFailed) {
+ tvTxBlockheight.setText(getString(R.string.tx_failed));
+ } else if (info.isPending) {
+ tvTxBlockheight.setText(getString(R.string.tx_pending));
+ } else {
+ tvTxBlockheight.setText("" + info.blockheight);
+ }
+ String sign = (info.direction == TransactionInfo.Direction.Direction_In ? "+" : "-");
+
+ long realAmount = info.amount;
+ tvTxAmount.setText(sign + Wallet.getDisplayAmount(realAmount));
+
+ if ((info.fee > 0)) {
+ String fee = Wallet.getDisplayAmount(info.fee);
+ tvTxFee.setText(getString(R.string.tx_list_fee, fee));
+ } else {
+ tvTxFee.setText(null);
+ tvTxFee.setVisibility(View.GONE);
+ }
+
+ if (info.isFailed) {
+ tvTxAmount.setText(getString(R.string.tx_list_amount_failed, Wallet.getDisplayAmount(info.amount)));
+ tvTxFee.setText(getString(R.string.tx_list_failed_text));
+ setTxColour(ThemeHelper.getThemedColor(getContext(), R.attr.neutralColor));
+ } else if (info.isPending) {
+ setTxColour(ThemeHelper.getThemedColor(getContext(), R.attr.neutralColor));
+ } else if (info.direction == TransactionInfo.Direction.Direction_In) {
+ setTxColour(ThemeHelper.getThemedColor(getContext(), R.attr.positiveColor));
+ } else {
+ setTxColour(ThemeHelper.getThemedColor(getContext(), R.attr.negativeColor));
+ }
+ Set destinations = new HashSet<>();
+ StringBuilder sb = new StringBuilder();
+ StringBuilder dstSb = new StringBuilder();
+ if (info.transfers != null) {
+ boolean newline = false;
+ for (Transfer transfer : info.transfers) {
+ destinations.add(transfer.address);
+ if (newline) {
+ sb.append("\n");
+ } else {
+ newline = true;
+ }
+ sb.append("[").append(transfer.address.substring(0, 6)).append("] ");
+ sb.append(Wallet.getDisplayAmount(transfer.amount));
+ }
+ newline = false;
+ for (String dst : destinations) {
+ if (newline) {
+ dstSb.append("\n");
+ } else {
+ newline = true;
+ }
+ dstSb.append(dst);
+ }
+ } else {
+ sb.append("-");
+ dstSb.append(info.direction == TransactionInfo.Direction.Direction_In ?
+ activityCallback.getWalletSubaddress(info.accountIndex, info.addressIndex).getAddress() :
+ "-");
+ }
+ tvTxTransfers.setText(sb.toString());
+ tvDestination.setText(dstSb.toString());
+ showBtcInfo();
+ }
+
+ @SuppressLint("SetTextI18n")
+ void showBtcInfo() {
+ if (userNotes.xmrtoKey != null) {
+ cvXmrTo.setVisibility(View.VISIBLE);
+ String key = userNotes.xmrtoKey;
+ if ("xmrto".equals(userNotes.xmrtoTag)) { // legacy xmr.to service :(
+ key = "xmrto-" + key;
+ }
+ tvTxXmrToKey.setText(key);
+ tvDestinationBtc.setText(userNotes.xmrtoDestination);
+ tvTxAmountBtc.setText(userNotes.xmrtoAmount + " " + userNotes.xmrtoCurrency);
+ switch (userNotes.xmrtoTag) {
+ case "xmrto":
+ tvXmrToSupport.setVisibility(View.GONE);
+ tvXmrToKeyLabel.setVisibility(View.INVISIBLE);
+ tvXmrToLogo.setImageResource(R.drawable.ic_xmrto_logo);
+ break;
+ case "side": // defaults in layout - just add underline
+ tvXmrToSupport.setPaintFlags(tvXmrToSupport.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
+ tvXmrToSupport.setOnClickListener(v -> {
+ Uri uri = Uri.parse("https://sideshift.ai/orders/" + userNotes.xmrtoKey);
+ Intent intent = new Intent(Intent.ACTION_VIEW, uri);
+ startActivity(intent);
+ });
+ break;
+ default:
+ tvXmrToSupport.setVisibility(View.GONE);
+ tvXmrToKeyLabel.setVisibility(View.INVISIBLE);
+ tvXmrToLogo.setVisibility(View.GONE);
+ }
+ } else {
+ cvXmrTo.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ Transition transform = TransitionInflater.from(requireContext())
+ .inflateTransition(R.transition.details);
+ setSharedElementEnterTransition(transform);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.tx_info_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ Listener activityCallback;
+
+ public interface Listener {
+ Subaddress getWalletSubaddress(int accountIndex, int subaddressIndex);
+
+ String getTxKey(String hash);
+
+ String getTxNotes(String hash);
+
+ boolean setTxNotes(String txId, String txNotes);
+
+ String getTxAddress(int major, int minor);
+
+ void setToolbarButton(int type);
+
+ void setSubtitle(String subtitle);
+
+ void showSubaddress(View view, final int subaddressIndex);
+
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ if (context instanceof TxFragment.Listener) {
+ this.activityCallback = (TxFragment.Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onPause() {
+ if (!etTxNotes.getText().toString().equals(userNotes.note)) { // notes have changed
+ // save them
+ userNotes.setNote(etTxNotes.getText().toString());
+ info.notes = userNotes.txNotes;
+ activityCallback.setTxNotes(info.hash, info.notes);
+ }
+ Helper.hideKeyboard(getActivity());
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setSubtitle(getString(R.string.tx_title));
+ activityCallback.setToolbarButton(Toolbar.BUTTON_BACK);
+ showSubaddressLabel();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/WalletActivity.java b/app/src/main/java/com/m2049r/xmrwallet/WalletActivity.java
new file mode 100644
index 0000000..5ab6ad7
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/WalletActivity.java
@@ -0,0 +1,1220 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBarDrawerToggle;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.view.GravityCompat;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.navigation.NavigationView;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.Subaddress;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.data.UserNotes;
+import com.m2049r.xmrwallet.dialog.CreditsFragment;
+import com.m2049r.xmrwallet.dialog.HelpFragment;
+import com.m2049r.xmrwallet.fragment.send.SendAddressWizardFragment;
+import com.m2049r.xmrwallet.fragment.send.SendFragment;
+import com.m2049r.xmrwallet.ledger.LedgerProgressDialog;
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.model.TransactionInfo;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.service.WalletService;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import timber.log.Timber;
+
+public class WalletActivity extends BaseActivity implements WalletFragment.Listener,
+ WalletService.Observer, SendFragment.Listener, TxFragment.Listener,
+ GenerateReviewFragment.ListenerWithWallet,
+ GenerateReviewFragment.Listener,
+ GenerateReviewFragment.PasswordChangedListener,
+ ScannerFragment.OnScannedListener, ReceiveFragment.Listener,
+ SendAddressWizardFragment.OnScanListener,
+ WalletFragment.DrawerLocker,
+ NavigationView.OnNavigationItemSelectedListener,
+ SubaddressFragment.Listener,
+ SubaddressInfoFragment.Listener {
+
+ public static final String REQUEST_ID = "id";
+ public static final String REQUEST_PW = "pw";
+ public static final String REQUEST_FINGERPRINT_USED = "fingerprint";
+ public static final String REQUEST_STREETMODE = "streetmode";
+ public static final String REQUEST_URI = "uri";
+
+ private NavigationView accountsView;
+ private DrawerLayout drawer;
+ private ActionBarDrawerToggle drawerToggle;
+
+ private Toolbar toolbar;
+ private boolean requestStreetMode = false;
+
+ private String password;
+
+ private String uri = null;
+
+ private long streetMode = 0;
+
+ @Override
+ public void onPasswordChanged(String newPassword) {
+ password = newPassword;
+ }
+
+ @Override
+ public String getPassword() {
+ return password;
+ }
+
+ @Override
+ public void setToolbarButton(int type) {
+ toolbar.setButton(type);
+ }
+
+ @Override
+ public void setTitle(String title, String subtitle) {
+ toolbar.setTitle(title, subtitle);
+ }
+
+ @Override
+ public void setTitle(String title) {
+ Timber.d("setTitle:%s.", title);
+ toolbar.setTitle(title);
+ }
+
+ @Override
+ public void setSubtitle(String subtitle) {
+ toolbar.setSubtitle(subtitle);
+ }
+
+ private boolean synced = false;
+
+ @Override
+ public boolean isSynced() {
+ return synced;
+ }
+
+ private WalletFragment getWalletFragment() {
+ return (WalletFragment) getSupportFragmentManager().findFragmentByTag(WalletFragment.class.getName());
+ }
+
+ private Fragment getCurrentFragment() {
+ return getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ }
+
+ @Override
+ public boolean isStreetMode() {
+ return streetMode > 0;
+ }
+
+ private void enableStreetMode(boolean enable) {
+ if (enable) {
+ streetMode = getWallet().getDaemonBlockChainHeight();
+ } else {
+ streetMode = 0;
+ }
+ final WalletFragment walletFragment = getWalletFragment();
+ if (walletFragment != null) walletFragment.resetDismissedTransactions();
+ forceUpdate();
+ runOnUiThread(() -> {
+ if (getWallet() != null)
+ updateAccountsBalance();
+ });
+ }
+
+ @Override
+ public long getStreetModeHeight() {
+ return streetMode;
+ }
+
+ @Override
+ public boolean isWatchOnly() {
+ return getWallet().isWatchOnly();
+ }
+
+ @Override
+ public String getTxKey(String txId) {
+ return getWallet().getTxKey(txId);
+ }
+
+ @Override
+ public String getTxNotes(String txId) {
+ return getWallet().getUserNote(txId);
+ }
+
+ @Override
+ public boolean setTxNotes(String txId, String txNotes) {
+ return getWallet().setUserNote(txId, txNotes);
+ }
+
+ @Override
+ public String getTxAddress(int major, int minor) {
+ return getWallet().getSubaddress(major, minor);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ Timber.d("onStart()");
+ }
+
+ private void startWalletService() {
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ acquireWakeLock();
+ String walletId = extras.getString(REQUEST_ID);
+ // we can set the streetmode height AFTER opening the wallet
+ requestStreetMode = extras.getBoolean(REQUEST_STREETMODE);
+ password = extras.getString(REQUEST_PW);
+ uri = extras.getString(REQUEST_URI);
+ connectWalletService(walletId, password);
+ } else {
+ finish();
+ }
+ }
+
+ private void stopWalletService() {
+ disconnectWalletService();
+ releaseWakeLock();
+ }
+
+ private void onWalletRescan() {
+ try {
+ final WalletFragment walletFragment = getWalletFragment();
+ getWallet().rescanBlockchainAsync();
+ synced = false;
+ walletFragment.unsync();
+ invalidateOptionsMenu();
+ } catch (ClassCastException ex) {
+ Timber.d(ex.getLocalizedMessage());
+ // keep calm and carry on
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ Timber.d("onStop()");
+ super.onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ Timber.d("onDestroy()");
+ if ((mBoundService != null) && (getWallet() != null)) {
+ saveWallet();
+ }
+ stopWalletService();
+ if (drawer != null) drawer.removeDrawerListener(drawerToggle);
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean hasWallet() {
+ return haveWallet;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ MenuItem renameItem = menu.findItem(R.id.action_rename);
+ if (renameItem != null)
+ renameItem.setEnabled(hasWallet() && getWallet().isSynchronized());
+ MenuItem streetmodeItem = menu.findItem(R.id.action_streetmode);
+ if (streetmodeItem != null)
+ if (isStreetMode()) {
+ streetmodeItem.setIcon(R.drawable.gunther_csi_24dp);
+ } else {
+ streetmodeItem.setIcon(R.drawable.gunther_24dp);
+ }
+ final MenuItem rescanItem = menu.findItem(R.id.action_rescan);
+ if (rescanItem != null)
+ rescanItem.setEnabled(isSynced());
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.action_rescan) {
+ onWalletRescan();
+ } else if (itemId == R.id.action_info) {
+ onWalletDetails();
+ } else if (itemId == R.id.action_credits) {
+ CreditsFragment.display(getSupportFragmentManager());
+ } else if (itemId == R.id.action_share) {
+ onShareTxInfo();
+ } else if (itemId == R.id.action_help_tx_info) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_tx_details);
+ } else if (itemId == R.id.action_help_wallet) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_wallet);
+ } else if (itemId == R.id.action_details_help) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_details);
+ } else if (itemId == R.id.action_details_changepw) {
+ onWalletChangePassword();
+ } else if (itemId == R.id.action_help_send) {
+ HelpFragment.display(getSupportFragmentManager(), R.string.help_send);
+ } else if (itemId == R.id.action_rename) {
+ onAccountRename();
+ } else if (itemId == R.id.action_subaddresses) {
+ showSubaddresses(true);
+ } else if (itemId == R.id.action_streetmode) {
+ if (isStreetMode()) { // disable streetmode
+ onDisableStreetMode();
+ } else {
+ onEnableStreetMode();
+ }
+ } else
+ return super.onOptionsItemSelected(item);
+ return true;
+ }
+
+ private void updateStreetMode() {
+ invalidateOptionsMenu();
+ }
+
+ private void onEnableStreetMode() {
+ enableStreetMode(true);
+ updateStreetMode();
+ }
+
+ private void onDisableStreetMode() {
+ Helper.promptPassword(WalletActivity.this, getWallet().getName(), false, new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName, String password, boolean fingerprintUsed) {
+ runOnUiThread(() -> {
+ enableStreetMode(false);
+ updateStreetMode();
+ });
+ }
+
+ @Override
+ public void fail(String walletName) {
+ }
+ });
+ }
+
+
+ public void onWalletChangePassword() {
+ try {
+ GenerateReviewFragment detailsFragment = (GenerateReviewFragment) getCurrentFragment();
+ AlertDialog dialog = detailsFragment.createChangePasswordDialog();
+ if (dialog != null) {
+ Helper.showKeyboard(dialog);
+ dialog.show();
+ }
+ } catch (ClassCastException ex) {
+ Timber.w("onWalletChangePassword() called, but no GenerateReviewFragment active");
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Timber.d("onCreate()");
+ ThemeHelper.setPreferred(this);
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ // activity restarted
+ // we don't want that - finish it and fall back to previous activity
+ finish();
+ return;
+ }
+
+ setContentView(R.layout.activity_wallet);
+ toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ getSupportActionBar().setDisplayShowTitleEnabled(false);
+
+ toolbar.setOnButtonListener(new Toolbar.OnButtonListener() {
+ @Override
+ public void onButton(int type) {
+ switch (type) {
+ case Toolbar.BUTTON_BACK:
+ onDisposeRequest();
+ onBackPressed();
+ break;
+ case Toolbar.BUTTON_CANCEL:
+ onDisposeRequest();
+ Helper.hideKeyboard(WalletActivity.this);
+ WalletActivity.super.onBackPressed();
+ break;
+ case Toolbar.BUTTON_CLOSE:
+ finish();
+ break;
+ case Toolbar.BUTTON_SETTINGS:
+ Toast.makeText(WalletActivity.this, getString(R.string.label_credits), Toast.LENGTH_SHORT).show();
+ case Toolbar.BUTTON_NONE:
+ default:
+ Timber.e("Button " + type + "pressed - how can this be?");
+ }
+ }
+ });
+
+ drawer = findViewById(R.id.drawer_layout);
+ drawerToggle = new ActionBarDrawerToggle(this, drawer, toolbar, 0, 0);
+ drawer.addDrawerListener(drawerToggle);
+ drawerToggle.syncState();
+ setDrawerEnabled(false); // disable until synced
+
+ accountsView = findViewById(R.id.accounts_nav);
+ accountsView.setNavigationItemSelectedListener(this);
+
+ showNet();
+
+ Fragment walletFragment = new WalletFragment();
+ getSupportFragmentManager().beginTransaction()
+ .add(R.id.fragment_container, walletFragment, WalletFragment.class.getName()).commit();
+ Timber.d("fragment added");
+
+ startWalletService();
+ Timber.d("onCreate() done.");
+ }
+
+ public void showNet() {
+ switch (WalletManager.getInstance().getNetworkType()) {
+ case NetworkType_Mainnet:
+ toolbar.setBackgroundResource(R.drawable.backgound_toolbar_mainnet);
+ break;
+ case NetworkType_Stagenet:
+ case NetworkType_Testnet:
+ toolbar.setBackgroundResource(ThemeHelper.getThemedResourceId(this, R.attr.colorPrimaryDark));
+ break;
+ default:
+ throw new IllegalStateException("Unsupported Network: " + WalletManager.getInstance().getNetworkType());
+ }
+ }
+
+ @Override
+ public Wallet getWallet() {
+ if (mBoundService == null) throw new IllegalStateException("WalletService not bound.");
+ return mBoundService.getWallet();
+ }
+
+ private WalletService mBoundService = null;
+ private boolean mIsBound = false;
+
+ private final ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ // This is called when the connection with the service has been
+ // established, giving us the service object we can use to
+ // interact with the service. Because we have bound to a explicit
+ // service that we know is running in our own process, we can
+ // cast its IBinder to a concrete class and directly access it.
+ mBoundService = ((WalletService.WalletServiceBinder) service).getService();
+ mBoundService.setObserver(WalletActivity.this);
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ String walletId = extras.getString(REQUEST_ID);
+ if (walletId != null) {
+ setTitle(walletId, getString(R.string.status_wallet_connecting));
+ }
+ }
+ updateProgress();
+ Timber.d("CONNECTED");
+ }
+
+ public void onServiceDisconnected(ComponentName className) {
+ // This is called when the connection with the service has been
+ // unexpectedly disconnected -- that is, its process crashed.
+ // Because it is running in our same process, we should never
+ // see this happen.
+ mBoundService = null;
+ setTitle(getString(R.string.wallet_activity_name), getString(R.string.status_wallet_disconnected));
+ Timber.d("DISCONNECTED");
+ }
+ };
+
+ void connectWalletService(String walletName, String walletPassword) {
+ // Establish a connection with the service. We use an explicit
+ // class name because we want a specific service implementation that
+ // we know will be running in our own process (and thus won't be
+ // supporting component replacement by other applications).
+ Intent intent = new Intent(getApplicationContext(), WalletService.class);
+ intent.putExtra(WalletService.REQUEST_WALLET, walletName);
+ intent.putExtra(WalletService.REQUEST, WalletService.REQUEST_CMD_LOAD);
+ intent.putExtra(WalletService.REQUEST_CMD_LOAD_PW, walletPassword);
+ startService(intent);
+ bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
+ mIsBound = true;
+ Timber.d("BOUND");
+ }
+
+ void disconnectWalletService() {
+ if (mIsBound) {
+ // Detach our existing connection.
+ mBoundService.setObserver(null);
+ unbindService(mConnection);
+ mIsBound = false;
+ Timber.d("UNBOUND");
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ Timber.d("onPause()");
+ super.onPause();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ }
+
+ @Override
+ public void saveWallet() {
+ if (mIsBound) { // no point in talking to unbound service
+ Intent intent = new Intent(getApplicationContext(), WalletService.class);
+ intent.putExtra(WalletService.REQUEST, WalletService.REQUEST_CMD_STORE);
+ startService(intent);
+ Timber.d("STORE request sent");
+ } else {
+ Timber.e("Service not bound");
+ }
+ }
+
+//////////////////////////////////////////
+// WalletFragment.Listener
+//////////////////////////////////////////
+
+ @Override
+ public boolean hasBoundService() {
+ return mBoundService != null;
+ }
+
+ @Override
+ public Wallet.ConnectionStatus getConnectionStatus() {
+ return mBoundService.getConnectionStatus();
+ }
+
+ @Override
+ public long getDaemonHeight() {
+ return mBoundService.getDaemonHeight();
+ }
+
+ @Override
+ public void onSendRequest(View view) {
+ replaceFragment(SendFragment.newInstance(uri), null, null);
+ uri = null; // only use uri once
+ }
+
+ @Override
+ public void onTxDetailsRequest(View view, TransactionInfo info) {
+ Bundle args = new Bundle();
+ args.putParcelable(TxFragment.ARG_INFO, info);
+ replaceFragmentWithTransition(view, new TxFragment(), null, args);
+ }
+
+ @Override
+ public void forceUpdate() {
+ try {
+ onRefreshed(getWallet(), true);
+ } catch (IllegalStateException ex) {
+ Timber.e(ex.getLocalizedMessage());
+ }
+ }
+
+///////////////////////////
+// WalletService.Observer
+///////////////////////////
+
+ private int numAccounts = -1;
+
+ // refresh and return true if successful
+ @Override
+ public boolean onRefreshed(final Wallet wallet, final boolean full) {
+ Timber.d("onRefreshed()");
+ runOnUiThread(() -> {
+ if (getWallet() != null)
+ updateAccountsBalance();
+ });
+ if (numAccounts != wallet.getNumAccounts()) {
+ numAccounts = wallet.getNumAccounts();
+ runOnUiThread(this::updateAccountsList);
+ }
+ try {
+ final WalletFragment walletFragment = getWalletFragment();
+ if (wallet.isSynchronized()) {
+ releaseWakeLock(RELEASE_WAKE_LOCK_DELAY); // the idea is to stay awake until synced
+ if (!synced) { // first sync
+ onProgress(-1);
+ saveWallet(); // save on first sync
+ synced = true;
+ runOnUiThread(walletFragment::onSynced);
+ }
+ }
+ runOnUiThread(() -> {
+ walletFragment.onRefreshed(wallet, full);
+ updateCurrentFragment(wallet);
+ });
+ return true;
+ } catch (ClassCastException ex) {
+ // not in wallet fragment (probably send monero)
+ Timber.d(ex.getLocalizedMessage());
+ // keep calm and carry on
+ }
+ return false;
+ }
+
+ private void updateCurrentFragment(final Wallet wallet) {
+ final Fragment fragment = getCurrentFragment();
+ if (fragment instanceof OnBlockUpdateListener) {
+ ((OnBlockUpdateListener) fragment).onBlockUpdate(wallet);
+ }
+ }
+
+ @Override
+ public void onWalletStored(final boolean success) {
+ runOnUiThread(() -> {
+ if (!success) {
+ Toast.makeText(WalletActivity.this, getString(R.string.status_wallet_unload_failed), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ boolean haveWallet = false;
+
+ @Override
+ public void onWalletOpen(final Wallet.Device device) {
+ if (device == Wallet.Device.Device_Ledger) {
+ runOnUiThread(() -> showLedgerProgressDialog(LedgerProgressDialog.TYPE_RESTORE));
+ }
+ }
+
+ @Override
+ public void onWalletStarted(final Wallet.Status walletStatus) {
+ runOnUiThread(() -> {
+ dismissProgressDialog();
+ if (walletStatus == null) {
+ // guess what went wrong
+ Toast.makeText(WalletActivity.this, getString(R.string.status_wallet_connect_failed), Toast.LENGTH_LONG).show();
+ } else {
+ if (Wallet.ConnectionStatus.ConnectionStatus_WrongVersion == walletStatus.getConnectionStatus())
+ Toast.makeText(WalletActivity.this, getString(R.string.status_wallet_connect_wrongversion), Toast.LENGTH_LONG).show();
+ else if (!walletStatus.isOk())
+ Toast.makeText(WalletActivity.this, walletStatus.getErrorString(), Toast.LENGTH_LONG).show();
+ }
+ });
+ if ((walletStatus == null) || (Wallet.ConnectionStatus.ConnectionStatus_Connected != walletStatus.getConnectionStatus())) {
+ finish();
+ } else {
+ haveWallet = true;
+ invalidateOptionsMenu();
+
+ if (requestStreetMode) onEnableStreetMode();
+
+ final WalletFragment walletFragment = getWalletFragment();
+ runOnUiThread(() -> {
+ updateAccountsHeader();
+ if (walletFragment != null) {
+ walletFragment.onLoaded();
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onTransactionCreated(final String txTag, final PendingTransaction pendingTransaction) {
+ try {
+ final SendFragment sendFragment = (SendFragment) getCurrentFragment();
+ runOnUiThread(() -> {
+ dismissProgressDialog();
+ PendingTransaction.Status status = pendingTransaction.getStatus();
+ if (status != PendingTransaction.Status.Status_Ok) {
+ String errorText = pendingTransaction.getErrorString();
+ getWallet().disposePendingTransaction();
+ sendFragment.onCreateTransactionFailed(errorText);
+ } else {
+ sendFragment.onTransactionCreated(txTag, pendingTransaction);
+ }
+ });
+ } catch (ClassCastException ex) {
+ // not in spend fragment
+ Timber.d(ex.getLocalizedMessage());
+ // don't need the transaction any more
+ getWallet().disposePendingTransaction();
+ }
+ }
+
+ @Override
+ public void onSendTransactionFailed(final String error) {
+ try {
+ final SendFragment sendFragment = (SendFragment) getCurrentFragment();
+ runOnUiThread(() -> sendFragment.onSendTransactionFailed(error));
+ } catch (ClassCastException ex) {
+ // not in spend fragment
+ Timber.d(ex.getLocalizedMessage());
+ }
+ }
+
+ @Override
+ public void onTransactionSent(final String txId) {
+ try {
+ final SendFragment sendFragment = (SendFragment) getCurrentFragment();
+ runOnUiThread(() -> sendFragment.onTransactionSent(txId));
+ } catch (ClassCastException ex) {
+ // not in spend fragment
+ Timber.d(ex.getLocalizedMessage());
+ }
+ }
+
+ @Override
+ public void onProgress(final String text) {
+ try {
+ final WalletFragment walletFragment = getWalletFragment();
+ runOnUiThread(new Runnable() {
+ public void run() {
+ walletFragment.setProgress(text);
+ }
+ });
+ } catch (ClassCastException ex) {
+ // not in wallet fragment (probably send monero)
+ Timber.d(ex.getLocalizedMessage());
+ // keep calm and carry on
+ }
+ }
+
+ @Override
+ public void onProgress(final int n) {
+ runOnUiThread(() -> {
+ try {
+ WalletFragment walletFragment = getWalletFragment();
+ if (walletFragment != null)
+ walletFragment.setProgress(n);
+ } catch (ClassCastException ex) {
+ // not in wallet fragment (probably send monero)
+ Timber.d(ex.getLocalizedMessage());
+ // keep calm and carry on
+ }
+ });
+ }
+
+ private void updateProgress() {
+ // TODO maybe show real state of WalletService (like "still closing previous wallet")
+ if (hasBoundService()) {
+ onProgress(mBoundService.getProgressText());
+ onProgress(mBoundService.getProgressValue());
+ }
+ }
+
+///////////////////////////
+// SendFragment.Listener
+///////////////////////////
+
+ @Override
+ public void onSend(UserNotes notes) {
+ if (mIsBound) { // no point in talking to unbound service
+ Intent intent = new Intent(getApplicationContext(), WalletService.class);
+ intent.putExtra(WalletService.REQUEST, WalletService.REQUEST_CMD_SEND);
+ intent.putExtra(WalletService.REQUEST_CMD_SEND_NOTES, notes.txNotes);
+ startService(intent);
+ Timber.d("SEND TX request sent");
+ } else {
+ Timber.e("Service not bound");
+ }
+
+ }
+
+ @Override
+ public void onPrepareSend(final String tag, final TxData txData) {
+ if (mIsBound) { // no point in talking to unbound service
+ Intent intent = new Intent(getApplicationContext(), WalletService.class);
+ intent.putExtra(WalletService.REQUEST, WalletService.REQUEST_CMD_TX);
+ intent.putExtra(WalletService.REQUEST_CMD_TX_DATA, txData);
+ intent.putExtra(WalletService.REQUEST_CMD_TX_TAG, tag);
+ startService(intent);
+ Timber.d("CREATE TX request sent");
+ if (getWallet().getDeviceType() == Wallet.Device.Device_Ledger)
+ showLedgerProgressDialog(LedgerProgressDialog.TYPE_SEND);
+ } else {
+ Timber.e("Service not bound");
+ }
+ }
+
+ @Override
+ public Subaddress getWalletSubaddress(int accountIndex, int subaddressIndex) {
+ return getWallet().getSubaddressObject(accountIndex, subaddressIndex);
+ }
+
+ public String getWalletName() {
+ return getWallet().getName();
+ }
+
+ void popFragmentStack(String name) {
+ if (name == null) {
+ getSupportFragmentManager().popBackStack();
+ } else {
+ getSupportFragmentManager().popBackStack(name, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ }
+ }
+
+ void replaceFragmentWithTransition(View view, Fragment newFragment, String stackName, Bundle extras) {
+ if (extras != null) {
+ newFragment.setArguments(extras);
+ }
+ int transition;
+ if (newFragment instanceof TxFragment)
+ transition = R.string.tx_details_transition_name;
+ else if (newFragment instanceof SubaddressInfoFragment)
+ transition = R.string.subaddress_info_transition_name;
+ else
+ throw new IllegalStateException("expecting known transition");
+
+ getSupportFragmentManager().beginTransaction()
+ .addSharedElement(view, getString(transition))
+ .replace(R.id.fragment_container, newFragment)
+ .addToBackStack(stackName)
+ .commit();
+ }
+
+ void replaceFragment(Fragment newFragment, String stackName, Bundle extras) {
+ if (extras != null) {
+ newFragment.setArguments(extras);
+ }
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, newFragment)
+ .addToBackStack(stackName)
+ .commit();
+ }
+
+ private void onWalletDetails() {
+ DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ switch (which) {
+ case DialogInterface.BUTTON_POSITIVE:
+ final Bundle extras = new Bundle();
+ extras.putString(GenerateReviewFragment.REQUEST_TYPE, GenerateReviewFragment.VIEW_TYPE_WALLET);
+
+ Helper.promptPassword(WalletActivity.this, getWallet().getName(), true, new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName, String password, boolean fingerprintUsed) {
+ replaceFragment(new GenerateReviewFragment(), null, extras);
+ }
+
+ @Override
+ public void fail(String walletName) {
+ }
+ });
+
+ break;
+ case DialogInterface.BUTTON_NEGATIVE:
+ // do nothing
+ break;
+ }
+ }
+ };
+
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
+ builder.setMessage(getString(R.string.details_alert_message))
+ .setPositiveButton(getString(R.string.details_alert_yes), dialogClickListener)
+ .setNegativeButton(getString(R.string.details_alert_no), dialogClickListener)
+ .show();
+ }
+
+ void onShareTxInfo() {
+ try {
+ TxFragment fragment = (TxFragment) getCurrentFragment();
+ fragment.shareTxInfo();
+ } catch (ClassCastException ex) {
+ // not in wallet fragment
+ Timber.e(ex.getLocalizedMessage());
+ // keep calm and carry on
+ }
+ }
+
+ @Override
+ public void onDisposeRequest() {
+ //TODO consider doing this through the WalletService to avoid concurrency issues
+ getWallet().disposePendingTransaction();
+ }
+
+ private boolean startScanFragment = false;
+
+ @Override
+ protected void onResumeFragments() {
+ super.onResumeFragments();
+ if (startScanFragment) {
+ startScanFragment();
+ startScanFragment = false;
+ }
+ }
+
+ private void startScanFragment() {
+ Bundle extras = new Bundle();
+ replaceFragment(new ScannerFragment(), null, extras);
+ }
+
+ /// QR scanner callbacks
+ @Override
+ public void onScan() {
+ if (Helper.getCameraPermission(this)) {
+ startScanFragment();
+ } else {
+ Timber.i("Waiting for permissions");
+ }
+ }
+
+ @Override
+ public boolean onScanned(String qrCode) {
+ // #gurke
+ BarcodeData bcData = BarcodeData.fromString(qrCode);
+ if (bcData != null) {
+ popFragmentStack(null);
+ Timber.d("AAA");
+ onUriScanned(bcData);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ OnUriScannedListener onUriScannedListener = null;
+
+ @Override
+ public void setOnUriScannedListener(OnUriScannedListener onUriScannedListener) {
+ this.onUriScannedListener = onUriScannedListener;
+ }
+
+ @Override
+ void onUriScanned(BarcodeData barcodeData) {
+ super.onUriScanned(barcodeData);
+ boolean processed = false;
+ if (onUriScannedListener != null) {
+ processed = onUriScannedListener.onUriScanned(barcodeData);
+ }
+ if (!processed || (onUriScannedListener == null)) {
+ Toast.makeText(this, getString(R.string.nfc_tag_read_what), Toast.LENGTH_LONG).show();
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Timber.d("onRequestPermissionsResult()");
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == Helper.PERMISSIONS_REQUEST_CAMERA) { // If request is cancelled, the result arrays are empty.
+ if (grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ startScanFragment = true;
+ } else {
+ String msg = getString(R.string.message_camera_not_permitted);
+ Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onWalletReceive(View view) {
+ final String address = getWallet().getAddress();
+ Timber.d("startReceive()");
+ Bundle b = new Bundle();
+ b.putString("address", address);
+ b.putString("name", getWalletName());
+ replaceFragment(new ReceiveFragment(), null, b);
+ Timber.d("ReceiveFragment placed");
+ }
+
+ @Override
+ public long getTotalFunds() {
+ return getWallet().getUnlockedBalance();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (drawer.isDrawerOpen(GravityCompat.START)) {
+ drawer.closeDrawer(GravityCompat.START);
+ return;
+ }
+ final Fragment fragment = getCurrentFragment();
+ if (fragment instanceof OnBackPressedListener) {
+ if (!((OnBackPressedListener) fragment).onBackPressed()) {
+ super.onBackPressed();
+ }
+ } else {
+ super.onBackPressed();
+ }
+ Helper.hideKeyboard(this);
+ }
+
+ @Override
+ public void onFragmentDone() {
+ popFragmentStack(null);
+ }
+
+ @Override
+ public SharedPreferences getPrefs() {
+ return getPreferences(Context.MODE_PRIVATE);
+ }
+
+ private final List accountIds = new ArrayList<>();
+
+ // generate and cache unique ids for use in accounts list
+ private int getAccountId(int accountIndex) {
+ final int n = accountIds.size();
+ for (int i = n; i <= accountIndex; i++) {
+ accountIds.add(View.generateViewId());
+ }
+ return accountIds.get(accountIndex);
+ }
+
+ // drawer stuff
+
+ void updateAccountsBalance() {
+ final TextView tvBalance = accountsView.getHeaderView(0).findViewById(R.id.tvBalance);
+ if (!isStreetMode()) {
+ tvBalance.setText(getString(R.string.accounts_balance,
+ Helper.getDisplayAmount(getWallet().getBalanceAll(), 5)));
+ } else {
+ tvBalance.setText(null);
+ }
+ updateAccountsList();
+ }
+
+ void updateAccountsHeader() {
+ final Wallet wallet = getWallet();
+ final TextView tvName = accountsView.getHeaderView(0).findViewById(R.id.tvName);
+ tvName.setText(wallet.getName());
+ }
+
+ void updateAccountsList() {
+ Menu menu = accountsView.getMenu();
+ menu.removeGroup(R.id.accounts_list);
+ final Wallet wallet = getWallet();
+ if (wallet != null) {
+ final int n = wallet.getNumAccounts();
+ final boolean showBalances = (n > 1) && !isStreetMode();
+ for (int i = 0; i < n; i++) {
+ final String label = (showBalances ?
+ getString(R.string.label_account, wallet.getAccountLabel(i), Helper.getDisplayAmount(wallet.getBalance(i), 2))
+ : wallet.getAccountLabel(i));
+ final MenuItem item = menu.add(R.id.accounts_list, getAccountId(i), 2 * i, label);
+ item.setIcon(R.drawable.ic_account_balance_wallet_black_24dp);
+ if (i == wallet.getAccountIndex())
+ item.setChecked(true);
+ }
+ menu.setGroupCheckable(R.id.accounts_list, true, true);
+ }
+ }
+
+ @Override
+ public void setDrawerEnabled(boolean enabled) {
+ Timber.d("setDrawerEnabled %b", enabled);
+ final int lockMode = enabled ? DrawerLayout.LOCK_MODE_UNLOCKED :
+ DrawerLayout.LOCK_MODE_LOCKED_CLOSED;
+ drawer.setDrawerLockMode(lockMode);
+ drawerToggle.setDrawerIndicatorEnabled(enabled);
+ invalidateOptionsMenu(); // menu may need to be changed
+ }
+
+ void updateAccountName() {
+ setSubtitle(getWallet().getAccountLabel());
+ updateAccountsList();
+ }
+
+ public void onAccountRename() {
+ final LayoutInflater li = LayoutInflater.from(this);
+ final View promptsView = li.inflate(R.layout.prompt_rename, null);
+
+ final AlertDialog.Builder alertDialogBuilder = new MaterialAlertDialogBuilder(this);
+ alertDialogBuilder.setView(promptsView);
+
+ final EditText etRename = promptsView.findViewById(R.id.etRename);
+ final TextView tvRenameLabel = promptsView.findViewById(R.id.tvRenameLabel);
+ final Wallet wallet = getWallet();
+ tvRenameLabel.setText(getString(R.string.prompt_rename, wallet.getAccountLabel()));
+
+ // set dialog message
+ alertDialogBuilder
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.label_ok),
+ (dialog, id) -> {
+ Helper.hideKeyboardAlways(WalletActivity.this);
+ String newName = etRename.getText().toString();
+ wallet.setAccountLabel(newName);
+ updateAccountName();
+ })
+ .setNegativeButton(getString(R.string.label_cancel),
+ (dialog, id) -> {
+ Helper.hideKeyboardAlways(WalletActivity.this);
+ dialog.cancel();
+ });
+
+ final AlertDialog dialog = alertDialogBuilder.create();
+ Helper.showKeyboard(dialog);
+
+ // accept keyboard "ok"
+ etRename.setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ Helper.hideKeyboardAlways(WalletActivity.this);
+ String newName = etRename.getText().toString();
+ dialog.cancel();
+ wallet.setAccountLabel(newName);
+ updateAccountName();
+ return false;
+ }
+ return false;
+ });
+
+ dialog.show();
+ }
+
+ public void setAccountIndex(int accountIndex) {
+ getWallet().setAccountIndex(accountIndex);
+ selectedSubaddressIndex = 0;
+ }
+
+ @Override
+ public boolean onNavigationItemSelected(MenuItem item) {
+ final int id = item.getItemId();
+ if (id == R.id.account_new) {
+ addAccount();
+ } else {
+ Timber.d("NavigationDrawer ID=%d", id);
+ int accountIdx = accountIds.indexOf(id);
+ if (accountIdx >= 0) {
+ Timber.d("found @%d", accountIdx);
+ setAccountIndex(accountIdx);
+ }
+ forceUpdate();
+ drawer.closeDrawer(GravityCompat.START);
+ }
+ return true;
+ }
+
+ private int lastUsedAccount() {
+ int lastUsedAccount = 0;
+ for (TransactionInfo info : getWallet().getHistory().getAll()) {
+ if (info.accountIndex > lastUsedAccount)
+ lastUsedAccount = info.accountIndex;
+ }
+ return lastUsedAccount;
+ }
+
+ private void addAccount() {
+ final Wallet wallet = getWallet();
+ final int maxAccounts = lastUsedAccount() + wallet.getDeviceType().getAccountLookahead();
+ if (wallet.getNumAccounts() < maxAccounts)
+ new AsyncAddAccount().executeOnExecutor(MoneroThreadPoolExecutor.MONERO_THREAD_POOL_EXECUTOR);
+ else
+ Toast.makeText(this, getString(R.string.max_account_warning), Toast.LENGTH_LONG).show();
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private class AsyncAddAccount extends AsyncTask {
+ boolean dialogOpened = false;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ switch (getWallet().getDeviceType()) {
+ case Device_Ledger:
+ showLedgerProgressDialog(LedgerProgressDialog.TYPE_ACCOUNT);
+ dialogOpened = true;
+ break;
+ case Device_Software:
+ showProgressDialog(R.string.accounts_progress_new);
+ dialogOpened = true;
+ break;
+ default:
+ throw new IllegalStateException("Hardware backing not supported. At all!");
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ if (params.length != 0) return false;
+ getWallet().addAccount();
+ setAccountIndex(getWallet().getNumAccounts() - 1);
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ forceUpdate();
+ drawer.closeDrawer(GravityCompat.START);
+ if (dialogOpened)
+ dismissProgressDialog();
+ Toast.makeText(WalletActivity.this,
+ getString(R.string.accounts_new, getWallet().getNumAccounts() - 1),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ // we store the index only and always retrieve a new Subaddress object
+ // to ensure we get the current label
+ private int selectedSubaddressIndex = 0;
+
+ @Override
+ public Subaddress getSelectedSubaddress() {
+ return getWallet().getSubaddressObject(selectedSubaddressIndex);
+ }
+
+ @Override
+ public void onSubaddressSelected(@Nullable final Subaddress subaddress) {
+ selectedSubaddressIndex = subaddress.getAddressIndex();
+ onBackPressed();
+ }
+
+ @Override
+ public void showSubaddresses(boolean managerMode) {
+ final Bundle b = new Bundle();
+ if (managerMode)
+ b.putString(SubaddressFragment.KEY_MODE, SubaddressFragment.MODE_MANAGER);
+ replaceFragment(new SubaddressFragment(), null, b);
+ }
+
+ @Override
+ public void showSubaddress(View view, final int subaddressIndex) {
+ final Bundle b = new Bundle();
+ b.putInt("subaddressIndex", subaddressIndex);
+ replaceFragmentWithTransition(view, new SubaddressInfoFragment(), null, b);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java
new file mode 100644
index 0000000..515d2d8
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java
@@ -0,0 +1,559 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.github.brnunes.swipeablerecyclerview.SwipeableRecyclerViewTouchListener;
+import com.m2049r.xmrwallet.layout.TransactionInfoAdapter;
+import com.m2049r.xmrwallet.model.TransactionInfo;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi;
+import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback;
+import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ServiceHelper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import timber.log.Timber;
+
+public class WalletFragment extends Fragment
+ implements TransactionInfoAdapter.OnInteractionListener {
+ private TransactionInfoAdapter adapter;
+ private final NumberFormat formatter = NumberFormat.getInstance();
+
+ private TextView tvStreetView;
+ private LinearLayout llBalance;
+ private FrameLayout flExchange;
+ private TextView tvBalance;
+ private TextView tvUnconfirmedAmount;
+ private TextView tvProgress;
+ private ImageView ivSynced;
+ private ProgressBar pbProgress;
+ private Button bReceive;
+ private Button bSend;
+ private ImageView ivStreetGunther;
+ private Drawable streetGunther = null;
+ RecyclerView txlist;
+
+ private Spinner sCurrency;
+
+ private final List dismissedTransactions = new ArrayList<>();
+
+ public void resetDismissedTransactions() {
+ dismissedTransactions.clear();
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+ if (activityCallback.hasWallet())
+ inflater.inflate(R.menu.wallet_menu, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_wallet, container, false);
+
+ ivStreetGunther = view.findViewById(R.id.ivStreetGunther);
+ tvStreetView = view.findViewById(R.id.tvStreetView);
+ llBalance = view.findViewById(R.id.llBalance);
+ flExchange = view.findViewById(R.id.flExchange);
+ ((ProgressBar) view.findViewById(R.id.pbExchange)).getIndeterminateDrawable().
+ setColorFilter(
+ ThemeHelper.getThemedColor(getContext(), R.attr.colorPrimaryVariant),
+ android.graphics.PorterDuff.Mode.MULTIPLY);
+
+ tvProgress = view.findViewById(R.id.tvProgress);
+ pbProgress = view.findViewById(R.id.pbProgress);
+ tvBalance = view.findViewById(R.id.tvBalance);
+ showBalance(Helper.getDisplayAmount(0));
+ tvUnconfirmedAmount = view.findViewById(R.id.tvUnconfirmedAmount);
+ showUnconfirmed(0);
+ ivSynced = view.findViewById(R.id.ivSynced);
+
+ sCurrency = view.findViewById(R.id.sCurrency);
+ List currencies = new ArrayList<>();
+ currencies.add(Helper.BASE_CRYPTO);
+ if (Helper.SHOW_EXCHANGERATES)
+ currencies.addAll(Arrays.asList(getResources().getStringArray(R.array.currency)));
+ ArrayAdapter spinnerAdapter = new ArrayAdapter<>(requireContext(), R.layout.item_spinner_balance, currencies);
+ spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ sCurrency.setAdapter(spinnerAdapter);
+
+ bSend = view.findViewById(R.id.bSend);
+ bReceive = view.findViewById(R.id.bReceive);
+
+ txlist = view.findViewById(R.id.list);
+ adapter = new TransactionInfoAdapter(getActivity(), this);
+ txlist.setAdapter(adapter);
+ adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onItemRangeInserted(int positionStart, int itemCount) {
+ if ((positionStart == 0) && (txlist.computeVerticalScrollOffset() == 0))
+ txlist.scrollToPosition(positionStart);
+ }
+ });
+
+ txlist.addOnItemTouchListener(
+ new SwipeableRecyclerViewTouchListener(txlist,
+ new SwipeableRecyclerViewTouchListener.SwipeListener() {
+ @Override
+ public boolean canSwipeLeft(int position) {
+ return activityCallback.isStreetMode();
+ }
+
+ @Override
+ public boolean canSwipeRight(int position) {
+ return activityCallback.isStreetMode();
+ }
+
+ @Override
+ public void onDismissedBySwipeLeft(RecyclerView recyclerView, int[] reverseSortedPositions) {
+ for (int position : reverseSortedPositions) {
+ dismissedTransactions.add(adapter.getItem(position).hash);
+ adapter.removeItem(position);
+ }
+ }
+
+ @Override
+ public void onDismissedBySwipeRight(RecyclerView recyclerView, int[] reverseSortedPositions) {
+ for (int position : reverseSortedPositions) {
+ dismissedTransactions.add(adapter.getItem(position).hash);
+ adapter.removeItem(position);
+ }
+ }
+ }));
+
+ bSend.setOnClickListener(v -> activityCallback.onSendRequest(v));
+ bReceive.setOnClickListener(v -> activityCallback.onWalletReceive(v));
+
+ sCurrency.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parentView, View selectedItemView, int position, long id) {
+ refreshBalance();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parentView) {
+ // nothing (yet?)
+ }
+ });
+
+ if (activityCallback.isSynced()) {
+ onSynced();
+ }
+
+ activityCallback.forceUpdate();
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ }
+
+ void showBalance(String balance) {
+ tvBalance.setText(balance);
+ final boolean streetMode = activityCallback.isStreetMode();
+ if (!streetMode) {
+ llBalance.setVisibility(View.VISIBLE);
+ tvStreetView.setVisibility(View.INVISIBLE);
+ } else {
+ llBalance.setVisibility(View.INVISIBLE);
+ tvStreetView.setVisibility(View.VISIBLE);
+ }
+ setStreetModeBackground(streetMode);
+ }
+
+ void showUnconfirmed(double unconfirmedAmount) {
+ if (activityCallback.isStreetMode() || unconfirmedAmount == 0) {
+ tvUnconfirmedAmount.setText(null);
+ tvUnconfirmedAmount.setVisibility(View.GONE);
+ } else {
+ String unconfirmed = Helper.getFormattedAmount(unconfirmedAmount, true);
+ tvUnconfirmedAmount.setText(getResources().getString(R.string.xmr_unconfirmed_amount, unconfirmed));
+ tvUnconfirmedAmount.setVisibility(View.VISIBLE);
+ }
+ }
+
+ void updateBalance() {
+ if (isExchanging) return; // wait for exchange to finish - it will fire this itself then.
+ // at this point selection is XMR in case of error
+ String displayB;
+ double amountA = Helper.getDecimalAmount(unlockedBalance).doubleValue();
+ if (!Helper.BASE_CRYPTO.equals(balanceCurrency)) { // not XMR
+ double amountB = amountA * balanceRate;
+ displayB = Helper.getFormattedAmount(amountB, false);
+ } else { // XMR
+ displayB = Helper.getFormattedAmount(amountA, true);
+ }
+ showBalance(displayB);
+ }
+
+ String balanceCurrency = Helper.BASE_CRYPTO;
+ double balanceRate = 1.0;
+
+ private final ExchangeApi exchangeApi = ServiceHelper.getExchangeApi();
+
+ void refreshBalance() {
+ double unconfirmedXmr = Helper.getDecimalAmount(balance - unlockedBalance).doubleValue();
+ showUnconfirmed(unconfirmedXmr);
+ if (sCurrency.getSelectedItemPosition() == 0) { // XMR
+ double amountXmr = Helper.getDecimalAmount(unlockedBalance).doubleValue();
+ showBalance(Helper.getFormattedAmount(amountXmr, true));
+ } else { // not XMR
+ String currency = (String) sCurrency.getSelectedItem();
+ Timber.d(currency);
+ if (!currency.equals(balanceCurrency) || (balanceRate <= 0)) {
+ showExchanging();
+ exchangeApi.queryExchangeRate(Helper.BASE_CRYPTO, currency,
+ new ExchangeCallback() {
+ @Override
+ public void onSuccess(final ExchangeRate exchangeRate) {
+ if (isAdded())
+ new Handler(Looper.getMainLooper()).post(() -> exchange(exchangeRate));
+ }
+
+ @Override
+ public void onError(final Exception e) {
+ Timber.e(e.getLocalizedMessage());
+ if (isAdded())
+ new Handler(Looper.getMainLooper()).post(() -> exchangeFailed());
+ }
+ });
+ } else {
+ updateBalance();
+ }
+ }
+ }
+
+ boolean isExchanging = false;
+
+ void showExchanging() {
+ isExchanging = true;
+ tvBalance.setVisibility(View.GONE);
+ flExchange.setVisibility(View.VISIBLE);
+ sCurrency.setEnabled(false);
+ }
+
+ void hideExchanging() {
+ isExchanging = false;
+ tvBalance.setVisibility(View.VISIBLE);
+ flExchange.setVisibility(View.GONE);
+ sCurrency.setEnabled(true);
+ }
+
+ public void exchangeFailed() {
+ sCurrency.setSelection(0, true); // default to XMR
+ double amountXmr = Helper.getDecimalAmount(unlockedBalance).doubleValue();
+ showBalance(Helper.getFormattedAmount(amountXmr, true));
+ hideExchanging();
+ }
+
+ public void exchange(final ExchangeRate exchangeRate) {
+ hideExchanging();
+ if (!Helper.BASE_CRYPTO.equals(exchangeRate.getBaseCurrency())) {
+ Timber.e("Not XMR");
+ sCurrency.setSelection(0, true);
+ balanceCurrency = Helper.BASE_CRYPTO;
+ balanceRate = 1.0;
+ } else {
+ int spinnerPosition = ((ArrayAdapter) sCurrency.getAdapter()).getPosition(exchangeRate.getQuoteCurrency());
+ if (spinnerPosition < 0) { // requested currency not in list
+ Timber.e("Requested currency not in list %s", exchangeRate.getQuoteCurrency());
+ sCurrency.setSelection(0, true);
+ } else {
+ sCurrency.setSelection(spinnerPosition, true);
+ }
+ balanceCurrency = exchangeRate.getQuoteCurrency();
+ balanceRate = exchangeRate.getRate();
+ }
+ updateBalance();
+ }
+
+ // Callbacks from TransactionInfoAdapter
+ @Override
+ public void onInteraction(final View view, final TransactionInfo infoItem) {
+ activityCallback.onTxDetailsRequest(view, infoItem);
+ }
+
+ // if account index has changed scroll to top?
+ private int accountIndex = 0;
+
+ public void onRefreshed(final Wallet wallet, boolean full) {
+ Timber.d("onRefreshed(%b)", full);
+
+ if (adapter.needsTransactionUpdateOnNewBlock()) {
+ wallet.refreshHistory();
+ full = true;
+ }
+ if (full) {
+ List list = new ArrayList<>();
+ final long streetHeight = activityCallback.getStreetModeHeight();
+ Timber.d("StreetHeight=%d", streetHeight);
+ wallet.refreshHistory();
+ for (TransactionInfo info : wallet.getHistory().getAll()) {
+ Timber.d("TxHeight=%d, Label=%s", info.blockheight, info.subaddressLabel);
+ if ((info.isPending || (info.blockheight >= streetHeight))
+ && !dismissedTransactions.contains(info.hash))
+ list.add(info);
+ }
+ adapter.setInfos(list);
+ if (accountIndex != wallet.getAccountIndex()) {
+ accountIndex = wallet.getAccountIndex();
+ txlist.scrollToPosition(0);
+ }
+ }
+ updateStatus(wallet);
+ }
+
+ public void onSynced() {
+ if (!activityCallback.isWatchOnly()) {
+ bSend.setVisibility(View.VISIBLE);
+ bSend.setEnabled(true);
+ }
+ if (isVisible()) enableAccountsList(true); //otherwise it is enabled in onResume()
+ }
+
+ public void unsync() {
+ if (!activityCallback.isWatchOnly()) {
+ bSend.setVisibility(View.INVISIBLE);
+ bSend.setEnabled(false);
+ }
+ if (isVisible()) enableAccountsList(false); //otherwise it is enabled in onResume()
+ firstBlock = 0;
+ }
+
+ boolean walletLoaded = false;
+
+ public void onLoaded() {
+ walletLoaded = true;
+ showReceive();
+ }
+
+ private void showReceive() {
+ if (walletLoaded) {
+ bReceive.setVisibility(View.VISIBLE);
+ bReceive.setEnabled(true);
+ }
+ }
+
+ private String syncText = null;
+
+ public void setProgress(final String text) {
+ syncText = text;
+ tvProgress.setText(text);
+ }
+
+ private int syncProgress = -1;
+
+ public void setProgress(final int n) {
+ syncProgress = n;
+ if (n > 100) {
+ pbProgress.setIndeterminate(true);
+ pbProgress.setVisibility(View.VISIBLE);
+ } else if (n >= 0) {
+ pbProgress.setIndeterminate(false);
+ pbProgress.setProgress(n);
+ pbProgress.setVisibility(View.VISIBLE);
+ } else { // <0
+ pbProgress.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ void setActivityTitle(Wallet wallet) {
+ if (wallet == null) return;
+ walletTitle = wallet.getName();
+ walletSubtitle = wallet.getAccountLabel();
+ activityCallback.setTitle(walletTitle, walletSubtitle);
+ Timber.d("wallet title is %s", walletTitle);
+ }
+
+ private long firstBlock = 0;
+ private String walletTitle = null;
+ private String walletSubtitle = null;
+ private long unlockedBalance = 0;
+ private long balance = 0;
+
+ private int accountIdx = -1;
+
+ private void updateStatus(Wallet wallet) {
+ if (!isAdded()) return;
+ Timber.d("updateStatus()");
+ if ((walletTitle == null) || (accountIdx != wallet.getAccountIndex())) {
+ accountIdx = wallet.getAccountIndex();
+ setActivityTitle(wallet);
+ }
+ balance = wallet.getBalance();
+ unlockedBalance = wallet.getUnlockedBalance();
+ refreshBalance();
+ String sync;
+ if (!activityCallback.hasBoundService())
+ throw new IllegalStateException("WalletService not bound.");
+ Wallet.ConnectionStatus daemonConnected = activityCallback.getConnectionStatus();
+ if (daemonConnected == Wallet.ConnectionStatus.ConnectionStatus_Connected) {
+ if (!wallet.isSynchronized()) {
+ long daemonHeight = activityCallback.getDaemonHeight();
+ long walletHeight = wallet.getBlockChainHeight();
+ long n = daemonHeight - walletHeight;
+ sync = getString(R.string.status_syncing) + " " + formatter.format(n) + " " + getString(R.string.status_remaining);
+ if (firstBlock == 0) {
+ firstBlock = walletHeight;
+ }
+ int x = 100 - Math.round(100f * n / (1f * daemonHeight - firstBlock));
+ if (x == 0) x = 101; // indeterminate
+ setProgress(x);
+ ivSynced.setVisibility(View.GONE);
+ } else {
+ sync = getString(R.string.status_synced) + " " + formatter.format(wallet.getBlockChainHeight());
+ ivSynced.setVisibility(View.VISIBLE);
+ }
+ } else {
+ sync = getString(R.string.status_wallet_connecting);
+ setProgress(101);
+ }
+ setProgress(sync);
+ // TODO show connected status somewhere
+ }
+
+ Listener activityCallback;
+
+ // Container Activity must implement this interface
+ public interface Listener {
+ boolean hasBoundService();
+
+ void forceUpdate();
+
+ Wallet.ConnectionStatus getConnectionStatus();
+
+ long getDaemonHeight(); //mBoundService.getDaemonHeight();
+
+ void onSendRequest(View view);
+
+ void onTxDetailsRequest(View view, TransactionInfo info);
+
+ boolean isSynced();
+
+ boolean isStreetMode();
+
+ long getStreetModeHeight();
+
+ boolean isWatchOnly();
+
+ String getTxKey(String txId);
+
+ void onWalletReceive(View view);
+
+ boolean hasWallet();
+
+ Wallet getWallet();
+
+ void setToolbarButton(int type);
+
+ void setTitle(String title, String subtitle);
+
+ void setSubtitle(String subtitle);
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ this.activityCallback = (Listener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume()");
+ activityCallback.setTitle(walletTitle, walletSubtitle);
+ activityCallback.setToolbarButton(Toolbar.BUTTON_NONE);
+ setProgress(syncProgress);
+ setProgress(syncText);
+ showReceive();
+ if (activityCallback.isSynced()) enableAccountsList(true);
+ }
+
+ @Override
+ public void onPause() {
+ enableAccountsList(false);
+ super.onPause();
+ }
+
+ public interface DrawerLocker {
+ void setDrawerEnabled(boolean enabled);
+ }
+
+ private void enableAccountsList(boolean enable) {
+ if (activityCallback instanceof DrawerLocker) {
+ ((DrawerLocker) activityCallback).setDrawerEnabled(enable);
+ }
+ }
+
+ public void setStreetModeBackground(boolean enable) {
+ //TODO figure out why gunther disappears on return from send although he is still set
+ if (enable) {
+ if (streetGunther == null)
+ streetGunther = ContextCompat.getDrawable(requireContext(), R.drawable.ic_gunther_streetmode);
+ ivStreetGunther.setImageDrawable(streetGunther);
+ } else
+ ivStreetGunther.setImageDrawable(null);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/XmrWalletApplication.java b/app/src/main/java/com/m2049r/xmrwallet/XmrWalletApplication.java
new file mode 100644
index 0000000..3762fb1
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/XmrWalletApplication.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2017 m2049r et al.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.FragmentManager;
+
+import com.m2049r.xmrwallet.model.NetworkType;
+import com.m2049r.xmrwallet.util.LocaleHelper;
+import com.m2049r.xmrwallet.util.NetCipherHelper;
+import com.m2049r.xmrwallet.util.NightmodeHelper;
+
+import timber.log.Timber;
+
+public class XmrWalletApplication extends Application {
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ FragmentManager.enableNewStateManager(false);
+ if (BuildConfig.DEBUG) {
+ Timber.plant(new Timber.DebugTree());
+ }
+
+ NightmodeHelper.setPreferredNightmode(this);
+
+ NetCipherHelper.createInstance(this);
+ }
+
+ @Override
+ protected void attachBaseContext(Context context) {
+ super.attachBaseContext(LocaleHelper.setPreferredLocale(context));
+ }
+
+ @Override
+ public void onConfigurationChanged(@NonNull Configuration configuration) {
+ super.onConfigurationChanged(configuration);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ LocaleHelper.updateSystemDefaultLocale(configuration.getLocales().get(0));
+ } else {
+ LocaleHelper.updateSystemDefaultLocale(configuration.locale);
+ }
+ LocaleHelper.setPreferredLocale(this);
+ }
+
+ static public NetworkType getNetworkType() {
+ switch (BuildConfig.FLAVOR_net) {
+ case "mainnet":
+ return NetworkType.NetworkType_Mainnet;
+ case "stagenet":
+ return NetworkType.NetworkType_Stagenet;
+ case "devnet": // flavors cannot start with "test"
+ return NetworkType.NetworkType_Testnet;
+ default:
+ throw new IllegalStateException("unknown net flavor " + BuildConfig.FLAVOR_net);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/BarcodeData.java b/app/src/main/java/com/m2049r/xmrwallet/data/BarcodeData.java
new file mode 100644
index 0000000..3a5cecb
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/BarcodeData.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import android.net.Uri;
+
+import com.m2049r.xmrwallet.util.OpenAliasHelper;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import lombok.ToString;
+import timber.log.Timber;
+
+@ToString
+public class BarcodeData {
+ public enum Security {
+ NORMAL,
+ OA_NO_DNSSEC,
+ OA_DNSSEC
+ }
+
+ final public Crypto asset;
+ final public List ambiguousAssets;
+ final public String address;
+ final public String addressName;
+ final public String amount;
+ final public String description;
+ final public Security security;
+
+ public BarcodeData(List assets, String address) {
+ if (assets.isEmpty())
+ throw new IllegalArgumentException("no assets specified");
+ this.addressName = null;
+ this.description = null;
+ this.amount = null;
+ this.security = Security.NORMAL;
+ this.address = address;
+ if (assets.size() == 1) {
+ this.asset = assets.get(0);
+ this.ambiguousAssets = null;
+ } else {
+ this.asset = null;
+ this.ambiguousAssets = assets;
+ }
+ }
+
+ public BarcodeData(Crypto asset, String address, String description, String amount) {
+ this(asset, address, null, description, amount, Security.NORMAL);
+ }
+
+ public BarcodeData(Crypto asset, String address, String addressName, String description, String amount, Security security) {
+ this.ambiguousAssets = null;
+ this.asset = asset;
+ this.address = address;
+ this.addressName = addressName;
+ this.description = description;
+ this.amount = amount;
+ this.security = security;
+ }
+
+ public Uri getUri() {
+ return Uri.parse(getUriString());
+ }
+
+ public String getUriString() {
+ if (asset != Crypto.XMR) throw new IllegalStateException("We can only do XMR stuff!");
+ StringBuilder sb = new StringBuilder();
+ sb.append(Crypto.XMR.getUriScheme())
+ .append(':')
+ .append(address);
+ boolean first = true;
+ if ((description != null) && !description.isEmpty()) {
+ sb.append(first ? "?" : "&");
+ first = false;
+ sb.append(Crypto.XMR.getUriMessage()).append('=').append(Uri.encode(description));
+ }
+ if ((amount != null) && !amount.isEmpty()) {
+ sb.append(first ? "?" : "&");
+ sb.append(Crypto.XMR.getUriAmount()).append('=').append(amount);
+ }
+ return sb.toString();
+ }
+
+ static private BarcodeData parseNaked(String address) {
+ List possibleAssets = new ArrayList<>();
+ for (Crypto crypto : Crypto.values()) {
+ if (crypto.validate(address)) {
+ possibleAssets.add(crypto);
+ }
+ }
+ if (possibleAssets.isEmpty())
+ return null;
+ return new BarcodeData(possibleAssets, address);
+ }
+
+ static public BarcodeData parseUri(String uriString) {
+ Timber.d("parseBitUri=%s", uriString);
+
+ URI uri;
+ try {
+ uri = new URI(uriString);
+ } catch (URISyntaxException ex) {
+ return null;
+ }
+ if (!uri.isOpaque()) return null;
+ final String scheme = uri.getScheme();
+ Crypto crypto = Crypto.withScheme(scheme);
+ if (crypto == null) return null;
+
+ String[] parts = uri.getRawSchemeSpecificPart().split("[?]");
+ if ((parts.length <= 0) || (parts.length > 2)) {
+ Timber.d("invalid number of parts %d", parts.length);
+ return null;
+ }
+
+ Map parms = new HashMap<>();
+ if (parts.length == 2) {
+ String[] args = parts[1].split("&");
+ for (String arg : args) {
+ String[] namevalue = arg.split("=");
+ if (namevalue.length == 0) {
+ continue;
+ }
+ parms.put(Uri.decode(namevalue[0]).toLowerCase(),
+ namevalue.length > 1 ? Uri.decode(namevalue[1]) : "");
+ }
+ }
+
+ String addressName = parms.get(crypto.getUriLabel());
+ String description = parms.get(crypto.getUriMessage());
+ String address = parts[0]; // no need to decode as there can be no special characters
+ if (address.isEmpty()) {
+ Timber.d("no address");
+ return null;
+ }
+ if (!crypto.validate(address)) {
+ Timber.d("%s address (%s) invalid", crypto, address);
+ return null;
+ }
+ String amount = parms.get(crypto.getUriAmount());
+ if ((amount != null) && (!amount.isEmpty())) {
+ try {
+ Double.parseDouble(amount);
+ } catch (NumberFormatException ex) {
+ Timber.d(ex.getLocalizedMessage());
+ return null; // we have an amount but its not a number!
+ }
+ }
+ return new BarcodeData(crypto, address, addressName, description, amount, Security.NORMAL);
+ }
+
+
+ static public BarcodeData fromString(String qrCode) {
+ BarcodeData bcData = parseUri(qrCode);
+ if (bcData == null) {
+ // maybe it's naked?
+ bcData = parseNaked(qrCode);
+ }
+ if (bcData == null) {
+ // check for OpenAlias
+ bcData = parseOpenAlias(qrCode, false);
+ }
+ return bcData;
+ }
+
+ static public BarcodeData parseOpenAlias(String oaString, boolean dnssec) {
+ Timber.d("parseOpenAlias=%s", oaString);
+ if (oaString == null) return null;
+
+ Map oaAttrs = OpenAliasHelper.parse(oaString);
+ if (oaAttrs == null) return null;
+
+ String oaAsset = oaAttrs.get(OpenAliasHelper.OA1_ASSET);
+ if (oaAsset == null) return null;
+
+ String address = oaAttrs.get(OpenAliasHelper.OA1_ADDRESS);
+ if (address == null) return null;
+
+ Crypto crypto = Crypto.withSymbol(oaAsset);
+ if (crypto == null) {
+ Timber.i("Unsupported OpenAlias asset %s", oaAsset);
+ return null;
+ }
+ if (!crypto.validate(address)) {
+ Timber.d("%s address invalid", crypto);
+ return null;
+ }
+
+ String description = oaAttrs.get(OpenAliasHelper.OA1_DESCRIPTION);
+ if (description == null) {
+ description = oaAttrs.get(OpenAliasHelper.OA1_NAME);
+ }
+ String amount = oaAttrs.get(OpenAliasHelper.OA1_AMOUNT);
+ String addressName = oaAttrs.get(OpenAliasHelper.OA1_NAME);
+
+ if (amount != null) {
+ try {
+ Double.parseDouble(amount);
+ } catch (NumberFormatException ex) {
+ Timber.d(ex.getLocalizedMessage());
+ return null; // we have an amount but its not a number!
+ }
+ }
+
+ Security sec = dnssec ? BarcodeData.Security.OA_DNSSEC : BarcodeData.Security.OA_NO_DNSSEC;
+
+ return new BarcodeData(crypto, address, addressName, description, amount, sec);
+ }
+
+ public boolean isAmbiguous() {
+ return ambiguousAssets != null;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/Crypto.java b/app/src/main/java/com/m2049r/xmrwallet/data/Crypto.java
new file mode 100644
index 0000000..e9e66f1
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/Crypto.java
@@ -0,0 +1,89 @@
+package com.m2049r.xmrwallet.data;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.validator.BitcoinAddressType;
+import com.m2049r.xmrwallet.util.validator.BitcoinAddressValidator;
+import com.m2049r.xmrwallet.util.validator.EthAddressValidator;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum Crypto {
+ XMR("XMR", true, "monero:tx_amount:recipient_name:tx_description", R.id.ibXMR, R.drawable.ic_monero, R.drawable.ic_monero_bw, Wallet::isAddressValid),
+ BTC("BTC", true, "bitcoin:amount:label:message", R.id.ibBTC, R.drawable.ic_xmrto_btc, R.drawable.ic_xmrto_btc_off, address -> {
+ return BitcoinAddressValidator.validate(address, BitcoinAddressType.BTC);
+ }),
+ DASH("DASH", true, "dash:amount:label:message", R.id.ibDASH, R.drawable.ic_xmrto_dash, R.drawable.ic_xmrto_dash_off, address -> {
+ return BitcoinAddressValidator.validate(address, BitcoinAddressType.DASH);
+ }),
+ DOGE("DOGE", true, "dogecoin:amount:label:message", R.id.ibDOGE, R.drawable.ic_xmrto_doge, R.drawable.ic_xmrto_doge_off, address -> {
+ return BitcoinAddressValidator.validate(address, BitcoinAddressType.DOGE);
+ }),
+ ETH("ETH", false, "ethereum:amount:label:message", R.id.ibETH, R.drawable.ic_xmrto_eth, R.drawable.ic_xmrto_eth_off, EthAddressValidator::validate),
+ LTC("LTC", true, "litecoin:amount:label:message", R.id.ibLTC, R.drawable.ic_xmrto_ltc, R.drawable.ic_xmrto_ltc_off, address -> {
+ return BitcoinAddressValidator.validate(address, BitcoinAddressType.LTC);
+ });
+
+ @Getter
+ @NonNull
+ private final String symbol;
+ @Getter
+ private final boolean casefull;
+ @NonNull
+ private final String uriSpec;
+ @Getter
+ private final int buttonId;
+ @Getter
+ private final int iconEnabledId;
+ @Getter
+ private final int iconDisabledId;
+ @NonNull
+ private final Validator validator;
+
+ @Nullable
+ public static Crypto withScheme(@NonNull String scheme) {
+ for (Crypto crypto : values()) {
+ if (crypto.getUriScheme().equals(scheme)) return crypto;
+ }
+ return null;
+ }
+
+ @Nullable
+ public static Crypto withSymbol(@NonNull String symbol) {
+ final String upperSymbol = symbol.toUpperCase();
+ for (Crypto crypto : values()) {
+ if (crypto.symbol.equals(upperSymbol)) return crypto;
+ }
+ return null;
+ }
+
+ interface Validator {
+ boolean validate(String address);
+ }
+
+ // TODO maybe cache these segments
+ String getUriScheme() {
+ return uriSpec.split(":")[0];
+ }
+
+ String getUriAmount() {
+ return uriSpec.split(":")[1];
+ }
+
+ String getUriLabel() {
+ return uriSpec.split(":")[2];
+ }
+
+ String getUriMessage() {
+ return uriSpec.split(":")[3];
+ }
+
+ boolean validate(String address) {
+ return validator.validate(address);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/DefaultNodes.java b/app/src/main/java/com/m2049r/xmrwallet/data/DefaultNodes.java
new file mode 100644
index 0000000..49aab3e
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/DefaultNodes.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2020 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+// Nodes stolen from https://moneroworld.com/#nodes
+
+@AllArgsConstructor
+public enum DefaultNodes {
+ MONERUJO("nodex.monerujo.io:18081"),
+ XMRTO("node.xmr.to:18081"),
+ SUPPORTXMR("node.supportxmr.com:18081"),
+ HASHVAULT("nodes.hashvault.pro:18081"),
+ MONEROWORLD("node.moneroworld.com:18089"),
+ XMRTW("opennode.xmr-tw.org:18089"),
+ MONERUJO_ONION("monerujods7mbghwe6cobdr6ujih6c22zu5rl7zshmizz2udf7v7fsad.onion:18081/mainnet/monerujo.onion"),
+ Criminales78("56wl7y2ebhamkkiza4b7il4mrzwtyvpdym7bm2bkg3jrei2je646k3qd.onion:18089/mainnet/Criminales78.onion"),
+ xmrfail("mxcd4577fldb3ppzy7obmmhnu3tf57gbcbd4qhwr2kxyjj2qi3dnbfqd.onion:18081/mainnet/xmrfail.onion"),
+ boldsuck("6dsdenp6vjkvqzy4wzsnzn6wixkdzihx3khiumyzieauxuxslmcaeiad.onion:18081/mainnet/boldsuck.onion");
+
+ @Getter
+ private final String uri;
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/Node.java b/app/src/main/java/com/m2049r/xmrwallet/data/Node.java
new file mode 100644
index 0000000..0f01153
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/Node.java
@@ -0,0 +1,371 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import com.m2049r.xmrwallet.model.NetworkType;
+import com.m2049r.xmrwallet.model.WalletManager;
+import com.m2049r.xmrwallet.util.OnionHelper;
+
+import java.io.UnsupportedEncodingException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.net.UnknownHostException;
+
+import lombok.Getter;
+import lombok.Setter;
+import timber.log.Timber;
+
+public class Node {
+ static public final String MAINNET = "mainnet";
+ static public final String STAGENET = "stagenet";
+ static public final String TESTNET = "testnet";
+
+ static class Address {
+ final private InetAddress inet;
+ final private String onion;
+
+ public boolean isOnion() {
+ return onion != null;
+ }
+
+ public String getHostName() {
+ if (inet != null) {
+ return inet.getHostName();
+ } else {
+ return onion;
+ }
+ }
+
+ public String getHostAddress() {
+ if (inet != null) {
+ return inet.getHostAddress();
+ } else {
+ return onion;
+ }
+ }
+
+ private Address(InetAddress address, String onion) {
+ this.inet = address;
+ this.onion = onion;
+ }
+
+ static Address of(InetAddress address) {
+ return new Address(address, null);
+ }
+
+ static Address of(String host) throws UnknownHostException {
+ if (OnionHelper.isOnionHost(host)) {
+ return new Address(null, host);
+ } else {
+ return new Address(InetAddress.getByName(host), null);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return getHostAddress().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return (other instanceof Address) && (getHostAddress().equals(((Address) other).getHostAddress()));
+ }
+ }
+
+ @Getter
+ private String name = null;
+ @Getter
+ final private NetworkType networkType;
+ Address hostAddress;
+ @Getter
+ private String host;
+ @Getter
+ @Setter
+ int rpcPort = 0;
+ private int levinPort = 0;
+ @Getter
+ @Setter
+ private String username = "";
+ @Getter
+ @Setter
+ private String password = "";
+ @Getter
+ @Setter
+ private boolean favourite = false;
+ @Getter
+ @Setter
+ private boolean selected = false;
+
+ @Override
+ public int hashCode() {
+ return hostAddress.hashCode();
+ }
+
+ // Nodes are equal if they are the same host address:port & are on the same network
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof Node)) return false;
+ final Node anotherNode = (Node) other;
+ return (hostAddress.equals(anotherNode.hostAddress)
+ && (rpcPort == anotherNode.rpcPort)
+ && (networkType == anotherNode.networkType));
+ }
+
+ public boolean isOnion() {
+ return hostAddress.isOnion();
+ }
+
+ static public Node fromString(String nodeString) {
+ try {
+ return new Node(nodeString);
+ } catch (IllegalArgumentException ex) {
+ Timber.w(ex);
+ return null;
+ }
+ }
+
+ Node(String nodeString) {
+ if ((nodeString == null) || nodeString.isEmpty())
+ throw new IllegalArgumentException("daemon is empty");
+ String daemonAddress;
+ String a[] = nodeString.split("@");
+ if (a.length == 1) { // no credentials
+ daemonAddress = a[0];
+ username = "";
+ password = "";
+ } else if (a.length == 2) { // credentials
+ String userPassword[] = a[0].split(":");
+ if (userPassword.length != 2)
+ throw new IllegalArgumentException("User:Password invalid");
+ username = userPassword[0];
+ if (!username.isEmpty()) {
+ password = userPassword[1];
+ } else {
+ password = "";
+ }
+ daemonAddress = a[1];
+ } else {
+ throw new IllegalArgumentException("Too many @");
+ }
+
+ String daParts[] = daemonAddress.split("/");
+ if ((daParts.length > 3) || (daParts.length < 1))
+ throw new IllegalArgumentException("Too many '/' or too few");
+
+ daemonAddress = daParts[0];
+ String da[] = daemonAddress.split(":");
+ if ((da.length > 2) || (da.length < 1))
+ throw new IllegalArgumentException("Too many ':' or too few");
+ String host = da[0];
+
+ if (daParts.length == 1) {
+ networkType = NetworkType.NetworkType_Mainnet;
+ } else {
+ switch (daParts[1]) {
+ case MAINNET:
+ networkType = NetworkType.NetworkType_Mainnet;
+ break;
+ case STAGENET:
+ networkType = NetworkType.NetworkType_Stagenet;
+ break;
+ case TESTNET:
+ networkType = NetworkType.NetworkType_Testnet;
+ break;
+ default:
+ throw new IllegalArgumentException("invalid net: " + daParts[1]);
+ }
+ }
+ if (networkType != WalletManager.getInstance().getNetworkType())
+ throw new IllegalArgumentException("wrong net: " + networkType);
+
+ String name = host;
+ if (daParts.length == 3) {
+ try {
+ name = URLDecoder.decode(daParts[2], "UTF-8");
+ } catch (UnsupportedEncodingException ex) {
+ Timber.w(ex); // if we can't encode it, we don't use it
+ }
+ }
+ this.name = name;
+
+ int port;
+ if (da.length == 2) {
+ try {
+ port = Integer.parseInt(da[1]);
+ } catch (NumberFormatException ex) {
+ throw new IllegalArgumentException("Port not numeric");
+ }
+ } else {
+ port = getDefaultRpcPort();
+ }
+ try {
+ setHost(host);
+ } catch (UnknownHostException ex) {
+ throw new IllegalArgumentException("cannot resolve host " + host);
+ }
+ this.rpcPort = port;
+ this.levinPort = getDefaultLevinPort();
+ }
+
+ public String toNodeString() {
+ return toString();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ if (!username.isEmpty() && !password.isEmpty()) {
+ sb.append(username).append(":").append(password).append("@");
+ }
+ sb.append(host).append(":").append(rpcPort);
+ sb.append("/");
+ switch (networkType) {
+ case NetworkType_Mainnet:
+ sb.append(MAINNET);
+ break;
+ case NetworkType_Stagenet:
+ sb.append(STAGENET);
+ break;
+ case NetworkType_Testnet:
+ sb.append(TESTNET);
+ break;
+ }
+ if (name != null)
+ try {
+ sb.append("/").append(URLEncoder.encode(name, "UTF-8"));
+ } catch (UnsupportedEncodingException ex) {
+ Timber.w(ex); // if we can't encode it, we don't store it
+ }
+ return sb.toString();
+ }
+
+ public Node() {
+ this.networkType = WalletManager.getInstance().getNetworkType();
+ }
+
+ // constructor used for created nodes from retrieved peer lists
+ public Node(InetSocketAddress socketAddress) {
+ this();
+ this.hostAddress = Address.of(socketAddress.getAddress());
+ this.host = socketAddress.getHostString();
+ this.rpcPort = 0; // unknown
+ this.levinPort = socketAddress.getPort();
+ this.username = "";
+ this.password = "";
+ }
+
+ public String getAddress() {
+ return getHostAddress() + ":" + rpcPort;
+ }
+
+ public String getHostAddress() {
+ return hostAddress.getHostAddress();
+ }
+
+ public void setHost(String host) throws UnknownHostException {
+ if ((host == null) || (host.isEmpty()))
+ throw new UnknownHostException("loopback not supported (yet?)");
+ this.host = host;
+ this.hostAddress = Address.of(host);
+ }
+
+ public void setDefaultName() {
+ if (name != null) return;
+ String nodeName = hostAddress.getHostName();
+ if (hostAddress.isOnion()) {
+ nodeName = nodeName.substring(0, nodeName.length() - ".onion".length());
+ if (nodeName.length() > 16) {
+ nodeName = nodeName.substring(0, 8) + "…" + nodeName.substring(nodeName.length() - 6);
+ }
+ nodeName = nodeName + ".onion";
+ }
+ this.name = nodeName;
+ }
+
+ public void setName(String name) {
+ if ((name == null) || (name.isEmpty()))
+ setDefaultName();
+ else
+ this.name = name;
+ }
+
+ public void toggleFavourite() {
+ favourite = !favourite;
+ }
+
+ public Node(Node anotherNode) {
+ networkType = anotherNode.networkType;
+ overwriteWith(anotherNode);
+ }
+
+ public void overwriteWith(Node anotherNode) {
+ if (networkType != anotherNode.networkType)
+ throw new IllegalStateException("network types do not match");
+ name = anotherNode.name;
+ hostAddress = anotherNode.hostAddress;
+ host = anotherNode.host;
+ rpcPort = anotherNode.rpcPort;
+ levinPort = anotherNode.levinPort;
+ username = anotherNode.username;
+ password = anotherNode.password;
+ favourite = anotherNode.favourite;
+ }
+
+ static private int DEFAULT_LEVIN_PORT = 0;
+
+ // every node knows its network, but they are all the same
+ static public int getDefaultLevinPort() {
+ if (DEFAULT_LEVIN_PORT > 0) return DEFAULT_LEVIN_PORT;
+ switch (WalletManager.getInstance().getNetworkType()) {
+ case NetworkType_Mainnet:
+ DEFAULT_LEVIN_PORT = 18080;
+ break;
+ case NetworkType_Testnet:
+ DEFAULT_LEVIN_PORT = 28080;
+ break;
+ case NetworkType_Stagenet:
+ DEFAULT_LEVIN_PORT = 38080;
+ break;
+ default:
+ throw new IllegalStateException("unsupported net " + WalletManager.getInstance().getNetworkType());
+ }
+ return DEFAULT_LEVIN_PORT;
+ }
+
+ static private int DEFAULT_RPC_PORT = 0;
+
+ // every node knows its network, but they are all the same
+ static public int getDefaultRpcPort() {
+ if (DEFAULT_RPC_PORT > 0) return DEFAULT_RPC_PORT;
+ switch (WalletManager.getInstance().getNetworkType()) {
+ case NetworkType_Mainnet:
+ DEFAULT_RPC_PORT = 18081;
+ break;
+ case NetworkType_Testnet:
+ DEFAULT_RPC_PORT = 28081;
+ break;
+ case NetworkType_Stagenet:
+ DEFAULT_RPC_PORT = 38081;
+ break;
+ default:
+ throw new IllegalStateException("unsupported net " + WalletManager.getInstance().getNetworkType());
+ }
+ return DEFAULT_RPC_PORT;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/NodeInfo.java b/app/src/main/java/com/m2049r/xmrwallet/data/NodeInfo.java
new file mode 100644
index 0000000..351e794
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/NodeInfo.java
@@ -0,0 +1,303 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import android.content.Context;
+import android.text.Html;
+import android.text.Spanned;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.m2049r.levin.scanner.LevinPeer;
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.util.NetCipherHelper;
+import com.m2049r.xmrwallet.util.NetCipherHelper.Request;
+import com.m2049r.xmrwallet.util.NodePinger;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.Calendar;
+import java.util.Comparator;
+
+import lombok.Getter;
+import lombok.Setter;
+import okhttp3.HttpUrl;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import timber.log.Timber;
+
+public class NodeInfo extends Node {
+ final static public int MIN_MAJOR_VERSION = 14;
+ final static public String RPC_VERSION = "2.0";
+
+ @Getter
+ private long height = 0;
+ @Getter
+ private long timestamp = 0;
+ @Getter
+ private int majorVersion = 0;
+ @Getter
+ private double responseTime = Double.MAX_VALUE;
+ @Getter
+ private int responseCode = 0;
+ @Getter
+ private boolean tested = false;
+ @Getter
+ @Setter
+ private boolean selecting = false;
+
+ public void clear() {
+ height = 0;
+ majorVersion = 0;
+ responseTime = Double.MAX_VALUE;
+ responseCode = 0;
+ timestamp = 0;
+ tested = false;
+ }
+
+ static public NodeInfo fromString(String nodeString) {
+ try {
+ return new NodeInfo(nodeString);
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
+ public NodeInfo(NodeInfo anotherNode) {
+ super(anotherNode);
+ overwriteWith(anotherNode);
+ }
+
+ private SocketAddress levinSocketAddress = null;
+
+ synchronized public SocketAddress getLevinSocketAddress() {
+ if (levinSocketAddress == null) {
+ // use default peer port if not set - very few peers use nonstandard port
+ levinSocketAddress = new InetSocketAddress(hostAddress.getHostAddress(), getDefaultLevinPort());
+ }
+ return levinSocketAddress;
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return super.equals(other);
+ }
+
+ public NodeInfo(String nodeString) {
+ super(nodeString);
+ }
+
+ public NodeInfo(LevinPeer levinPeer) {
+ super(levinPeer.getSocketAddress());
+ }
+
+ public NodeInfo(InetSocketAddress address) {
+ super(address);
+ }
+
+ public NodeInfo() {
+ super();
+ }
+
+ public boolean isSuccessful() {
+ return (responseCode >= 200) && (responseCode < 300);
+ }
+
+ public boolean isUnauthorized() {
+ return responseCode == HttpURLConnection.HTTP_UNAUTHORIZED;
+ }
+
+ public boolean isValid() {
+ return isSuccessful() && (majorVersion >= MIN_MAJOR_VERSION) && (responseTime < Double.MAX_VALUE);
+ }
+
+ static public Comparator BestNodeComparator = (o1, o2) -> {
+ if (o1.isValid()) {
+ if (o2.isValid()) { // both are valid
+ // higher node wins
+ int heightDiff = (int) (o2.height - o1.height);
+ if (heightDiff != 0)
+ return heightDiff;
+ // if they are equal, faster node wins
+ return (int) Math.signum(o1.responseTime - o2.responseTime);
+ } else {
+ return -1;
+ }
+ } else {
+ return 1;
+ }
+ };
+
+ public void overwriteWith(NodeInfo anotherNode) {
+ super.overwriteWith(anotherNode);
+ height = anotherNode.height;
+ timestamp = anotherNode.timestamp;
+ majorVersion = anotherNode.majorVersion;
+ responseTime = anotherNode.responseTime;
+ responseCode = anotherNode.responseCode;
+ }
+
+ public String toNodeString() {
+ return super.toString();
+ }
+
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(super.toString());
+ sb.append("?rc=").append(responseCode);
+ sb.append("?v=").append(majorVersion);
+ sb.append("&h=").append(height);
+ sb.append("&ts=").append(timestamp);
+ if (responseTime < Double.MAX_VALUE) {
+ sb.append("&t=").append(responseTime).append("ms");
+ }
+ return sb.toString();
+ }
+
+ private static final int HTTP_TIMEOUT = 1000; //ms
+ public static final double PING_GOOD = HTTP_TIMEOUT / 3.0; //ms
+ public static final double PING_MEDIUM = 2 * PING_GOOD; //ms
+ public static final double PING_BAD = HTTP_TIMEOUT;
+
+ public boolean testRpcService() {
+ return testRpcService(rpcPort);
+ }
+
+ public boolean testRpcService(NodePinger.Listener listener) {
+ boolean result = testRpcService(rpcPort);
+ if (listener != null)
+ listener.publish(this);
+ return result;
+ }
+
+ private Request rpcServiceRequest(int port) {
+ final HttpUrl url = new HttpUrl.Builder()
+ .scheme("http")
+ .host(getHost())
+ .port(port)
+ .addPathSegment("json_rpc")
+ .build();
+ final String json = "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"getlastblockheader\"}";
+ return new Request(url, json, getUsername(), getPassword());
+ }
+
+ private boolean testRpcService(int port) {
+ Timber.d("Testing %s", toNodeString());
+ clear();
+ if (hostAddress.isOnion() && !NetCipherHelper.isTor()) {
+ tested = true; // sortof
+ responseCode = 418; // I'm a teapot - or I need an Onion - who knows
+ return false; // autofail
+ }
+ try {
+ long ta = System.nanoTime();
+ try (Response response = rpcServiceRequest(port).execute()) {
+ Timber.d("%s: %s", response.code(), response.request().url());
+ responseTime = (System.nanoTime() - ta) / 1000000.0;
+ responseCode = response.code();
+ if (response.isSuccessful()) {
+ ResponseBody respBody = response.body(); // closed through Response object
+ if ((respBody != null) && (respBody.contentLength() < 2000)) { // sanity check
+ final JSONObject json = new JSONObject(respBody.string());
+ String rpcVersion = json.getString("jsonrpc");
+ if (!RPC_VERSION.equals(rpcVersion))
+ return false;
+ final JSONObject result = json.getJSONObject("result");
+ if (!result.has("credits")) // introduced in monero v0.15.0
+ return false;
+ final JSONObject header = result.getJSONObject("block_header");
+ height = header.getLong("height");
+ timestamp = header.getLong("timestamp");
+ majorVersion = header.getInt("major_version");
+ return true; // success
+ }
+ }
+ }
+ } catch (IOException | JSONException ex) {
+ Timber.d("EX: %s", ex.getMessage()); //TODO: do something here (show error?)
+ } finally {
+ tested = true;
+ }
+ return false;
+ }
+
+ static final private int[] TEST_PORTS = {18089}; // check only opt-in port
+
+ public boolean findRpcService() {
+ // if already have an rpcPort, use that
+ if (rpcPort > 0) return testRpcService(rpcPort);
+ // otherwise try to find one
+ for (int port : TEST_PORTS) {
+ if (testRpcService(port)) { // found a service
+ this.rpcPort = port;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static public final int STALE_NODE_HOURS = 2;
+
+ public void showInfo(TextView view, String info, boolean isError) {
+ final Context ctx = view.getContext();
+ final Spanned text = Html.fromHtml(ctx.getString(R.string.status,
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, R.attr.positiveColor) & 0xFFFFFF),
+ Integer.toHexString(ThemeHelper.getThemedColor(ctx, android.R.attr.colorBackground) & 0xFFFFFF),
+ (hostAddress.isOnion() ? " .onion " : ""), " " + info));
+ view.setText(text);
+ if (isError)
+ view.setTextColor(ThemeHelper.getThemedColor(ctx, R.attr.colorError));
+ else
+ view.setTextColor(ThemeHelper.getThemedColor(ctx, android.R.attr.textColorSecondary));
+ }
+
+ public void showInfo(TextView view) {
+ if (!isTested()) {
+ showInfo(view, "", false);
+ return;
+ }
+ final Context ctx = view.getContext();
+ final long now = Calendar.getInstance().getTimeInMillis() / 1000;
+ final long secs = (now - timestamp);
+ final long mins = secs / 60;
+ final long hours = mins / 60;
+ final long days = hours / 24;
+ String info;
+ if (mins < 2) {
+ info = ctx.getString(R.string.node_updated_now, secs);
+ } else if (hours < 2) {
+ info = ctx.getString(R.string.node_updated_mins, mins);
+ } else if (days < 2) {
+ info = ctx.getString(R.string.node_updated_hours, hours);
+ } else {
+ info = ctx.getString(R.string.node_updated_days, days);
+ }
+ showInfo(view, info, hours >= STALE_NODE_HOURS);
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/PendingTx.java b/app/src/main/java/com/m2049r/xmrwallet/data/PendingTx.java
new file mode 100644
index 0000000..7f99ad9
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/PendingTx.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import com.m2049r.xmrwallet.model.PendingTransaction;
+
+public class PendingTx {
+ final public PendingTransaction.Status status;
+ final public String error;
+ final public long amount;
+ final public long dust;
+ final public long fee;
+ final public String txId;
+ final public long txCount;
+
+ public PendingTx(PendingTransaction pendingTransaction) {
+ status = pendingTransaction.getStatus();
+ error = pendingTransaction.getErrorString();
+ amount = pendingTransaction.getAmount();
+ dust = pendingTransaction.getDust();
+ fee = pendingTransaction.getFee();
+ txId = pendingTransaction.getFirstTxId();
+ txCount = pendingTransaction.getTxCount();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/Subaddress.java b/app/src/main/java/com/m2049r/xmrwallet/data/Subaddress.java
new file mode 100644
index 0000000..582ce87
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/Subaddress.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import java.util.regex.Pattern;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+@RequiredArgsConstructor
+@ToString
+@EqualsAndHashCode
+public class Subaddress implements Comparable {
+ @Getter
+ final private int accountIndex;
+ @Getter
+ final private int addressIndex;
+ @Getter
+ final private String address;
+ @Getter
+ private final String label;
+ @Getter
+ @Setter
+ private long amount;
+
+ @Override
+ public int compareTo(Subaddress another) { // newer is <
+ final int compareAccountIndex = another.accountIndex - accountIndex;
+ if (compareAccountIndex == 0)
+ return another.addressIndex - addressIndex;
+ return compareAccountIndex;
+ }
+
+ public String getSquashedAddress() {
+ return address.substring(0, 8) + "…" + address.substring(address.length() - 8);
+ }
+
+ public static final Pattern DEFAULT_LABEL_FORMATTER = Pattern.compile("^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}:[0-9]{2}:[0-9]{2}$");
+
+ public String getDisplayLabel() {
+ if (label.isEmpty() || (DEFAULT_LABEL_FORMATTER.matcher(label).matches()))
+ return ("#" + addressIndex);
+ else
+ return label;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/TxData.java b/app/src/main/java/com/m2049r/xmrwallet/data/TxData.java
new file mode 100644
index 0000000..144948c
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/TxData.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+
+// https://stackoverflow.com/questions/2139134/how-to-send-an-object-from-one-android-activity-to-another-using-intents
+public class TxData implements Parcelable {
+
+ public TxData() {
+ }
+
+ public TxData(TxData txData) {
+ this.dstAddr = txData.dstAddr;
+ this.amount = txData.amount;
+ this.mixin = txData.mixin;
+ this.priority = txData.priority;
+ }
+
+ public TxData(String dstAddr,
+ long amount,
+ int mixin,
+ PendingTransaction.Priority priority) {
+ this.dstAddr = dstAddr;
+ this.amount = amount;
+ this.mixin = mixin;
+ this.priority = priority;
+ }
+
+ public String getDestinationAddress() {
+ return dstAddr;
+ }
+
+ public long getAmount() {
+ return amount;
+ }
+
+ public double getAmountAsDouble() {
+ return 1.0 * amount / Helper.ONE_XMR;
+ }
+
+ public int getMixin() {
+ return mixin;
+ }
+
+ public PendingTransaction.Priority getPriority() {
+ return priority;
+ }
+
+ public void setDestinationAddress(String dstAddr) {
+ this.dstAddr = dstAddr;
+ }
+
+ public void setAmount(long amount) {
+ this.amount = amount;
+ }
+
+ public void setAmount(double amount) {
+ this.amount = Wallet.getAmountFromDouble(amount);
+ }
+
+ public void setMixin(int mixin) {
+ this.mixin = mixin;
+ }
+
+ public void setPriority(PendingTransaction.Priority priority) {
+ this.priority = priority;
+ }
+
+ public UserNotes getUserNotes() {
+ return userNotes;
+ }
+
+ public void setUserNotes(UserNotes userNotes) {
+ this.userNotes = userNotes;
+ }
+
+ private String dstAddr;
+ private long amount;
+ private int mixin;
+ private PendingTransaction.Priority priority;
+
+ private UserNotes userNotes;
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ out.writeString(dstAddr);
+ out.writeLong(amount);
+ out.writeInt(mixin);
+ out.writeInt(priority.getValue());
+ }
+
+ // this is used to regenerate your object. All Parcelables must have a CREATOR that implements these two methods
+ public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
+ public TxData createFromParcel(Parcel in) {
+ return new TxData(in);
+ }
+
+ public TxData[] newArray(int size) {
+ return new TxData[size];
+ }
+ };
+
+ protected TxData(Parcel in) {
+ dstAddr = in.readString();
+ amount = in.readLong();
+ mixin = in.readInt();
+ priority = PendingTransaction.Priority.fromInteger(in.readInt());
+
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public String toString() {
+ StringBuffer sb = new StringBuffer();
+ sb.append("dstAddr:");
+ sb.append(dstAddr);
+ sb.append(",amount:");
+ sb.append(amount);
+ sb.append(",mixin:");
+ sb.append(mixin);
+ sb.append(",priority:");
+ sb.append(priority);
+ return sb.toString();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/TxDataBtc.java b/app/src/main/java/com/m2049r/xmrwallet/data/TxDataBtc.java
new file mode 100644
index 0000000..55ec71a
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/TxDataBtc.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import lombok.Getter;
+import lombok.Setter;
+
+public class TxDataBtc extends TxData {
+ @Getter
+ @Setter
+ private String btcSymbol; // the actual non-XMR thing we're sending
+ @Getter
+ @Setter
+ private String xmrtoOrderId; // shown in success screen
+ @Getter
+ @Setter
+ private String btcAddress;
+ @Getter
+ @Setter
+ private double btcAmount;
+
+ public TxDataBtc() {
+ super();
+ }
+
+ public TxDataBtc(TxDataBtc txDataBtc) {
+ super(txDataBtc);
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+ out.writeString(btcSymbol);
+ out.writeString(xmrtoOrderId);
+ out.writeString(btcAddress);
+ out.writeDouble(btcAmount);
+ }
+
+ // this is used to regenerate your object. All Parcelables must have a CREATOR that implements these two methods
+ public static final Creator CREATOR = new Creator() {
+ public TxDataBtc createFromParcel(Parcel in) {
+ return new TxDataBtc(in);
+ }
+
+ public TxDataBtc[] newArray(int size) {
+ return new TxDataBtc[size];
+ }
+ };
+
+ protected TxDataBtc(Parcel in) {
+ super(in);
+ btcSymbol = in.readString();
+ xmrtoOrderId = in.readString();
+ btcAddress = in.readString();
+ btcAmount = in.readDouble();
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("xmrtoOrderId:");
+ sb.append(xmrtoOrderId);
+ sb.append(",btcSymbol:");
+ sb.append(btcSymbol);
+ sb.append(",btcAddress:");
+ sb.append(btcAddress);
+ sb.append(",btcAmount:");
+ sb.append(btcAmount);
+ return sb.toString();
+ }
+
+ public boolean validateAddress(@NonNull String address) {
+ if ((btcSymbol == null) || (btcAddress == null)) return false;
+ final Crypto crypto = Crypto.withSymbol(btcSymbol);
+ if (crypto == null) return false;
+ if (crypto.isCasefull()) { // compare as-is
+ return address.equals(btcAddress);
+ } else { // normalize & compare (e.g. ETH with and without checksum capitals
+ return address.toLowerCase().equals(btcAddress.toLowerCase());
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/data/UserNotes.java b/app/src/main/java/com/m2049r/xmrwallet/data/UserNotes.java
new file mode 100644
index 0000000..f5eb14b
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/data/UserNotes.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.data;
+
+import com.m2049r.xmrwallet.service.shift.sideshift.api.CreateOrder;
+import com.m2049r.xmrwallet.util.Helper;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class UserNotes {
+ public String txNotes = "";
+ public String note = "";
+ public String xmrtoTag = null;
+ public String xmrtoKey = null;
+ public String xmrtoAmount = null; // could be a double - but we are not doing any calculations
+ public String xmrtoCurrency = null;
+ public String xmrtoDestination = null;
+
+ public UserNotes(final String txNotes) {
+ if (txNotes == null) {
+ return;
+ }
+ this.txNotes = txNotes;
+ Pattern p = Pattern.compile("^\\{([a-z]+)-(\\w{6,}),([0-9.]*)([A-Z]+),(\\w*)\\} ?(.*)");
+ Matcher m = p.matcher(txNotes);
+ if (m.find()) {
+ xmrtoTag = m.group(1);
+ xmrtoKey = m.group(2);
+ xmrtoAmount = m.group(3);
+ xmrtoCurrency = m.group(4);
+ xmrtoDestination = m.group(5);
+ note = m.group(6);
+ } else {
+ note = txNotes;
+ }
+ }
+
+ public void setNote(String newNote) {
+ if (newNote != null) {
+ note = newNote;
+ } else {
+ note = "";
+ }
+ txNotes = buildTxNote();
+ }
+
+ public void setXmrtoOrder(CreateOrder order) {
+ if (order != null) {
+ xmrtoTag = order.TAG;
+ xmrtoKey = order.getOrderId();
+ xmrtoAmount = Helper.getDisplayAmount(order.getBtcAmount());
+ xmrtoCurrency = order.getBtcCurrency();
+ xmrtoDestination = order.getBtcAddress();
+ } else {
+ xmrtoTag = null;
+ xmrtoKey = null;
+ xmrtoAmount = null;
+ xmrtoDestination = null;
+ }
+ txNotes = buildTxNote();
+ }
+
+ private String buildTxNote() {
+ StringBuilder sb = new StringBuilder();
+ if (xmrtoKey != null) {
+ if ((xmrtoAmount == null) || (xmrtoDestination == null))
+ throw new IllegalArgumentException("Broken notes");
+ sb.append("{");
+ sb.append(xmrtoTag);
+ sb.append("-");
+ sb.append(xmrtoKey);
+ sb.append(",");
+ sb.append(xmrtoAmount);
+ sb.append(xmrtoCurrency);
+ sb.append(",");
+ sb.append(xmrtoDestination);
+ sb.append("}");
+ if ((note != null) && (!note.isEmpty()))
+ sb.append(" ");
+ }
+ sb.append(note);
+ return sb.toString();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/dialog/AboutFragment.java b/app/src/main/java/com/m2049r/xmrwallet/dialog/AboutFragment.java
new file mode 100644
index 0000000..dc1047e
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/dialog/AboutFragment.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.dialog;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.m2049r.xmrwallet.BuildConfig;
+import com.m2049r.xmrwallet.R;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+import timber.log.Timber;
+
+public class AboutFragment extends DialogFragment {
+ static final String TAG = "AboutFragment";
+
+ public static AboutFragment newInstance() {
+ return new AboutFragment();
+ }
+
+ public static void display(FragmentManager fm) {
+ FragmentTransaction ft = fm.beginTransaction();
+ Fragment prev = fm.findFragmentByTag(TAG);
+ if (prev != null) {
+ ft.remove(prev);
+ }
+
+ AboutFragment.newInstance().show(ft, TAG);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_about, null);
+ ((TextView) view.findViewById(R.id.tvHelp)).setText(Html.fromHtml(getLicencesHtml()));
+ ((TextView) view.findViewById(R.id.tvVersion)).setText(getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity())
+ .setView(view)
+ .setNegativeButton(R.string.about_close,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.dismiss();
+ }
+ });
+ return builder.create();
+ }
+
+ private String getLicencesHtml() {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(getContext().getAssets().open("licenses.html"), StandardCharsets.UTF_8))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null)
+ sb.append(line);
+ return sb.toString();
+ } catch (IOException ex) {
+ Timber.e(ex);
+ return ex.getLocalizedMessage();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/dialog/CreditsFragment.java b/app/src/main/java/com/m2049r/xmrwallet/dialog/CreditsFragment.java
new file mode 100644
index 0000000..d33921e
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/dialog/CreditsFragment.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.dialog;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.m2049r.xmrwallet.R;
+
+public class CreditsFragment extends DialogFragment {
+ static final String TAG = "DonationFragment";
+
+ public static CreditsFragment newInstance() {
+ return new CreditsFragment();
+ }
+
+ public static void display(FragmentManager fm) {
+ FragmentTransaction ft = fm.beginTransaction();
+ Fragment prev = fm.findFragmentByTag(TAG);
+ if (prev != null) {
+ ft.remove(prev);
+ }
+
+ CreditsFragment.newInstance().show(ft, TAG);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_credits, null);
+
+ ((TextView) view.findViewById(R.id.tvCredits)).setText(Html.fromHtml(getString(R.string.credits_text)));
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity())
+ .setView(view)
+ .setNegativeButton(R.string.about_close,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.dismiss();
+ }
+ });
+ return builder.create();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/dialog/HelpFragment.java b/app/src/main/java/com/m2049r/xmrwallet/dialog/HelpFragment.java
new file mode 100644
index 0000000..6928937
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/dialog/HelpFragment.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.dialog;
+
+import android.app.Dialog;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.util.NetCipherHelper;
+
+public class HelpFragment extends DialogFragment {
+ static final String TAG = "HelpFragment";
+ private static final String HELP_ID = "HELP_ID";
+ private static final String TOR_BUTTON = "TOR";
+
+ public static HelpFragment newInstance(int helpResourceId) {
+ HelpFragment fragment = new HelpFragment();
+ Bundle bundle = new Bundle();
+ bundle.putInt(HELP_ID, helpResourceId);
+ // a hack for the tor button
+ if (helpResourceId == R.string.help_tor)
+ bundle.putInt(TOR_BUTTON, 7);
+ fragment.setArguments(bundle);
+ return fragment;
+ }
+
+ public static void display(FragmentManager fm, int helpResourceId) {
+ FragmentTransaction ft = fm.beginTransaction();
+ Fragment prev = fm.findFragmentByTag(TAG);
+ if (prev != null) {
+ ft.remove(prev);
+ }
+
+ HelpFragment.newInstance(helpResourceId).show(ft, TAG);
+ }
+
+ private Spanned getHtml(String html, double textSize) {
+ final Html.ImageGetter imageGetter = source -> {
+ final int imageId = getResources().getIdentifier(source.replace("/", ""), "drawable", requireActivity().getPackageName());
+ // Don't die if we don't find the image - use a heart instead
+ final Drawable drawable = ContextCompat.getDrawable(requireActivity(), imageId > 0 ? imageId : R.drawable.ic_favorite_24dp);
+ final double f = textSize / drawable.getIntrinsicHeight();
+ drawable.setBounds(0, 0, (int) (f * drawable.getIntrinsicWidth()), (int) textSize);
+ return drawable;
+ };
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ return Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY, imageGetter, null);
+ } else {
+ return Html.fromHtml(html, imageGetter, null);
+ }
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_help, null);
+
+ int helpId = 0;
+ boolean torButton = false;
+ Bundle arguments = getArguments();
+ if (arguments != null) {
+ helpId = arguments.getInt(HELP_ID);
+ torButton = arguments.getInt(TOR_BUTTON) > 0;
+ }
+ final TextView helpTv = view.findViewById(R.id.tvHelp);
+ if (helpId > 0)
+ helpTv.setText(getHtml(getString(helpId), helpTv.getTextSize()));
+
+ MaterialAlertDialogBuilder builder =
+ new MaterialAlertDialogBuilder(requireActivity())
+ .setView(view);
+ if (torButton) {
+ builder.setNegativeButton(R.string.help_nok,
+ (dialog, id) -> dialog.dismiss())
+ .setPositiveButton(R.string.help_getorbot,
+ (dialog, id) -> {
+ dialog.dismiss();
+ NetCipherHelper.getInstance().installOrbot(requireActivity());
+ });
+ } else {
+ builder.setNegativeButton(R.string.help_ok,
+ (dialog, id) -> dialog.dismiss());
+ }
+ return builder.create();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/dialog/PrivacyFragment.java b/app/src/main/java/com/m2049r/xmrwallet/dialog/PrivacyFragment.java
new file mode 100644
index 0000000..6ddb2e4
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/dialog/PrivacyFragment.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.dialog;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.m2049r.xmrwallet.R;
+
+public class PrivacyFragment extends DialogFragment {
+ static final String TAG = "PrivacyFragment";
+
+ public static PrivacyFragment newInstance() {
+ return new PrivacyFragment();
+ }
+
+ public static void display(FragmentManager fm) {
+ FragmentTransaction ft = fm.beginTransaction();
+ Fragment prev = fm.findFragmentByTag(TAG);
+ if (prev != null) {
+ ft.remove(prev);
+ }
+
+ PrivacyFragment.newInstance().show(ft, TAG);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_privacy_policy, null);
+
+ ((TextView) view.findViewById(R.id.tvCredits)).setText(Html.fromHtml(getString(R.string.privacy_policy)));
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity())
+ .setView(view)
+ .setNegativeButton(R.string.about_close,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.dismiss();
+ }
+ });
+ return builder.create();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/dialog/ProgressDialog.java b/app/src/main/java/com/m2049r/xmrwallet/dialog/ProgressDialog.java
new file mode 100644
index 0000000..a8bb780
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/dialog/ProgressDialog.java
@@ -0,0 +1,132 @@
+package com.m2049r.xmrwallet.dialog;
+
+/*
+ * Copyright (C) 2007 The Android Open Source Project
+ * Copyright (C) 2018 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.util.Helper;
+
+import java.util.Locale;
+
+import timber.log.Timber;
+
+public class ProgressDialog extends AlertDialog {
+
+ private ProgressBar pbBar;
+
+ private TextView tvMessage;
+
+ private TextView tvProgress;
+
+ private View rlProgressBar, pbCircle;
+
+ static private final String PROGRESS_FORMAT = "%1d/%2d";
+
+ private CharSequence message;
+ private int maxValue, progressValue;
+ private boolean indeterminate = true;
+
+ public ProgressDialog(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ final View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_ledger_progress, null);
+ pbCircle = view.findViewById(R.id.pbCircle);
+ tvMessage = view.findViewById(R.id.tvMessage);
+ rlProgressBar = view.findViewById(R.id.rlProgressBar);
+ pbBar = view.findViewById(R.id.pbBar);
+ tvProgress = view.findViewById(R.id.tvProgress);
+ setView(view);
+ setIndeterminate(indeterminate);
+ if (maxValue > 0) {
+ setMax(maxValue);
+ }
+ if (progressValue > 0) {
+ setProgress(progressValue);
+ }
+ if (message != null) {
+ Timber.d("msg=%s", message);
+ setMessage(message);
+ }
+
+ super.onCreate(savedInstanceState);
+
+ if (Helper.preventScreenshot()) {
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+ }
+
+ public void setProgress(int value, int max) {
+ progressValue = value;
+ maxValue = max;
+ if (pbBar != null) {
+ pbBar.setProgress(value);
+ pbBar.setMax(max);
+ tvProgress.setText(String.format(Locale.getDefault(), PROGRESS_FORMAT, value, maxValue));
+ }
+ }
+
+ public void setProgress(int value) {
+ progressValue = value;
+ if (pbBar != null) {
+ pbBar.setProgress(value);
+ tvProgress.setText(String.format(Locale.getDefault(), PROGRESS_FORMAT, value, maxValue));
+ }
+ }
+
+ public void setMax(int max) {
+ maxValue = max;
+ if (pbBar != null) {
+ pbBar.setMax(max);
+ }
+ }
+
+ public void setIndeterminate(boolean indeterminate) {
+ if (this.indeterminate != indeterminate) {
+ if (rlProgressBar != null) {
+ if (indeterminate) {
+ pbCircle.setVisibility(View.VISIBLE);
+ rlProgressBar.setVisibility(View.GONE);
+ } else {
+ pbCircle.setVisibility(View.GONE);
+ rlProgressBar.setVisibility(View.VISIBLE);
+ }
+ }
+ this.indeterminate = indeterminate;
+ }
+ }
+
+ @Override
+ public void setMessage(CharSequence message) {
+ this.message = message;
+ if (tvMessage != null) {
+ tvMessage.setText(message);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAddressWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAddressWizardFragment.java
new file mode 100644
index 0000000..f25ad19
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAddressWizardFragment.java
@@ -0,0 +1,525 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.content.Context;
+import android.nfc.NfcManager;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.InputType;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.util.Patterns;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.Crypto;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.data.TxDataBtc;
+import com.m2049r.xmrwallet.data.UserNotes;
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.OpenAliasHelper;
+import com.m2049r.xmrwallet.util.ServiceHelper;
+import com.m2049r.xmrwallet.util.validator.BitcoinAddressType;
+import com.m2049r.xmrwallet.util.validator.BitcoinAddressValidator;
+import com.m2049r.xmrwallet.util.validator.EthAddressValidator;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import timber.log.Timber;
+
+public class SendAddressWizardFragment extends SendWizardFragment {
+
+ static final int INTEGRATED_ADDRESS_LENGTH = 106;
+
+ public static SendAddressWizardFragment newInstance(Listener listener) {
+ SendAddressWizardFragment instance = new SendAddressWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ Listener sendListener;
+
+ public void setSendListener(Listener listener) {
+ this.sendListener = listener;
+ }
+
+ public interface Listener {
+ void setBarcodeData(BarcodeData data);
+
+ BarcodeData getBarcodeData();
+
+ BarcodeData popBarcodeData();
+
+ void setMode(SendFragment.Mode mode);
+
+ TxData getTxData();
+ }
+
+ private EditText etDummy;
+ private TextInputLayout etAddress;
+ private TextInputLayout etNotes;
+ private TextView tvXmrTo;
+ private TextView tvTor;
+ private Map ibCrypto;
+ final private Set possibleCryptos = new HashSet<>();
+ private Crypto selectedCrypto = null;
+
+ private boolean resolvingOA = false;
+
+ OnScanListener onScanListener;
+
+ public interface OnScanListener {
+ void onScan();
+ }
+
+ private Crypto getCryptoForButton(ImageButton button) {
+ for (Map.Entry entry : ibCrypto.entrySet()) {
+ if (entry.getValue() == button) return entry.getKey();
+ }
+ return null;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Timber.d("onCreateView() %s", (String.valueOf(savedInstanceState)));
+
+ View view = inflater.inflate(R.layout.fragment_send_address, container, false);
+
+ tvTor = view.findViewById(R.id.tvTor);
+ tvXmrTo = view.findViewById(R.id.tvXmrTo);
+ ibCrypto = new HashMap<>();
+ for (Crypto crypto : Crypto.values()) {
+ final ImageButton button = view.findViewById(crypto.getButtonId());
+ if (Helper.ALLOW_SHIFT || (crypto == Crypto.XMR)) {
+ ibCrypto.put(crypto, button);
+ button.setOnClickListener(v -> {
+ if (possibleCryptos.contains(crypto)) {
+ selectedCrypto = crypto;
+ updateCryptoButtons(false);
+ } else {
+ // show help what to do:
+ if (button.getId() != R.id.ibXMR) {
+ final String name = getResources().getStringArray(R.array.cryptos)[crypto.ordinal()];
+ final String symbol = getCryptoForButton(button).getSymbol();
+ tvXmrTo.setText(Html.fromHtml(getString(R.string.info_xmrto_help, name, symbol)));
+ tvXmrTo.setVisibility(View.VISIBLE);
+ } else {
+ tvXmrTo.setText(Html.fromHtml(getString(R.string.info_xmrto_help_xmr)));
+ tvXmrTo.setVisibility(View.VISIBLE);
+ tvTor.setVisibility(View.INVISIBLE);
+ }
+ }
+ });
+ } else {
+ button.setImageResource(crypto.getIconDisabledId());
+ button.setImageAlpha(128);
+ button.setEnabled(false);
+ }
+ }
+ if (!Helper.ALLOW_SHIFT) {
+ tvTor.setVisibility(View.VISIBLE);
+ }
+ updateCryptoButtons(true);
+
+ etAddress = view.findViewById(R.id.etAddress);
+ etAddress.getEditText().setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ etAddress.getEditText().setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ // ignore ENTER
+ return ((event != null) && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER));
+ }
+ });
+ etAddress.getEditText().setOnFocusChangeListener((v, hasFocus) -> {
+ if (!hasFocus) {
+ String enteredAddress = etAddress.getEditText().getText().toString().trim();
+ String dnsOA = dnsFromOpenAlias(enteredAddress);
+ Timber.d("OpenAlias is %s", dnsOA);
+ if (dnsOA != null) {
+ processOpenAlias(dnsOA);
+ }
+ }
+ });
+ etAddress.getEditText().addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable editable) {
+ Timber.d("AFTER: %s", editable.toString());
+ etAddress.setError(null);
+ possibleCryptos.clear();
+ selectedCrypto = null;
+ final String address = etAddress.getEditText().getText().toString();
+ if (isIntegratedAddress(address)) {
+ Timber.d("isIntegratedAddress");
+ possibleCryptos.add(Crypto.XMR);
+ selectedCrypto = Crypto.XMR;
+ etAddress.setError(getString(R.string.info_paymentid_integrated));
+ sendListener.setMode(SendFragment.Mode.XMR);
+ } else if (isStandardAddress(address)) {
+ Timber.d("isStandardAddress");
+ possibleCryptos.add(Crypto.XMR);
+ selectedCrypto = Crypto.XMR;
+ sendListener.setMode(SendFragment.Mode.XMR);
+ }
+ if (!Helper.ALLOW_SHIFT) return;
+ if ((selectedCrypto == null) && isEthAddress(address)) {
+ Timber.d("isEthAddress");
+ possibleCryptos.add(Crypto.ETH);
+ selectedCrypto = Crypto.ETH;
+ tvXmrTo.setVisibility(View.VISIBLE);
+ sendListener.setMode(SendFragment.Mode.BTC);
+ }
+ if (possibleCryptos.isEmpty()) {
+ Timber.d("isBitcoinAddress");
+ for (BitcoinAddressType type : BitcoinAddressType.values()) {
+ if (BitcoinAddressValidator.validate(address, type)) {
+ possibleCryptos.add(Crypto.valueOf(type.name()));
+ }
+ }
+ if (!possibleCryptos.isEmpty()) // found something in need of shifting!
+ sendListener.setMode(SendFragment.Mode.BTC);
+ if (possibleCryptos.size() == 1) {
+ selectedCrypto = (Crypto) possibleCryptos.toArray()[0];
+ }
+ }
+ if (possibleCryptos.isEmpty()) {
+ Timber.d("other");
+ tvXmrTo.setVisibility(View.INVISIBLE);
+ sendListener.setMode(SendFragment.Mode.XMR);
+ }
+ updateCryptoButtons(address.isEmpty());
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ });
+
+ final ImageButton bPasteAddress = view.findViewById(R.id.bPasteAddress);
+ bPasteAddress.setOnClickListener(v -> {
+ final String clip = Helper.getClipBoardText(getActivity());
+ if (clip == null) return;
+ // clean it up
+ final String address = clip.replaceAll("( +)|(\\r?\\n?)", "");
+ BarcodeData bc = BarcodeData.fromString(address);
+ if (bc != null) {
+ processScannedData(bc);
+ final EditText et = etAddress.getEditText();
+ et.setSelection(et.getText().length());
+ etAddress.requestFocus();
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.send_address_invalid), Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ etNotes = view.findViewById(R.id.etNotes);
+ etNotes.getEditText().setRawInputType(InputType.TYPE_CLASS_TEXT);
+ etNotes.getEditText().
+
+ setOnEditorActionListener((v, actionId, event) -> {
+ if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN))
+ || (actionId == EditorInfo.IME_ACTION_DONE)) {
+ etDummy.requestFocus();
+ return true;
+ }
+ return false;
+ });
+
+ final View cvScan = view.findViewById(R.id.bScan);
+ cvScan.setOnClickListener(v -> onScanListener.onScan());
+
+ etDummy = view.findViewById(R.id.etDummy);
+ etDummy.setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ etDummy.requestFocus();
+
+ View tvNfc = view.findViewById(R.id.tvNfc);
+ NfcManager manager = (NfcManager) getContext().getSystemService(Context.NFC_SERVICE);
+ if ((manager != null) && (manager.getDefaultAdapter() != null))
+ tvNfc.setVisibility(View.VISIBLE);
+
+ return view;
+ }
+
+ private void selectedCrypto(Crypto crypto) {
+ final ImageButton button = ibCrypto.get(crypto);
+ button.setImageResource(crypto.getIconEnabledId());
+ button.setImageAlpha(255);
+ button.setEnabled(true);
+ }
+
+ private void possibleCrypto(Crypto crypto) {
+ final ImageButton button = ibCrypto.get(crypto);
+ button.setImageResource(crypto.getIconDisabledId());
+ button.setImageAlpha(255);
+ button.setEnabled(true);
+ }
+
+ private void impossibleCrypto(Crypto crypto) {
+ final ImageButton button = ibCrypto.get(crypto);
+ button.setImageResource(crypto.getIconDisabledId());
+ button.setImageAlpha(128);
+ button.setEnabled(true);
+ }
+
+ private void updateCryptoButtons(boolean noAddress) {
+ if (!Helper.ALLOW_SHIFT) return;
+ for (Crypto crypto : Crypto.values()) {
+ if (crypto == selectedCrypto) {
+ selectedCrypto(crypto);
+ } else if (possibleCryptos.contains(crypto)) {
+ possibleCrypto(crypto);
+ } else {
+ impossibleCrypto(crypto);
+ }
+ }
+ if ((selectedCrypto != null) && (selectedCrypto != Crypto.XMR)) {
+ tvXmrTo.setText(Html.fromHtml(getString(R.string.info_xmrto, selectedCrypto.getSymbol())));
+ tvXmrTo.setVisibility(View.VISIBLE);
+ } else if ((selectedCrypto == null) && (possibleCryptos.size() > 1)) {
+ tvXmrTo.setText(Html.fromHtml(getString(R.string.info_xmrto_ambiguous)));
+ tvXmrTo.setVisibility(View.VISIBLE);
+ } else {
+ tvXmrTo.setVisibility(View.INVISIBLE);
+ }
+ if (noAddress) {
+ selectedCrypto(Crypto.XMR);
+ }
+ }
+
+ private void processOpenAlias(String dnsOA) {
+ if (resolvingOA) return; // already resolving - just wait
+ sendListener.popBarcodeData();
+ if (dnsOA != null) {
+ resolvingOA = true;
+ etAddress.setError(getString(R.string.send_address_resolve_openalias));
+ OpenAliasHelper.resolve(dnsOA, new OpenAliasHelper.OnResolvedListener() {
+ @Override
+ public void onResolved(Map dataMap) {
+ resolvingOA = false;
+ BarcodeData barcodeData = dataMap.get(Crypto.XMR);
+ if (barcodeData == null) barcodeData = dataMap.get(Crypto.BTC);
+ if (barcodeData != null) {
+ Timber.d("Security=%s, %s", barcodeData.security.toString(), barcodeData.address);
+ processScannedData(barcodeData);
+ } else {
+ etAddress.setError(getString(R.string.send_address_not_openalias));
+ Timber.d("NO XMR OPENALIAS TXT FOUND");
+ }
+ }
+
+ @Override
+ public void onFailure() {
+ resolvingOA = false;
+ etAddress.setError(getString(R.string.send_address_not_openalias));
+ Timber.e("OA FAILED");
+ }
+ });
+ } // else ignore
+ }
+
+ private boolean checkAddressNoError() {
+ return selectedCrypto != null;
+ }
+
+ private boolean checkAddress() {
+ boolean ok = checkAddressNoError();
+ if (possibleCryptos.isEmpty()) {
+ etAddress.setError(getString(R.string.send_address_invalid));
+ } else {
+ etAddress.setError(null);
+ }
+ return ok;
+ }
+
+ private boolean isStandardAddress(String address) {
+ return Wallet.isAddressValid(address);
+ }
+
+ private boolean isIntegratedAddress(String address) {
+ return (address.length() == INTEGRATED_ADDRESS_LENGTH)
+ && Wallet.isAddressValid(address);
+ }
+
+ private boolean isBitcoinishAddress(String address) {
+ return BitcoinAddressValidator.validate(address, BitcoinAddressType.BTC)
+ ||
+ BitcoinAddressValidator.validate(address, BitcoinAddressType.LTC)
+ ||
+ BitcoinAddressValidator.validate(address, BitcoinAddressType.DASH);
+ }
+
+ private boolean isEthAddress(String address) {
+ return EthAddressValidator.validate(address);
+ }
+
+ private void shakeAddress() {
+ if (possibleCryptos.size() > 1) { // address ambiguous
+ for (Crypto crypto : Crypto.values()) {
+ if (possibleCryptos.contains(crypto)) {
+ ibCrypto.get(crypto).startAnimation(Helper.getShakeAnimation(getContext()));
+ }
+ }
+ } else {
+ etAddress.startAnimation(Helper.getShakeAnimation(getContext()));
+ }
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ if (!checkAddressNoError()) {
+ shakeAddress();
+ String enteredAddress = etAddress.getEditText().getText().toString().trim();
+ String dnsOA = dnsFromOpenAlias(enteredAddress);
+ Timber.d("OpenAlias is %s", dnsOA);
+ if (dnsOA != null) {
+ processOpenAlias(dnsOA);
+ }
+ return false;
+ }
+
+ if (sendListener != null) {
+ TxData txData = sendListener.getTxData();
+ if (txData instanceof TxDataBtc) {
+ ((TxDataBtc) txData).setBtcAddress(etAddress.getEditText().getText().toString());
+ ((TxDataBtc) txData).setBtcSymbol(selectedCrypto.getSymbol());
+ txData.setDestinationAddress(null);
+ ServiceHelper.ASSET = selectedCrypto.getSymbol().toLowerCase();
+ } else {
+ txData.setDestinationAddress(etAddress.getEditText().getText().toString());
+ ServiceHelper.ASSET = null;
+ }
+ txData.setUserNotes(new UserNotes(etNotes.getEditText().getText().toString()));
+ txData.setPriority(PendingTransaction.Priority.Priority_Default);
+ txData.setMixin(SendFragment.MIXIN);
+ }
+ return true;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof OnScanListener) {
+ onScanListener = (OnScanListener) context;
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement ScanListener");
+ }
+ }
+
+ // QR Scan Stuff
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume");
+ processScannedData();
+ }
+
+ public void processScannedData(BarcodeData barcodeData) {
+ sendListener.setBarcodeData(barcodeData);
+ if (isResumed())
+ processScannedData();
+ }
+
+ public void processScannedData() {
+ BarcodeData barcodeData = sendListener.getBarcodeData();
+ if (barcodeData != null) {
+ Timber.d("GOT DATA");
+ if (!Helper.ALLOW_SHIFT && (barcodeData.asset != Crypto.XMR)) {
+ Timber.d("BUT ONLY XMR SUPPORTED");
+ barcodeData = null;
+ sendListener.setBarcodeData(barcodeData);
+ return;
+ }
+ if (barcodeData.address != null) {
+ etAddress.getEditText().setText(barcodeData.address);
+ possibleCryptos.clear();
+ selectedCrypto = null;
+ if (barcodeData.isAmbiguous()) {
+ possibleCryptos.addAll(barcodeData.ambiguousAssets);
+ } else {
+ possibleCryptos.add(barcodeData.asset);
+ selectedCrypto = barcodeData.asset;
+ }
+ if (Helper.ALLOW_SHIFT)
+ updateCryptoButtons(false);
+ if (checkAddress()) {
+ if (barcodeData.security == BarcodeData.Security.OA_NO_DNSSEC)
+ etAddress.setError(getString(R.string.send_address_no_dnssec));
+ else if (barcodeData.security == BarcodeData.Security.OA_DNSSEC)
+ etAddress.setError(getString(R.string.send_address_openalias));
+ }
+ } else {
+ etAddress.getEditText().getText().clear();
+ etAddress.setError(null);
+ }
+
+ String scannedNotes = barcodeData.addressName;
+ if (scannedNotes == null) {
+ scannedNotes = barcodeData.description;
+ } else if (barcodeData.description != null) {
+ scannedNotes = scannedNotes + ": " + barcodeData.description;
+ }
+ if (scannedNotes != null) {
+ etNotes.getEditText().setText(scannedNotes);
+ } else {
+ etNotes.getEditText().getText().clear();
+ etNotes.setError(null);
+ }
+ } else
+ Timber.d("barcodeData=null");
+ }
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ etDummy.requestFocus();
+ }
+
+ String dnsFromOpenAlias(String openalias) {
+ Timber.d("checking openalias candidate %s", openalias);
+ if (Patterns.DOMAIN_NAME.matcher(openalias).matches()) return openalias;
+ if (Patterns.EMAIL_ADDRESS.matcher(openalias).matches()) {
+ openalias = openalias.replaceFirst("@", ".");
+ if (Patterns.DOMAIN_NAME.matcher(openalias).matches()) return openalias;
+ }
+ return null; // not an openalias
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java
new file mode 100644
index 0000000..12edaf6
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.widget.ExchangeEditText;
+
+import timber.log.Timber;
+
+public class SendAmountWizardFragment extends SendWizardFragment {
+
+ public static SendAmountWizardFragment newInstance(Listener listener) {
+ SendAmountWizardFragment instance = new SendAmountWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ Listener sendListener;
+
+ public void setSendListener(Listener listener) {
+ this.sendListener = listener;
+ }
+
+ interface Listener {
+ SendFragment.Listener getActivityCallback();
+
+ TxData getTxData();
+
+ BarcodeData popBarcodeData();
+ }
+
+ private TextView tvFunds;
+ private ExchangeEditText etAmount;
+ private View rlSweep;
+ private ImageButton ibSweep;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Timber.d("onCreateView() %s", (String.valueOf(savedInstanceState)));
+
+ sendListener = (Listener) getParentFragment();
+
+ View view = inflater.inflate(R.layout.fragment_send_amount, container, false);
+
+ tvFunds = view.findViewById(R.id.tvFunds);
+ etAmount = view.findViewById(R.id.etAmount);
+ rlSweep = view.findViewById(R.id.rlSweep);
+
+ view.findViewById(R.id.ivSweep).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ sweepAll(false);
+ }
+ });
+
+ ibSweep = view.findViewById(R.id.ibSweep);
+
+ ibSweep.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ sweepAll(true);
+ }
+ });
+
+ etAmount.requestFocus();
+ return view;
+ }
+
+ private boolean spendAllMode = false;
+
+ private void sweepAll(boolean spendAllMode) {
+ if (spendAllMode) {
+ ibSweep.setVisibility(View.INVISIBLE);
+ etAmount.setVisibility(View.GONE);
+ rlSweep.setVisibility(View.VISIBLE);
+ } else {
+ ibSweep.setVisibility(View.VISIBLE);
+ etAmount.setVisibility(View.VISIBLE);
+ rlSweep.setVisibility(View.GONE);
+ }
+ this.spendAllMode = spendAllMode;
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ if (spendAllMode) {
+ if (sendListener != null) {
+ sendListener.getTxData().setAmount(Wallet.SWEEP_ALL);
+ }
+ } else {
+ if (!etAmount.validate(maxFunds, 0)) {
+ return false;
+ }
+
+ if (sendListener != null) {
+ String xmr = etAmount.getNativeAmount();
+ if (xmr != null) {
+ sendListener.getTxData().setAmount(Wallet.getAmountFromString(xmr));
+ } else {
+ sendListener.getTxData().setAmount(0L);
+ }
+ }
+ }
+ return true;
+ }
+
+ double maxFunds = 0;
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ Helper.showKeyboard(getActivity());
+ final long funds = getTotalFunds();
+ maxFunds = 1.0 * funds / Helper.ONE_XMR;
+ if (!sendListener.getActivityCallback().isStreetMode()) {
+ tvFunds.setText(getString(R.string.send_available,
+ Wallet.getDisplayAmount(funds)));
+ } else {
+ tvFunds.setText(getString(R.string.send_available,
+ getString(R.string.unknown_amount)));
+ }
+ final BarcodeData data = sendListener.popBarcodeData();
+ if ((data != null) && (data.amount != null)) {
+ etAmount.setAmount(data.amount);
+ }
+ }
+
+ long getTotalFunds() {
+ return sendListener.getActivityCallback().getTotalFunds();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java
new file mode 100644
index 0000000..72b2e98
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.os.Bundle;
+import android.text.Html;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.TxDataBtc;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.service.shift.ShiftCallback;
+import com.m2049r.xmrwallet.service.shift.ShiftError;
+import com.m2049r.xmrwallet.service.shift.ShiftException;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.QueryOrderParameters;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi;
+import com.m2049r.xmrwallet.service.shift.sideshift.network.SideShiftApiImpl;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ServiceHelper;
+import com.m2049r.xmrwallet.widget.ExchangeOtherEditText;
+import com.m2049r.xmrwallet.widget.SendProgressView;
+
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import timber.log.Timber;
+
+public class SendBtcAmountWizardFragment extends SendWizardFragment {
+
+ public static SendBtcAmountWizardFragment newInstance(SendAmountWizardFragment.Listener listener) {
+ SendBtcAmountWizardFragment instance = new SendBtcAmountWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ SendAmountWizardFragment.Listener sendListener;
+
+ public SendBtcAmountWizardFragment setSendListener(SendAmountWizardFragment.Listener listener) {
+ this.sendListener = listener;
+ return this;
+ }
+
+ private TextView tvFunds;
+ private ExchangeOtherEditText etAmount;
+
+ private TextView tvXmrToParms;
+ private SendProgressView evParams;
+ private View llXmrToParms;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Timber.d("onCreateView() %s", (String.valueOf(savedInstanceState)));
+
+ sendListener = (SendAmountWizardFragment.Listener) getParentFragment();
+
+ View view = inflater.inflate(R.layout.fragment_send_btc_amount, container, false);
+
+ tvFunds = view.findViewById(R.id.tvFunds);
+
+ evParams = view.findViewById(R.id.evXmrToParms);
+ llXmrToParms = view.findViewById(R.id.llXmrToParms);
+
+ tvXmrToParms = view.findViewById(R.id.tvXmrToParms);
+
+ etAmount = view.findViewById(R.id.etAmount);
+ etAmount.requestFocus();
+
+ return view;
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ Timber.i(maxBtc + "/" + minBtc);
+ if (!etAmount.validate(maxBtc, minBtc)) {
+ return false;
+ }
+ if (orderParameters == null) {
+ return false; // this should never happen
+ }
+ if (sendListener != null) {
+ TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData();
+ String btcString = etAmount.getNativeAmount();
+ if (btcString != null) {
+ try {
+ double btc = Double.parseDouble(btcString);
+ Timber.d("setBtcAmount %f", btc);
+ txDataBtc.setBtcAmount(btc);
+ txDataBtc.setAmount(btc / orderParameters.getPrice());
+ } catch (NumberFormatException ex) {
+ Timber.d(ex.getLocalizedMessage());
+ txDataBtc.setBtcAmount(0);
+ }
+ } else {
+ txDataBtc.setBtcAmount(0);
+ }
+ }
+ return true;
+ }
+
+ double maxBtc = 0;
+ double minBtc = 0;
+
+ @Override
+ public void onPauseFragment() {
+ llXmrToParms.setVisibility(View.INVISIBLE);
+ }
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ final String btcSymbol = ((TxDataBtc) sendListener.getTxData()).getBtcSymbol();
+ if (!btcSymbol.toLowerCase().equals(ServiceHelper.ASSET))
+ throw new IllegalStateException("Asset Symbol is wrong!");
+ final long funds = getTotalFunds();
+ if (!sendListener.getActivityCallback().isStreetMode()) {
+ tvFunds.setText(getString(R.string.send_available,
+ Wallet.getDisplayAmount(funds)));
+ //TODO
+ } else {
+ tvFunds.setText(getString(R.string.send_available,
+ getString(R.string.unknown_amount)));
+ }
+ etAmount.setAmount("");
+ final BarcodeData data = sendListener.popBarcodeData();
+ if (data != null) {
+ if (data.amount != null) {
+ etAmount.setAmount(data.amount);
+ }
+ }
+ etAmount.setBaseCurrency(btcSymbol);
+ callXmrTo();
+ }
+
+ long getTotalFunds() {
+ return sendListener.getActivityCallback().getTotalFunds();
+ }
+
+ private QueryOrderParameters orderParameters = null;
+
+ private void processOrderParms(final QueryOrderParameters orderParameters) {
+ this.orderParameters = orderParameters;
+ getView().post(() -> {
+ final double price = orderParameters.getPrice();
+ etAmount.setExchangeRate(1 / price);
+ maxBtc = price * orderParameters.getUpperLimit();
+ minBtc = price * orderParameters.getLowerLimit();
+ Timber.d("minBtc=%f / maxBtc=%f", minBtc, maxBtc);
+ NumberFormat df = NumberFormat.getInstance(Locale.US);
+ df.setMaximumFractionDigits(6);
+ String min = df.format(minBtc);
+ String max = df.format(maxBtc);
+ String rate = df.format(price);
+ final TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData();
+ Spanned xmrParmText = Html.fromHtml(getString(R.string.info_send_xmrto_parms,
+ min, max, rate, txDataBtc.getBtcSymbol()));
+ tvXmrToParms.setText(xmrParmText);
+
+ final long funds = getTotalFunds();
+ double availableXmr = 1.0 * funds / Helper.ONE_XMR;
+
+ String availBtcString;
+ String availXmrString;
+ if (!sendListener.getActivityCallback().isStreetMode()) {
+ availBtcString = df.format(availableXmr * price);
+ availXmrString = df.format(availableXmr);
+ } else {
+ availBtcString = getString(R.string.unknown_amount);
+ availXmrString = availBtcString;
+ }
+ tvFunds.setText(getString(R.string.send_available_btc,
+ availXmrString,
+ availBtcString,
+ ((TxDataBtc) sendListener.getTxData()).getBtcSymbol()));
+ llXmrToParms.setVisibility(View.VISIBLE);
+ evParams.hideProgress();
+ });
+ }
+
+ private void processOrderParmsError(final Exception ex) {
+ etAmount.setExchangeRate(0);
+ orderParameters = null;
+ maxBtc = 0;
+ minBtc = 0;
+ Timber.e(ex);
+ getView().post(() -> {
+ if (ex instanceof ShiftException) {
+ ShiftException xmrEx = (ShiftException) ex;
+ ShiftError xmrErr = xmrEx.getError();
+ if (xmrErr != null) {
+ if (xmrErr.isRetryable()) {
+ evParams.showMessage(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_retry));
+ evParams.setOnClickListener(v -> {
+ evParams.setOnClickListener(null);
+ callXmrTo();
+ });
+ } else {
+ evParams.showMessage(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ evParams.showMessage(getString(R.string.label_generic_xmrto_error),
+ getString(R.string.text_generic_xmrto_error, xmrEx.getCode()),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ evParams.showMessage(getString(R.string.label_generic_xmrto_error),
+ ex.getLocalizedMessage(),
+ getString(R.string.text_noretry));
+ }
+ });
+ }
+
+ private void callXmrTo() {
+ evParams.showProgress(getString(R.string.label_send_progress_queryparms));
+ getXmrToApi().queryOrderParameters(new ShiftCallback() {
+ @Override
+ public void onSuccess(final QueryOrderParameters orderParameters) {
+ processOrderParms(orderParameters);
+ }
+
+ @Override
+ public void onError(final Exception e) {
+ processOrderParmsError(e);
+ }
+ });
+ }
+
+ private SideShiftApi xmrToApi = null;
+
+ private SideShiftApi getXmrToApi() {
+ if (xmrToApi == null) {
+ synchronized (this) {
+ if (xmrToApi == null) {
+ xmrToApi = new SideShiftApiImpl(ServiceHelper.getXmrToBaseUrl());
+ }
+ }
+ }
+ return xmrToApi;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcConfirmWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcConfirmWizardFragment.java
new file mode 100644
index 0000000..66a66c2
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcConfirmWizardFragment.java
@@ -0,0 +1,551 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.data.TxDataBtc;
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.service.shift.ShiftCallback;
+import com.m2049r.xmrwallet.service.shift.ShiftError;
+import com.m2049r.xmrwallet.service.shift.ShiftException;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.CreateOrder;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.RequestQuote;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi;
+import com.m2049r.xmrwallet.service.shift.sideshift.network.SideShiftApiImpl;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ServiceHelper;
+import com.m2049r.xmrwallet.widget.SendProgressView;
+
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import timber.log.Timber;
+
+public class SendBtcConfirmWizardFragment extends SendWizardFragment implements SendConfirm {
+ public static SendBtcConfirmWizardFragment newInstance(SendConfirmWizardFragment.Listener listener) {
+ SendBtcConfirmWizardFragment instance = new SendBtcConfirmWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ SendConfirmWizardFragment.Listener sendListener;
+
+ public void setSendListener(SendConfirmWizardFragment.Listener listener) {
+ this.sendListener = listener;
+ }
+
+ private View llStageA;
+ private SendProgressView evStageA;
+ private View llStageB;
+ private SendProgressView evStageB;
+ private View llStageC;
+ private SendProgressView evStageC;
+ private TextView tvTxBtcAmount;
+ private TextView tvTxBtcRate;
+ private TextView tvTxBtcAddress;
+ private TextView tvTxBtcAddressLabel;
+ private TextView tvTxXmrToKey;
+ private TextView tvTxFee;
+ private TextView tvTxTotal;
+ private View llConfirmSend;
+ private Button bSend;
+ private View pbProgressSend;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Timber.d("onCreateView(%s)", (String.valueOf(savedInstanceState)));
+
+ View view = inflater.inflate(
+ R.layout.fragment_send_btc_confirm, container, false);
+
+ tvTxBtcAddress = view.findViewById(R.id.tvTxBtcAddress);
+ tvTxBtcAddressLabel = view.findViewById(R.id.tvTxBtcAddressLabel);
+ tvTxBtcAmount = view.findViewById(R.id.tvTxBtcAmount);
+ tvTxBtcRate = view.findViewById(R.id.tvTxBtcRate);
+ tvTxXmrToKey = view.findViewById(R.id.tvTxXmrToKey);
+
+ tvTxFee = view.findViewById(R.id.tvTxFee);
+ tvTxTotal = view.findViewById(R.id.tvTxTotal);
+
+ llStageA = view.findViewById(R.id.llStageA);
+ evStageA = view.findViewById(R.id.evStageA);
+ llStageB = view.findViewById(R.id.llStageB);
+ evStageB = view.findViewById(R.id.evStageB);
+ llStageC = view.findViewById(R.id.llStageC);
+ evStageC = view.findViewById(R.id.evStageC);
+
+ tvTxXmrToKey.setOnClickListener(v -> {
+ Helper.clipBoardCopy(getActivity(), getString(R.string.label_copy_xmrtokey), tvTxXmrToKey.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_xmrtokey), Toast.LENGTH_SHORT).show();
+ });
+
+ llConfirmSend = view.findViewById(R.id.llConfirmSend);
+ pbProgressSend = view.findViewById(R.id.pbProgressSend);
+
+ bSend = view.findViewById(R.id.bSend);
+ bSend.setEnabled(false);
+
+ bSend.setOnClickListener(v -> {
+ Timber.d("bSend.setOnClickListener");
+ bSend.setEnabled(false);
+ preSend();
+ });
+
+ return view;
+ }
+
+ int inProgress = 0;
+ final static int STAGE_X = 0;
+ final static int STAGE_A = 1;
+ final static int STAGE_B = 2;
+ final static int STAGE_C = 3;
+
+ private void showProgress(int stage, String progressText) {
+ Timber.d("showProgress(%d)", stage);
+ inProgress = stage;
+ switch (stage) {
+ case STAGE_A:
+ evStageA.showProgress(progressText);
+ break;
+ case STAGE_B:
+ evStageB.showProgress(progressText);
+ break;
+ case STAGE_C:
+ evStageC.showProgress(progressText);
+ break;
+ default:
+ throw new IllegalStateException("unknown stage " + stage);
+ }
+ }
+
+ public void hideProgress() {
+ Timber.d("hideProgress(%d)", inProgress);
+ switch (inProgress) {
+ case STAGE_A:
+ evStageA.hideProgress();
+ llStageA.setVisibility(View.VISIBLE);
+ break;
+ case STAGE_B:
+ evStageB.hideProgress();
+ llStageB.setVisibility(View.VISIBLE);
+ break;
+ case STAGE_C:
+ evStageC.hideProgress();
+ llStageC.setVisibility(View.VISIBLE);
+ break;
+ default:
+ throw new IllegalStateException("unknown stage " + inProgress);
+ }
+ inProgress = STAGE_X;
+ }
+
+ public void showStageError(String code, String message, String solution) {
+ switch (inProgress) {
+ case STAGE_A:
+ evStageA.showMessage(code, message, solution);
+ break;
+ case STAGE_B:
+ evStageB.showMessage(code, message, solution);
+ break;
+ case STAGE_C:
+ evStageC.showMessage(code, message, solution);
+ break;
+ default:
+ throw new IllegalStateException("unknown stage");
+ }
+ inProgress = STAGE_X;
+ }
+
+ PendingTransaction pendingTransaction = null;
+
+ void send() {
+ Timber.d("SEND @%d", sendCountdown);
+ if (sendCountdown <= 0) {
+ Timber.i("User waited too long in password dialog.");
+ Toast.makeText(getContext(), getString(R.string.send_xmrto_timeout), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ sendListener.getTxData().getUserNotes().setXmrtoOrder(xmrtoOrder); // note the transaction in the TX notes
+ ((TxDataBtc) sendListener.getTxData()).setXmrtoOrderId(xmrtoOrder.getOrderId()); // remember the order id for later
+ // TODO make method in TxDataBtc to set both of the above in one go
+ sendListener.commitTransaction();
+ getActivity().runOnUiThread(() -> pbProgressSend.setVisibility(View.VISIBLE));
+ }
+
+ @Override
+ public void sendFailed(String error) {
+ pbProgressSend.setVisibility(View.INVISIBLE);
+ Toast.makeText(getContext(), getString(R.string.status_transaction_failed, error), Toast.LENGTH_LONG).show();
+ }
+
+ @Override
+ // callback from wallet when PendingTransaction created (started by prepareSend() here
+ public void transactionCreated(final String txTag, final PendingTransaction pendingTransaction) {
+ if (isResumed
+ && (inProgress == STAGE_C)
+ && (xmrtoOrder != null)
+ && (xmrtoOrder.getOrderId().equals(txTag))) {
+ this.pendingTransaction = pendingTransaction;
+ getView().post(() -> {
+ hideProgress();
+ tvTxFee.setText(Wallet.getDisplayAmount(pendingTransaction.getFee()));
+ tvTxTotal.setText(Wallet.getDisplayAmount(
+ pendingTransaction.getFee() + pendingTransaction.getAmount()));
+ updateSendButton();
+ });
+ } else {
+ this.pendingTransaction = null;
+ sendListener.disposeTransaction();
+ }
+ }
+
+ @Override
+ public void createTransactionFailed(String errorText) {
+ Timber.e("CREATE TX FAILED");
+ if (pendingTransaction != null) {
+ throw new IllegalStateException("pendingTransaction is not null");
+ }
+ showStageError(getString(R.string.send_create_tx_error_title),
+ errorText,
+ getString(R.string.text_noretry_monero));
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ return true;
+ }
+
+ private boolean isResumed = false;
+
+ @Override
+ public void onPauseFragment() {
+ isResumed = false;
+ stopSendTimer();
+ sendListener.disposeTransaction();
+ pendingTransaction = null;
+ inProgress = STAGE_X;
+ updateSendButton();
+ super.onPauseFragment();
+ }
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ if (sendListener.getMode() != SendFragment.Mode.BTC) {
+ throw new IllegalStateException("Mode is not BTC!");
+ }
+ if (!((TxDataBtc) sendListener.getTxData()).getBtcSymbol().toLowerCase().equals(ServiceHelper.ASSET))
+ throw new IllegalStateException("Asset Symbol is wrong!");
+ Helper.hideKeyboard(getActivity());
+ llStageA.setVisibility(View.INVISIBLE);
+ evStageA.hideProgress();
+ llStageB.setVisibility(View.INVISIBLE);
+ evStageB.hideProgress();
+ llStageC.setVisibility(View.INVISIBLE);
+ evStageC.hideProgress();
+ isResumed = true;
+ if ((pendingTransaction == null) && (inProgress == STAGE_X)) {
+ stageA();
+ } // otherwise just sit there blank
+ // TODO: don't sit there blank - can this happen? should we just die?
+ }
+
+ private int sendCountdown = 0;
+ private static final int XMRTO_COUNTDOWN_STEP = 1; // 1 second
+
+ Runnable updateRunnable = null;
+
+ void startSendTimer(int timeout) {
+ Timber.d("startSendTimer()");
+ sendCountdown = timeout;
+ updateRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isAdded())
+ return;
+ Timber.d("updateTimer()");
+ if (sendCountdown <= 0) {
+ bSend.setEnabled(false);
+ sendCountdown = 0;
+ Toast.makeText(getContext(), getString(R.string.send_xmrto_timeout), Toast.LENGTH_SHORT).show();
+ }
+ int minutes = sendCountdown / 60;
+ int seconds = sendCountdown % 60;
+ String t = String.format("%d:%02d", minutes, seconds);
+ bSend.setText(getString(R.string.send_send_timed_label, t));
+ if (sendCountdown > 0) {
+ sendCountdown -= XMRTO_COUNTDOWN_STEP;
+ getView().postDelayed(this, XMRTO_COUNTDOWN_STEP * 1000);
+ }
+ }
+ };
+ getView().post(updateRunnable);
+ }
+
+ void stopSendTimer() {
+ getView().removeCallbacks(updateRunnable);
+ }
+
+ void updateSendButton() {
+ Timber.d("updateSendButton()");
+ if (pendingTransaction != null) {
+ llConfirmSend.setVisibility(View.VISIBLE);
+ bSend.setEnabled(sendCountdown > 0);
+ } else {
+ llConfirmSend.setVisibility(View.GONE);
+ bSend.setEnabled(false);
+ }
+ }
+
+ public void preSend() {
+ Helper.promptPassword(getContext(), getActivityCallback().getWalletName(), false, new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName, String password, boolean fingerprintUsed) {
+ send();
+ }
+
+ public void fail(String walletName) {
+ getActivity().runOnUiThread(() -> {
+ bSend.setEnabled(sendCountdown > 0); // allow to try again
+ });
+ }
+ });
+ }
+
+ // creates a pending transaction and calls us back with transactionCreated()
+ // or createTransactionFailed()
+ void prepareSend() {
+ if (!isResumed) return;
+ if ((xmrtoOrder == null)) {
+ throw new IllegalStateException("xmrtoOrder is null");
+ }
+ showProgress(3, getString(R.string.label_send_progress_create_tx));
+ final TxData txData = sendListener.getTxData();
+ txData.setDestinationAddress(xmrtoOrder.getXmrAddress());
+ txData.setAmount(xmrtoOrder.getXmrAmount());
+ getActivityCallback().onPrepareSend(xmrtoOrder.getOrderId(), txData);
+ }
+
+ SendFragment.Listener getActivityCallback() {
+ return sendListener.getActivityCallback();
+ }
+
+ private RequestQuote xmrtoQuote = null;
+
+ private void processStageA(final RequestQuote requestQuote) {
+ Timber.d("processCreateOrder %s", requestQuote.getId());
+ TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData();
+ // verify the BTC amount is correct
+ if (requestQuote.getBtcAmount() != txDataBtc.getBtcAmount()) {
+ Timber.d("Failed to get quote");
+ getView().post(() -> showStageError(ShiftError.Error.SERVICE.toString(),
+ getString(R.string.shift_noquote),
+ getString(R.string.shift_checkamount)));
+ return; // just stop for now
+ }
+ xmrtoQuote = requestQuote;
+ txDataBtc.setAmount(xmrtoQuote.getXmrAmount());
+ getView().post(() -> {
+ // show data from the actual quote as that is what is used to
+ NumberFormat df = NumberFormat.getInstance(Locale.US);
+ df.setMaximumFractionDigits(12);
+ final String btcAmount = df.format(xmrtoQuote.getBtcAmount());
+ final String xmrAmountTotal = df.format(xmrtoQuote.getXmrAmount());
+ tvTxBtcAmount.setText(getString(R.string.text_send_btc_amount,
+ btcAmount, xmrAmountTotal, txDataBtc.getBtcSymbol()));
+ final String xmrPriceBtc = df.format(xmrtoQuote.getPrice());
+ tvTxBtcRate.setText(getString(R.string.text_send_btc_rate, xmrPriceBtc, txDataBtc.getBtcSymbol()));
+ hideProgress();
+ });
+ stageB(requestQuote.getId());
+ }
+
+ private void processStageAError(final Exception ex) {
+ Timber.e("processStageAError %s", ex.getLocalizedMessage());
+ getView().post(() -> {
+ if (ex instanceof ShiftException) {
+ ShiftException xmrEx = (ShiftException) ex;
+ ShiftError xmrErr = xmrEx.getError();
+ if (xmrErr != null) {
+ if (xmrErr.isRetryable()) {
+ showStageError(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_retry));
+ evStageA.setOnClickListener(v -> {
+ evStageA.setOnClickListener(null);
+ stageA();
+ });
+ } else {
+ showStageError(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ showStageError(getString(R.string.label_generic_xmrto_error),
+ getString(R.string.text_generic_xmrto_error, xmrEx.getCode()),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ evStageA.showMessage(getString(R.string.label_generic_xmrto_error),
+ ex.getLocalizedMessage(),
+ getString(R.string.text_noretry));
+ }
+ });
+ }
+
+ private void stageA() {
+ if (!isResumed) return;
+ Timber.d("Request Quote");
+ xmrtoQuote = null;
+ xmrtoOrder = null;
+ showProgress(1, getString(R.string.label_send_progress_xmrto_create));
+ TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData();
+
+ ShiftCallback callback = new ShiftCallback() {
+ @Override
+ public void onSuccess(RequestQuote requestQuote) {
+ if (!isResumed) return;
+ if (xmrtoQuote != null) {
+ Timber.w("another ongoing request quote request");
+ return;
+ }
+ processStageA(requestQuote);
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ if (!isResumed) return;
+ if (xmrtoQuote != null) {
+ Timber.w("another ongoing request quote request");
+ return;
+ }
+ processStageAError(ex);
+ }
+ };
+
+ getXmrToApi().requestQuote(txDataBtc.getBtcAmount(), callback);
+ }
+
+ private CreateOrder xmrtoOrder = null;
+
+ private void processStageB(final CreateOrder order) {
+ Timber.d("processCreateOrder %s for %s", order.getOrderId(), order.getQuoteId());
+ TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData();
+ // verify amount & destination
+ if ((order.getBtcAmount() != txDataBtc.getBtcAmount())
+ || (!txDataBtc.validateAddress(order.getBtcAddress()))) {
+ throw new IllegalStateException("Order does not fulfill quote!"); // something is terribly wrong - die
+ }
+ xmrtoOrder = order;
+ getView().post(() -> {
+ tvTxXmrToKey.setText(order.getOrderId());
+ tvTxBtcAddress.setText(order.getBtcAddress());
+ tvTxBtcAddressLabel.setText(getString(R.string.label_send_btc_address, txDataBtc.getBtcSymbol()));
+ hideProgress();
+ Timber.d("Expires @ %s", order.getExpiresAt().toString());
+ final int timeout = (int) (order.getExpiresAt().getTime() - order.getCreatedAt().getTime()) / 1000 - 60; // -1 minute buffer
+ startSendTimer(timeout);
+ prepareSend();
+ });
+ }
+
+ private void processStageBError(final Exception ex) {
+ Timber.e("processCreateOrderError %s", ex.getLocalizedMessage());
+ getView().post(() -> {
+ if (ex instanceof ShiftException) {
+ ShiftException xmrEx = (ShiftException) ex;
+ ShiftError xmrErr = xmrEx.getError();
+ if (xmrErr != null) {
+ if (xmrErr.isRetryable()) {
+ showStageError(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_retry));
+ evStageB.setOnClickListener(v -> {
+ evStageB.setOnClickListener(null);
+ stageB(xmrtoOrder.getOrderId());
+ });
+ } else {
+ showStageError(xmrErr.getErrorType().toString(), xmrErr.getErrorMsg(),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ showStageError(getString(R.string.label_generic_xmrto_error),
+ getString(R.string.text_generic_xmrto_error, xmrEx.getCode()),
+ getString(R.string.text_noretry));
+ }
+ } else {
+ evStageB.showMessage(getString(R.string.label_generic_xmrto_error),
+ ex.getLocalizedMessage(),
+ getString(R.string.text_noretry));
+ }
+ });
+ }
+
+ private void stageB(final String quoteId) {
+ Timber.d("createOrder(%s)", quoteId);
+ if (!isResumed) return;
+ final String btcAddress = ((TxDataBtc) sendListener.getTxData()).getBtcAddress();
+ getView().post(() -> {
+ xmrtoOrder = null;
+ showProgress(2, getString(R.string.label_send_progress_xmrto_query));
+ getXmrToApi().createOrder(quoteId, btcAddress, new ShiftCallback() {
+ @Override
+ public void onSuccess(CreateOrder order) {
+ if (!isResumed) return;
+ if (xmrtoQuote == null) return;
+ if (!order.getQuoteId().equals(xmrtoQuote.getId())) {
+ Timber.d("Quote ID does not match");
+ // ignore (we got a response to a stale request)
+ return;
+ }
+ if (xmrtoOrder != null)
+ throw new IllegalStateException("xmrtoOrder must be null here!");
+ processStageB(order);
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ if (!isResumed) return;
+ processStageBError(ex);
+ }
+ });
+ });
+ }
+
+ private SideShiftApi xmrToApi = null;
+
+ private SideShiftApi getXmrToApi() {
+ if (xmrToApi == null) {
+ synchronized (this) {
+ if (xmrToApi == null) {
+ xmrToApi = new SideShiftApiImpl(ServiceHelper.getXmrToBaseUrl());
+ }
+ }
+ }
+ return xmrToApi;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcSuccessWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcSuccessWizardFragment.java
new file mode 100644
index 0000000..41c13db
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcSuccessWizardFragment.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.content.Intent;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.Crypto;
+import com.m2049r.xmrwallet.data.PendingTx;
+import com.m2049r.xmrwallet.data.TxDataBtc;
+import com.m2049r.xmrwallet.service.shift.ShiftCallback;
+import com.m2049r.xmrwallet.service.shift.ShiftException;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.QueryOrderStatus;
+import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi;
+import com.m2049r.xmrwallet.service.shift.sideshift.network.SideShiftApiImpl;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.ServiceHelper;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import timber.log.Timber;
+
+public class SendBtcSuccessWizardFragment extends SendWizardFragment {
+
+ public static SendBtcSuccessWizardFragment newInstance(SendSuccessWizardFragment.Listener listener) {
+ SendBtcSuccessWizardFragment instance = new SendBtcSuccessWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ SendSuccessWizardFragment.Listener sendListener;
+
+ public void setSendListener(SendSuccessWizardFragment.Listener listener) {
+ this.sendListener = listener;
+ }
+
+ ImageButton bCopyTxId;
+ private TextView tvTxId;
+ private TextView tvTxAddress;
+ private TextView tvTxAmount;
+ private TextView tvTxFee;
+ private TextView tvXmrToAmount;
+ private ImageView ivXmrToIcon;
+ private TextView tvXmrToStatus;
+ private ImageView ivXmrToStatus;
+ private ImageView ivXmrToStatusBig;
+ private ProgressBar pbXmrto;
+ private TextView tvTxXmrToKey;
+ private TextView tvXmrToSupport;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Timber.d("onCreateView() %s", (String.valueOf(savedInstanceState)));
+
+ View view = inflater.inflate(
+ R.layout.fragment_send_btc_success, container, false);
+
+ bCopyTxId = view.findViewById(R.id.bCopyTxId);
+ bCopyTxId.setEnabled(false);
+ bCopyTxId.setOnClickListener(v -> {
+ Helper.clipBoardCopy(getActivity(), getString(R.string.label_send_txid), tvTxId.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_txid), Toast.LENGTH_SHORT).show();
+ });
+
+ tvXmrToAmount = view.findViewById(R.id.tvXmrToAmount);
+ ivXmrToIcon = view.findViewById(R.id.ivXmrToIcon);
+ tvXmrToStatus = view.findViewById(R.id.tvXmrToStatus);
+ ivXmrToStatus = view.findViewById(R.id.ivXmrToStatus);
+ ivXmrToStatusBig = view.findViewById(R.id.ivXmrToStatusBig);
+
+ tvTxId = view.findViewById(R.id.tvTxId);
+ tvTxAddress = view.findViewById(R.id.tvTxAddress);
+ tvTxAmount = view.findViewById(R.id.tvTxAmount);
+ tvTxFee = view.findViewById(R.id.tvTxFee);
+
+ pbXmrto = view.findViewById(R.id.pbXmrto);
+ pbXmrto.getIndeterminateDrawable().setColorFilter(0x61000000, android.graphics.PorterDuff.Mode.MULTIPLY);
+
+ tvTxXmrToKey = view.findViewById(R.id.tvTxXmrToKey);
+ tvTxXmrToKey.setOnClickListener(v -> {
+ Helper.clipBoardCopy(getActivity(), getString(R.string.label_copy_xmrtokey), tvTxXmrToKey.getText().toString());
+ Toast.makeText(getActivity(), getString(R.string.message_copy_xmrtokey), Toast.LENGTH_SHORT).show();
+ });
+
+ tvXmrToSupport = view.findViewById(R.id.tvXmrToSupport);
+ tvXmrToSupport.setPaintFlags(tvXmrToSupport.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
+
+ return view;
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ return true;
+ }
+
+ private boolean isResumed = false;
+
+ @Override
+ public void onPauseFragment() {
+ isResumed = false;
+ super.onPauseFragment();
+ }
+
+ TxDataBtc btcData = null;
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ Helper.hideKeyboard(getActivity());
+ isResumed = true;
+
+ btcData = (TxDataBtc) sendListener.getTxData();
+ tvTxAddress.setText(btcData.getDestinationAddress());
+
+ final PendingTx committedTx = sendListener.getCommittedTx();
+ if (committedTx != null) {
+ tvTxId.setText(committedTx.txId);
+ bCopyTxId.setEnabled(true);
+ tvTxAmount.setText(getString(R.string.send_amount, Helper.getDisplayAmount(committedTx.amount)));
+ tvTxFee.setText(getString(R.string.send_fee, Helper.getDisplayAmount(committedTx.fee)));
+ if (btcData != null) {
+ NumberFormat df = NumberFormat.getInstance(Locale.US);
+ df.setMaximumFractionDigits(12);
+ String btcAmount = df.format(btcData.getBtcAmount());
+ tvXmrToAmount.setText(getString(R.string.info_send_xmrto_success_btc, btcAmount, btcData.getBtcSymbol()));
+ //TODO btcData.getBtcAddress();
+ tvTxXmrToKey.setText(btcData.getXmrtoOrderId());
+ final Crypto crypto = Crypto.withSymbol(btcData.getBtcSymbol());
+ ivXmrToIcon.setImageResource(crypto.getIconEnabledId());
+ tvXmrToSupport.setOnClickListener(v -> {
+ Uri orderUri = getXmrToApi().getQueryOrderUri(btcData.getXmrtoOrderId());
+ Intent intent = new Intent(Intent.ACTION_VIEW, orderUri);
+ startActivity(intent);
+ });
+ queryOrder();
+ } else {
+ throw new IllegalStateException("btcData is null");
+ }
+ }
+ sendListener.enableDone();
+ }
+
+ private void processQueryOrder(final QueryOrderStatus status) {
+ Timber.d("processQueryOrder %s for %s", status.getState().toString(), status.getOrderId());
+ if (!btcData.getXmrtoOrderId().equals(status.getOrderId()))
+ throw new IllegalStateException("UUIDs do not match!");
+ if (isResumed && (getView() != null))
+ getView().post(() -> {
+ showXmrToStatus(status);
+ if (!status.isTerminal()) {
+ getView().postDelayed(this::queryOrder, SideShiftApi.QUERY_INTERVAL);
+ }
+ });
+ }
+
+ private void queryOrder() {
+ Timber.d("queryOrder(%s)", btcData.getXmrtoOrderId());
+ if (!isResumed) return;
+ getXmrToApi().queryOrderStatus(btcData.getXmrtoOrderId(), new ShiftCallback() {
+ @Override
+ public void onSuccess(QueryOrderStatus status) {
+ if (!isAdded()) return;
+ processQueryOrder(status);
+ }
+
+ @Override
+ public void onError(final Exception ex) {
+ if (!isResumed) return;
+ Timber.w(ex);
+ getActivity().runOnUiThread(() -> {
+ if (ex instanceof ShiftException) {
+ Toast.makeText(getActivity(), ((ShiftException) ex).getError().getErrorMsg(), Toast.LENGTH_LONG).show();
+ } else {
+ Toast.makeText(getActivity(), ex.getLocalizedMessage(), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+ });
+ }
+
+ void showXmrToStatus(final QueryOrderStatus status) {
+ int statusResource = 0;
+ if (status.isError()) {
+ tvXmrToStatus.setText(getString(R.string.info_send_xmrto_error, status.toString()));
+ statusResource = R.drawable.ic_error_red_24dp;
+ pbXmrto.getIndeterminateDrawable().setColorFilter(
+ ThemeHelper.getThemedColor(getContext(), android.R.attr.colorError),
+ android.graphics.PorterDuff.Mode.MULTIPLY);
+ } else if (status.isSent() || status.isPaid()) {
+ tvXmrToStatus.setText(getString(R.string.info_send_xmrto_sent, btcData.getBtcSymbol()));
+ statusResource = R.drawable.ic_success;
+ pbXmrto.getIndeterminateDrawable().setColorFilter(
+ ThemeHelper.getThemedColor(getContext(), R.attr.positiveColor),
+ android.graphics.PorterDuff.Mode.MULTIPLY);
+ } else if (status.isWaiting()) {
+ tvXmrToStatus.setText(getString(R.string.info_send_xmrto_unpaid));
+ statusResource = R.drawable.ic_pending;
+ pbXmrto.getIndeterminateDrawable().setColorFilter(
+ ThemeHelper.getThemedColor(getContext(), R.attr.neutralColor),
+ android.graphics.PorterDuff.Mode.MULTIPLY);
+ } else if (status.isPending()) {
+ tvXmrToStatus.setText(getString(R.string.info_send_xmrto_paid));
+ statusResource = R.drawable.ic_pending;
+ pbXmrto.getIndeterminateDrawable().setColorFilter(
+ ThemeHelper.getThemedColor(getContext(), R.attr.neutralColor),
+ android.graphics.PorterDuff.Mode.MULTIPLY);
+ } else {
+ throw new IllegalStateException("status is broken: " + status.toString());
+ }
+ ivXmrToStatus.setImageResource(statusResource);
+ if (status.isTerminal()) {
+ pbXmrto.setVisibility(View.INVISIBLE);
+ ivXmrToIcon.setVisibility(View.GONE);
+ ivXmrToStatus.setVisibility(View.GONE);
+ ivXmrToStatusBig.setImageResource(statusResource);
+ ivXmrToStatusBig.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private SideShiftApi xmrToApi = null;
+
+ private SideShiftApi getXmrToApi() {
+ if (xmrToApi == null) {
+ synchronized (this) {
+ if (xmrToApi == null) {
+ xmrToApi = new SideShiftApiImpl(ServiceHelper.getXmrToBaseUrl());
+ }
+ }
+ }
+ return xmrToApi;
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirm.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirm.java
new file mode 100644
index 0000000..69e97c8
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirm.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import com.m2049r.xmrwallet.model.PendingTransaction;
+
+interface SendConfirm {
+ void sendFailed(String errorText);
+
+ void createTransactionFailed(String errorText);
+
+ void transactionCreated(String txTag, PendingTransaction pendingTransaction);
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirmWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirmWizardFragment.java
new file mode 100644
index 0000000..f4d334e
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendConfirmWizardFragment.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.textfield.TextInputLayout;
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.data.UserNotes;
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.model.Wallet;
+import com.m2049r.xmrwallet.util.Helper;
+
+import timber.log.Timber;
+
+public class SendConfirmWizardFragment extends SendWizardFragment implements SendConfirm {
+
+ public static SendConfirmWizardFragment newInstance(Listener listener) {
+ SendConfirmWizardFragment instance = new SendConfirmWizardFragment();
+ instance.setSendListener(listener);
+ return instance;
+ }
+
+ Listener sendListener;
+
+ public SendConfirmWizardFragment setSendListener(Listener listener) {
+ this.sendListener = listener;
+ return this;
+ }
+
+ interface Listener {
+ SendFragment.Listener getActivityCallback();
+
+ TxData getTxData();
+
+ void commitTransaction();
+
+ void disposeTransaction();
+
+ SendFragment.Mode getMode();
+ }
+
+ private TextView tvTxAddress;
+ private TextView tvTxNotes;
+ private TextView tvTxAmount;
+ private TextView tvTxFee;
+ private TextView tvTxTotal;
+ private View llProgress;
+ private View bSend;
+ private View llConfirmSend;
+ private View pbProgressSend;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ Timber.d("onCreateView() %s", (String.valueOf(savedInstanceState)));
+
+ View view = inflater.inflate(
+ R.layout.fragment_send_confirm, container, false);
+
+ tvTxAddress = view.findViewById(R.id.tvTxAddress);
+ tvTxNotes = view.findViewById(R.id.tvTxNotes);
+ tvTxAmount = view.findViewById(R.id.tvTxAmount);
+ tvTxFee = view.findViewById(R.id.tvTxFee);
+ tvTxTotal = view.findViewById(R.id.tvTxTotal);
+
+ llProgress = view.findViewById(R.id.llProgress);
+ pbProgressSend = view.findViewById(R.id.pbProgressSend);
+ llConfirmSend = view.findViewById(R.id.llConfirmSend);
+
+ bSend = view.findViewById(R.id.bSend);
+ bSend.setEnabled(false);
+ bSend.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Timber.d("bSend.setOnClickListener");
+ bSend.setEnabled(false);
+ preSend();
+ }
+ });
+ return view;
+ }
+
+ boolean inProgress = false;
+
+ public void hideProgress() {
+ llProgress.setVisibility(View.INVISIBLE);
+ inProgress = false;
+ }
+
+ public void showProgress() {
+ llProgress.setVisibility(View.VISIBLE);
+ inProgress = true;
+ }
+
+ PendingTransaction pendingTransaction = null;
+
+ @Override
+ // callback from wallet when PendingTransaction created
+ public void transactionCreated(String txTag, PendingTransaction pendingTransaction) {
+ // ignore txTag - the app flow ensures this is the correct tx
+ // TODO: use the txTag
+ hideProgress();
+ if (isResumed) {
+ this.pendingTransaction = pendingTransaction;
+ refreshTransactionDetails();
+ } else {
+ sendListener.disposeTransaction();
+ }
+ }
+
+ void send() {
+ sendListener.commitTransaction();
+ getActivity().runOnUiThread(() -> pbProgressSend.setVisibility(View.VISIBLE));
+ }
+
+ @Override
+ public void sendFailed(String errorText) {
+ pbProgressSend.setVisibility(View.INVISIBLE);
+ showAlert(getString(R.string.send_create_tx_error_title), errorText);
+ }
+
+ @Override
+ public void createTransactionFailed(String errorText) {
+ hideProgress();
+ showAlert(getString(R.string.send_create_tx_error_title), errorText);
+ }
+
+ private void showAlert(String title, String message) {
+ AlertDialog.Builder builder = new MaterialAlertDialogBuilder(getActivity());
+ builder.setCancelable(true).
+ setTitle(title).
+ setMessage(message).
+ create().
+ show();
+ }
+
+ @Override
+ public boolean onValidateFields() {
+ return true;
+ }
+
+ private boolean isResumed = false;
+
+ @Override
+ public void onPauseFragment() {
+ isResumed = false;
+ pendingTransaction = null;
+ sendListener.disposeTransaction();
+ refreshTransactionDetails();
+ super.onPauseFragment();
+ }
+
+ @Override
+ public void onResumeFragment() {
+ super.onResumeFragment();
+ Timber.d("onResumeFragment()");
+ Helper.hideKeyboard(getActivity());
+ isResumed = true;
+
+ final TxData txData = sendListener.getTxData();
+ tvTxAddress.setText(txData.getDestinationAddress());
+ UserNotes notes = sendListener.getTxData().getUserNotes();
+ if ((notes != null) && (!notes.note.isEmpty())) {
+ tvTxNotes.setText(notes.note);
+ } else {
+ tvTxNotes.setText("-");
+ }
+ refreshTransactionDetails();
+ if ((pendingTransaction == null) && (!inProgress)) {
+ showProgress();
+ prepareSend(txData);
+ }
+ }
+
+ void refreshTransactionDetails() {
+ Timber.d("refreshTransactionDetails()");
+ if (pendingTransaction != null) {
+ llConfirmSend.setVisibility(View.VISIBLE);
+ bSend.setEnabled(true);
+ tvTxFee.setText(Wallet.getDisplayAmount(pendingTransaction.getFee()));
+ if (getActivityCallback().isStreetMode()
+ && (sendListener.getTxData().getAmount() == Wallet.SWEEP_ALL)) {
+ tvTxAmount.setText(getString(R.string.street_sweep_amount));
+ tvTxTotal.setText(getString(R.string.street_sweep_amount));
+ } else {
+ tvTxAmount.setText(Wallet.getDisplayAmount(pendingTransaction.getAmount()));
+ tvTxTotal.setText(Wallet.getDisplayAmount(
+ pendingTransaction.getFee() + pendingTransaction.getAmount()));
+ }
+ } else {
+ llConfirmSend.setVisibility(View.GONE);
+ bSend.setEnabled(false);
+ }
+ }
+
+ public void preSend() {
+ Helper.promptPassword(getContext(), getActivityCallback().getWalletName(), false, new Helper.PasswordAction() {
+ @Override
+ public void act(String walletName, String password, boolean fingerprintUsed) {
+ send();
+ }
+
+ public void fail(String walletName) {
+ getActivity().runOnUiThread(() -> {
+ bSend.setEnabled(true); // allow to try again
+ });
+ }
+ });
+ }
+
+ // creates a pending transaction and calls us back with transactionCreated()
+ // or createTransactionFailed()
+ void prepareSend(TxData txData) {
+ getActivityCallback().onPrepareSend(null, txData);
+ }
+
+ SendFragment.Listener getActivityCallback() {
+ return sendListener.getActivityCallback();
+ }
+}
diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendFragment.java
new file mode 100644
index 0000000..ce82795
--- /dev/null
+++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendFragment.java
@@ -0,0 +1,558 @@
+/*
+ * Copyright (c) 2017 m2049r
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.m2049r.xmrwallet.fragment.send;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.text.InputType;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+
+import com.google.android.material.transition.MaterialContainerTransform;
+import com.m2049r.xmrwallet.OnBackPressedListener;
+import com.m2049r.xmrwallet.OnUriScannedListener;
+import com.m2049r.xmrwallet.R;
+import com.m2049r.xmrwallet.WalletActivity;
+import com.m2049r.xmrwallet.data.BarcodeData;
+import com.m2049r.xmrwallet.data.PendingTx;
+import com.m2049r.xmrwallet.data.TxData;
+import com.m2049r.xmrwallet.data.TxDataBtc;
+import com.m2049r.xmrwallet.data.UserNotes;
+import com.m2049r.xmrwallet.layout.SpendViewPager;
+import com.m2049r.xmrwallet.model.PendingTransaction;
+import com.m2049r.xmrwallet.util.Helper;
+import com.m2049r.xmrwallet.util.Notice;
+import com.m2049r.xmrwallet.util.ThemeHelper;
+import com.m2049r.xmrwallet.widget.DotBar;
+import com.m2049r.xmrwallet.widget.Toolbar;
+
+import java.lang.ref.WeakReference;
+
+import timber.log.Timber;
+
+public class SendFragment extends Fragment
+ implements SendAddressWizardFragment.Listener,
+ SendAmountWizardFragment.Listener,
+ SendConfirmWizardFragment.Listener,
+ SendSuccessWizardFragment.Listener,
+ OnBackPressedListener, OnUriScannedListener {
+
+ final static public int MIXIN = 0;
+
+ private Listener activityCallback;
+
+ public interface Listener {
+ SharedPreferences getPrefs();
+
+ long getTotalFunds();
+
+ boolean isStreetMode();
+
+ void onPrepareSend(String tag, TxData data);
+
+ String getWalletName();
+
+ void onSend(UserNotes notes);
+
+ void onDisposeRequest();
+
+ void onFragmentDone();
+
+ void setToolbarButton(int type);
+
+ void setTitle(String title);
+
+ void setSubtitle(String subtitle);
+
+ void setOnUriScannedListener(OnUriScannedListener onUriScannedListener);
+ }
+
+ private View llNavBar;
+ private DotBar dotBar;
+ private Button bPrev;
+ private Button bNext;
+
+ private Button bDone;
+
+ static private final int MAX_FALLBACK = Integer.MAX_VALUE;
+
+ public static SendFragment newInstance(String uri) {
+ SendFragment f = new SendFragment();
+ Bundle args = new Bundle();
+ args.putString(WalletActivity.REQUEST_URI, uri);
+ f.setArguments(args);
+ return f;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+
+ final View view = inflater.inflate(R.layout.fragment_send, container, false);
+
+ llNavBar = view.findViewById(R.id.llNavBar);
+ bDone = view.findViewById(R.id.bDone);
+
+ dotBar = view.findViewById(R.id.dotBar);
+ bPrev = view.findViewById(R.id.bPrev);
+ bNext = view.findViewById(R.id.bNext);
+
+ ViewGroup llNotice = view.findViewById(R.id.llNotice);
+ Notice.showAll(llNotice, ".*_send");
+
+ spendViewPager = view.findViewById(R.id.pager);
+ pagerAdapter = new SpendPagerAdapter(getChildFragmentManager());
+ spendViewPager.setOffscreenPageLimit(pagerAdapter.getCount()); // load & keep all pages in cache
+ spendViewPager.setAdapter(pagerAdapter);
+
+ spendViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
+ private int fallbackPosition = MAX_FALLBACK;
+ private int currentPosition = 0;
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ }
+
+ @Override
+ public void onPageSelected(int newPosition) {
+ Timber.d("onPageSelected=%d/%d", newPosition, fallbackPosition);
+ if (fallbackPosition < newPosition) {
+ spendViewPager.setCurrentItem(fallbackPosition);
+ } else {
+ pagerAdapter.getFragment(currentPosition).onPauseFragment();
+ pagerAdapter.getFragment(newPosition).onResumeFragment();
+ updatePosition(newPosition);
+ currentPosition = newPosition;
+ fallbackPosition = MAX_FALLBACK;
+ }
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ if (state == ViewPager.SCROLL_STATE_DRAGGING) {
+ if (!spendViewPager.validateFields(spendViewPager.getCurrentItem())) {
+ fallbackPosition = spendViewPager.getCurrentItem();
+ } else {
+ fallbackPosition = spendViewPager.getCurrentItem() + 1;
+ }
+ }
+ }
+ });
+
+ bPrev.setOnClickListener(v -> spendViewPager.previous());
+
+ bNext.setOnClickListener(v -> spendViewPager.next());
+
+ bDone.setOnClickListener(v -> {
+ Timber.d("bDone.onClick");
+ activityCallback.onFragmentDone();
+ });
+
+ updatePosition(0);
+
+ final EditText etDummy = view.findViewById(R.id.etDummy);
+ etDummy.setRawInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ etDummy.requestFocus();
+ Helper.hideKeyboard(getActivity());
+
+ Bundle args = getArguments();
+ if (args != null) {
+ String uri = args.getString(WalletActivity.REQUEST_URI);
+ Timber.d("URI: %s", uri);
+ if (uri != null) {
+ barcodeData = BarcodeData.fromString(uri);
+ Timber.d("barcodeData: %s", barcodeData != null ? barcodeData.toString() : "null");
+ }
+ }
+
+ return view;
+ }
+
+ void updatePosition(int position) {
+ dotBar.setActiveDot(position);
+ CharSequence nextLabel = pagerAdapter.getPageTitle(position + 1);
+ bNext.setText(nextLabel);
+ if (nextLabel != null) {
+ bNext.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_navigate_next, 0);
+ } else {
+ bNext.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+ CharSequence prevLabel = pagerAdapter.getPageTitle(position - 1);
+ bPrev.setText(prevLabel);
+ if (prevLabel != null) {
+ bPrev.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_navigate_prev, 0, 0, 0);
+ } else {
+ bPrev.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Timber.d("onResume");
+ activityCallback.setSubtitle(getString(R.string.send_title));
+ if (spendViewPager.getCurrentItem() == SpendPagerAdapter.POS_SUCCESS) {
+ activityCallback.setToolbarButton(Toolbar.BUTTON_NONE);
+ } else {
+ activityCallback.setToolbarButton(Toolbar.BUTTON_CANCEL);
+ }
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ Timber.d("onAttach %s", context);
+ super.onAttach(context);
+ if (context instanceof Listener) {
+ activityCallback = (Listener) context;
+ activityCallback.setOnUriScannedListener(this);
+ } else {
+ throw new ClassCastException(context.toString()
+ + " must implement Listener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ activityCallback.setOnUriScannedListener(null);
+ super.onDetach();
+ }
+
+ private SpendViewPager spendViewPager;
+ private SpendPagerAdapter pagerAdapter;
+
+ @Override
+ public boolean onBackPressed() {
+ if (isComitted()) return true; // no going back
+ if (spendViewPager.getCurrentItem() == 0) {
+ return false;
+ } else {
+ spendViewPager.previous();
+ return true;
+ }
+ }
+
+ @Override
+ public boolean onUriScanned(BarcodeData barcodeData) {
+ if (spendViewPager.getCurrentItem() == SpendPagerAdapter.POS_ADDRESS) {
+ final SendWizardFragment fragment = pagerAdapter.getFragment(SpendPagerAdapter.POS_ADDRESS);
+ if (fragment instanceof SendAddressWizardFragment) {
+ ((SendAddressWizardFragment) fragment).processScannedData(barcodeData);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ enum Mode {
+ XMR, BTC
+ }
+
+ Mode mode = Mode.XMR;
+
+ @Override
+ public void setMode(Mode aMode) {
+ if (mode != aMode) {
+ mode = aMode;
+ switch (aMode) {
+ case XMR:
+ txData = new TxData();
+ break;
+ case BTC:
+ txData = new TxDataBtc();
+ break;
+ default:
+ throw new IllegalArgumentException("Mode " + String.valueOf(aMode) + " unknown!");
+ }
+ getView().post(() -> pagerAdapter.notifyDataSetChanged());
+ Timber.d("New Mode = %s", mode.toString());
+ }
+ }
+
+ @Override
+ public Mode getMode() {
+ return mode;
+ }
+
+ public class SpendPagerAdapter extends FragmentStatePagerAdapter {
+ private static final int POS_ADDRESS = 0;
+ private static final int POS_AMOUNT = 1;
+ private static final int POS_CONFIRM = 2;
+ private static final int POS_SUCCESS = 3;
+ private int numPages = 3;
+
+ SparseArray> myFragments = new SparseArray<>();
+
+ public SpendPagerAdapter(FragmentManager fm) {
+ super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+ }
+
+ public void addSuccess() {
+ numPages++;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getCount() {
+ return numPages;
+ }
+
+ @NonNull
+ @Override
+ public Object instantiateItem(@NonNull ViewGroup container, int position) {
+ Timber.d("instantiateItem %d", position);
+ SendWizardFragment fragment = (SendWizardFragment) super.instantiateItem(container, position);
+ myFragments.put(position, new WeakReference<>(fragment));
+ return fragment;
+ }
+
+ @Override
+ public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
+ Timber.d("destroyItem %d", position);
+ myFragments.remove(position);
+ super.destroyItem(container, position, object);
+ }
+
+ public SendWizardFragment getFragment(int position) {
+ WeakReference