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

+ +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

+ +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 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 ref = myFragments.get(position); + if (ref != null) + return myFragments.get(position).get(); + else + return null; + } + + @NonNull + @Override + public SendWizardFragment getItem(int position) { + Timber.d("getItem(%d) CREATE", position); + Timber.d("Mode=%s", mode.toString()); + if (mode == Mode.XMR) { + switch (position) { + case POS_ADDRESS: + return SendAddressWizardFragment.newInstance(SendFragment.this); + case POS_AMOUNT: + return SendAmountWizardFragment.newInstance(SendFragment.this); + case POS_CONFIRM: + return SendConfirmWizardFragment.newInstance(SendFragment.this); + case POS_SUCCESS: + return SendSuccessWizardFragment.newInstance(SendFragment.this); + default: + throw new IllegalArgumentException("no such send position(" + position + ")"); + } + } else if (mode == Mode.BTC) { + switch (position) { + case POS_ADDRESS: + return SendAddressWizardFragment.newInstance(SendFragment.this); + case POS_AMOUNT: + return SendBtcAmountWizardFragment.newInstance(SendFragment.this); + case POS_CONFIRM: + return SendBtcConfirmWizardFragment.newInstance(SendFragment.this); + case POS_SUCCESS: + return SendBtcSuccessWizardFragment.newInstance(SendFragment.this); + default: + throw new IllegalArgumentException("no such send position(" + position + ")"); + } + } else { + throw new IllegalStateException("Unknown mode!"); + } + } + + @Override + public CharSequence getPageTitle(int position) { + Timber.d("getPageTitle(%d)", position); + if (position >= numPages) return null; + switch (position) { + case POS_ADDRESS: + return getString(R.string.send_address_title); + case POS_AMOUNT: + return getString(R.string.send_amount_title); + case POS_CONFIRM: + return getString(R.string.send_confirm_title); + case POS_SUCCESS: + return getString(R.string.send_success_title); + default: + return null; + } + } + + @Override + public int getItemPosition(@NonNull Object object) { + Timber.d("getItemPosition %s", String.valueOf(object)); + if (object instanceof SendAddressWizardFragment) { + // keep these pages + return POSITION_UNCHANGED; + } else { + return POSITION_NONE; + } + } + } + + @Override + public TxData getTxData() { + return txData; + } + + private TxData txData = new TxData(); + + private BarcodeData barcodeData; + + // Listeners + @Override + public void setBarcodeData(BarcodeData data) { + barcodeData = data; + } + + @Override + public BarcodeData getBarcodeData() { + return barcodeData; + } + + @Override + public BarcodeData popBarcodeData() { + Timber.d("POPPED"); + BarcodeData data = barcodeData; + barcodeData = null; + return data; + } + + boolean isComitted() { + return committedTx != null; + } + + PendingTx committedTx; + + @Override + public PendingTx getCommittedTx() { + return committedTx; + } + + + @Override + public void commitTransaction() { + Timber.d("REALLY SEND"); + disableNavigation(); // committed - disable all navigation + activityCallback.onSend(txData.getUserNotes()); + committedTx = pendingTx; + } + + void disableNavigation() { + spendViewPager.allowSwipe(false); + } + + void enableNavigation() { + spendViewPager.allowSwipe(true); + } + + @Override + public void enableDone() { + llNavBar.setVisibility(View.INVISIBLE); + bDone.setVisibility(View.VISIBLE); + } + + public Listener getActivityCallback() { + return activityCallback; + } + + + // callbacks from send service + + public void onTransactionCreated(final String txTag, final PendingTransaction pendingTransaction) { + final SendConfirm confirm = getSendConfirm(); + if (confirm != null) { + pendingTx = new PendingTx(pendingTransaction); + confirm.transactionCreated(txTag, pendingTransaction); + } else { + // not in confirm fragment => dispose & move on + disposeTransaction(); + } + } + + @Override + public void disposeTransaction() { + pendingTx = null; + activityCallback.onDisposeRequest(); + } + + PendingTx pendingTx; + + public PendingTx getPendingTx() { + return pendingTx; + } + + public void onCreateTransactionFailed(String errorText) { + final SendConfirm confirm = getSendConfirm(); + if (confirm != null) { + confirm.createTransactionFailed(errorText); + } + } + + SendConfirm getSendConfirm() { + final SendWizardFragment fragment = pagerAdapter.getFragment(SpendPagerAdapter.POS_CONFIRM); + if (fragment instanceof SendConfirm) { + return (SendConfirm) fragment; + } else { + return null; + } + } + + public void onTransactionSent(final String txId) { + Timber.d("txid=%s", txId); + pagerAdapter.addSuccess(); + Timber.d("numPages=%d", spendViewPager.getAdapter().getCount()); + activityCallback.setToolbarButton(Toolbar.BUTTON_NONE); + spendViewPager.setCurrentItem(SpendPagerAdapter.POS_SUCCESS); + } + + public void onSendTransactionFailed(final String error) { + Timber.d("error=%s", error); + committedTx = null; + final SendConfirm confirm = getSendConfirm(); + if (confirm != null) { + confirm.sendFailed(getString(R.string.status_transaction_failed, error)); + } + enableNavigation(); + } + + 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); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.send_menu, menu); + super.onCreateOptionsMenu(menu, inflater); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendSuccessWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendSuccessWizardFragment.java new file mode 100644 index 0000000..34f3339 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendSuccessWizardFragment.java @@ -0,0 +1,128 @@ +/* + * 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 android.widget.Toast; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.data.PendingTx; +import com.m2049r.xmrwallet.data.TxData; +import com.m2049r.xmrwallet.model.Wallet; +import com.m2049r.xmrwallet.util.Helper; + +import timber.log.Timber; + +public class SendSuccessWizardFragment extends SendWizardFragment { + + public static SendSuccessWizardFragment newInstance(Listener listener) { + SendSuccessWizardFragment instance = new SendSuccessWizardFragment(); + instance.setSendListener(listener); + return instance; + } + + Listener sendListener; + + public SendSuccessWizardFragment setSendListener(Listener listener) { + this.sendListener = listener; + return this; + } + + interface Listener { + TxData getTxData(); + + PendingTx getCommittedTx(); + + void enableDone(); + + SendFragment.Mode getMode(); + + SendFragment.Listener getActivityCallback(); + } + + ImageButton bCopyTxId; + private TextView tvTxId; + private TextView tvTxAddress; + private TextView tvTxPaymentId; + private TextView tvTxAmount; + private TextView tvTxFee; + + @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_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(); + }); + + tvTxId = view.findViewById(R.id.tvTxId); + tvTxAddress = view.findViewById(R.id.tvTxAddress); + tvTxPaymentId = view.findViewById(R.id.tvTxPaymentId); + tvTxAmount = view.findViewById(R.id.tvTxAmount); + tvTxFee = view.findViewById(R.id.tvTxFee); + + return view; + } + + @Override + public boolean onValidateFields() { + return true; + } + + @Override + public void onPauseFragment() { + super.onPauseFragment(); + } + + @Override + public void onResumeFragment() { + super.onResumeFragment(); + Timber.d("onResumeFragment()"); + Helper.hideKeyboard(getActivity()); + + final TxData txData = sendListener.getTxData(); + tvTxAddress.setText(txData.getDestinationAddress()); + + final PendingTx committedTx = sendListener.getCommittedTx(); + if (committedTx != null) { + tvTxId.setText(committedTx.txId); + bCopyTxId.setEnabled(true); + + if (sendListener.getActivityCallback().isStreetMode() + && (sendListener.getTxData().getAmount() == Wallet.SWEEP_ALL)) { + tvTxAmount.setText(getString(R.string.street_sweep_amount)); + } else { + tvTxAmount.setText(getString(R.string.send_amount, Helper.getDisplayAmount(committedTx.amount))); + } + tvTxFee.setText(getString(R.string.send_fee, Helper.getDisplayAmount(committedTx.fee))); + } + sendListener.enableDone(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendWizardFragment.java new file mode 100644 index 0000000..5848ad8 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendWizardFragment.java @@ -0,0 +1,36 @@ +/* + * 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 androidx.fragment.app.Fragment; + +import com.m2049r.xmrwallet.layout.SpendViewPager; + +abstract public class SendWizardFragment extends Fragment + implements SpendViewPager.OnValidateFieldsListener { + + @Override + public boolean onValidateFields() { + return true; + } + + public void onPauseFragment() { + } + + public void onResumeFragment() { + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/DiffCallback.java b/app/src/main/java/com/m2049r/xmrwallet/layout/DiffCallback.java new file mode 100644 index 0000000..231f7c1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/DiffCallback.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 yorha-0x + * + * 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.layout; + +import androidx.recyclerview.widget.DiffUtil; + +import java.util.List; + +public abstract class DiffCallback extends DiffUtil.Callback { + + protected final List mOldList; + protected final List mNewList; + + public DiffCallback(List oldList, List newList) { + this.mOldList = oldList; + this.mNewList = newList; + } + + @Override + public int getOldListSize() { + return mOldList.size(); + } + + @Override + public int getNewListSize() { + return mNewList.size(); + } + + public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition); + + public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition); +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/NodeInfoAdapter.java b/app/src/main/java/com/m2049r/xmrwallet/layout/NodeInfoAdapter.java new file mode 100644 index 0000000..baae49e --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/NodeInfoAdapter.java @@ -0,0 +1,261 @@ +/* + * 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.layout; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.data.NodeInfo; +import com.m2049r.xmrwallet.dialog.HelpFragment; +import com.m2049r.xmrwallet.util.NetCipherHelper; + +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class NodeInfoAdapter extends RecyclerView.Adapter { + public interface OnInteractionListener { + void onInteraction(View view, NodeInfo item); + + boolean onLongInteraction(View view, NodeInfo item); + } + + private final List nodeItems = new ArrayList<>(); + private final OnInteractionListener listener; + + private final FragmentActivity activity; + + public NodeInfoAdapter(FragmentActivity activity, OnInteractionListener listener) { + this.activity = activity; + this.listener = listener; + } + + public void notifyItemChanged(NodeInfo nodeInfo) { + final int pos = nodeItems.indexOf(nodeInfo); + if (pos >= 0) notifyItemChanged(pos); + } + + private static class NodeDiff extends DiffCallback { + + public NodeDiff(List oldList, List newList) { + super(oldList, newList); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).equals(mNewList.get(newItemPosition)); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + final NodeInfo oldItem = mOldList.get(oldItemPosition); + final NodeInfo newItem = mNewList.get(newItemPosition); + return (oldItem.getTimestamp() == newItem.getTimestamp()) + && (oldItem.isTested() == newItem.isTested()) + && (oldItem.isValid() == newItem.isValid()) + && (oldItem.getResponseTime() == newItem.getResponseTime()) + && (oldItem.isSelected() == newItem.isSelected()) + && (oldItem.getName().equals(newItem.getName())); + } + } + + @Override + public @NonNull + ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_node, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(final @NonNull ViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + return nodeItems.size(); + } + + public void addNode(NodeInfo node) { + List newItems = new ArrayList<>(nodeItems); + if (!nodeItems.contains(node)) + newItems.add(node); + setNodes(newItems); // in case the nodeinfo has changed + } + + public void setNodes(Collection newItemsCollection) { + List newItems; + if (newItemsCollection != null) { + newItems = new ArrayList<>(newItemsCollection); + Collections.sort(newItems, NodeInfo.BestNodeComparator); + } else { + newItems = new ArrayList<>(); + } + final NodeInfoAdapter.NodeDiff diffCallback = new NodeInfoAdapter.NodeDiff(nodeItems, newItems); + final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback); + nodeItems.clear(); + nodeItems.addAll(newItems); + diffResult.dispatchUpdatesTo(this); + } + + public void setNodes() { + setNodes(nodeItems); + } + + private boolean itemsClickable = true; + + public void allowClick(boolean clickable) { + itemsClickable = clickable; + notifyDataSetChanged(); + } + + class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + final ImageButton ibBookmark; + final View pbBookmark; + final TextView tvName; + final TextView tvInfo; + final ImageView ivPing; + NodeInfo nodeItem; + + ViewHolder(View itemView) { + super(itemView); + ibBookmark = itemView.findViewById(R.id.ibBookmark); + pbBookmark = itemView.findViewById(R.id.pbBookmark); + tvName = itemView.findViewById(R.id.tvName); + tvInfo = itemView.findViewById(R.id.tvInfo); + ivPing = itemView.findViewById(R.id.ivPing); + ibBookmark.setOnClickListener(v -> { + nodeItem.toggleFavourite(); + showStar(); + if (!nodeItem.isFavourite()) { + nodeItem.setSelected(false); + setNodes(nodeItems); + } + }); + itemView.setOnClickListener(this); + itemView.setOnLongClickListener(this); + } + + private void showStar() { + if (nodeItem.isFavourite()) { + ibBookmark.setImageResource(R.drawable.ic_favorite_24dp); + } else { + ibBookmark.setImageResource(R.drawable.ic_favorite_border_24dp); + } + } + + void bind(int position) { + nodeItem = nodeItems.get(position); + tvName.setText(nodeItem.getName()); + ivPing.setImageResource(getPingIcon(nodeItem)); + if (nodeItem.isTested()) { + if (nodeItem.isValid()) { + nodeItem.showInfo(tvInfo); + } else { + nodeItem.showInfo(tvInfo, getResponseErrorText(activity, nodeItem.getResponseCode()), true); + } + } else { + nodeItem.showInfo(tvInfo); + } + itemView.setSelected(nodeItem.isSelected()); + itemView.setClickable(itemsClickable); + itemView.setEnabled(itemsClickable); + ibBookmark.setClickable(itemsClickable); + pbBookmark.setVisibility(nodeItem.isSelecting() ? View.VISIBLE : View.INVISIBLE); + showStar(); + } + + @Override + public void onClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + final NodeInfo node = nodeItems.get(position); + if (node.isOnion()) { + switch (NetCipherHelper.getStatus()) { + case NOT_INSTALLED: + HelpFragment.display(activity.getSupportFragmentManager(), R.string.help_tor); + return; + case DISABLED: + HelpFragment.display(activity.getSupportFragmentManager(), R.string.help_tor_enable); + return; + } + } + node.setSelecting(true); + allowClick(false); + listener.onInteraction(view, node); + } + } + } + + @Override + public boolean onLongClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + return listener.onLongInteraction(view, nodeItems.get(position)); + } + } + return false; + } + } + + static public int getPingIcon(NodeInfo nodeInfo) { + if (nodeInfo.isUnauthorized()) { + return R.drawable.ic_wifi_lock; + } + if (nodeInfo.isValid()) { + final double ping = nodeInfo.getResponseTime(); + if (ping < NodeInfo.PING_GOOD) { + return R.drawable.ic_wifi_4_bar; + } else if (ping < NodeInfo.PING_MEDIUM) { + return R.drawable.ic_wifi_3_bar; + } else if (ping < NodeInfo.PING_BAD) { + return R.drawable.ic_wifi_2_bar; + } else { + return R.drawable.ic_wifi_1_bar; + } + } else { + return R.drawable.ic_wifi_off; + } + } + + static public String getResponseErrorText(Context ctx, int responseCode) { + if (responseCode == 0) { + return ctx.getResources().getString(R.string.node_general_error); + } else if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + return ctx.getResources().getString(R.string.node_auth_error); + } else if (responseCode == 418) { + return ctx.getResources().getString(R.string.node_tor_error); + } else { + return ctx.getResources().getString(R.string.node_test_error, responseCode); + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/SpendViewPager.java b/app/src/main/java/com/m2049r/xmrwallet/layout/SpendViewPager.java new file mode 100644 index 0000000..71b7e0c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/SpendViewPager.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.layout; + +import android.content.Context; +import androidx.viewpager.widget.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import com.m2049r.xmrwallet.fragment.send.SendFragment; + +public class SpendViewPager extends ViewPager { + + public interface OnValidateFieldsListener { + boolean onValidateFields(); + } + + public SpendViewPager(Context context) { + super(context); + } + + public SpendViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void next() { + int pos = getCurrentItem(); + if (validateFields(pos)) { + setCurrentItem(pos + 1); + } + } + + public void previous() { + setCurrentItem(getCurrentItem() - 1); + } + + private boolean allowSwipe = true; + + public void allowSwipe(boolean allow) { + allowSwipe = allow; + } + + public boolean validateFields(int position) { + OnValidateFieldsListener c = ((SendFragment.SpendPagerAdapter) getAdapter()).getFragment(position); + return c.onValidateFields(); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (allowSwipe) return super.onInterceptTouchEvent(event); + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (allowSwipe) return super.onTouchEvent(event); + return false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/SubaddressInfoAdapter.java b/app/src/main/java/com/m2049r/xmrwallet/layout/SubaddressInfoAdapter.java new file mode 100644 index 0000000..6dab349 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/SubaddressInfoAdapter.java @@ -0,0 +1,164 @@ +/* + * 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.layout; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.data.Subaddress; +import com.m2049r.xmrwallet.util.Helper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import timber.log.Timber; + +public class SubaddressInfoAdapter extends RecyclerView.Adapter { + public interface OnInteractionListener { + void onInteraction(View view, Subaddress item); + + boolean onLongInteraction(View view, Subaddress item); + } + + private final List items; + private final OnInteractionListener listener; + + Context context; + + public SubaddressInfoAdapter(Context context, OnInteractionListener listener) { + this.context = context; + this.items = new ArrayList<>(); + this.listener = listener; + } + + private static class SubaddressInfoDiff extends DiffCallback { + + public SubaddressInfoDiff(List oldList, List newList) { + super(oldList, newList); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).getAddress().equals(mNewList.get(newItemPosition).getAddress()); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).equals(mNewList.get(newItemPosition)); + } + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_subaddress, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(final ViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + return items.size(); + } + + public Subaddress getItem(int position) { + return items.get(position); + } + + public void setInfos(List newItems) { + if (newItems == null) { + newItems = new ArrayList<>(); + Timber.d("setInfos null"); + } else { + Timber.d("setInfos %s", newItems.size()); + } + Collections.sort(newItems); + final DiffCallback diffCallback = new SubaddressInfoDiff(items, newItems); + final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback); + items.clear(); + items.addAll(newItems); + diffResult.dispatchUpdatesTo(this); + } + + class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + final TextView tvName; + final TextView tvAddress; + final TextView tvAmount; + Subaddress item; + + ViewHolder(View itemView) { + super(itemView); + tvName = itemView.findViewById(R.id.tvName); + tvAddress = itemView.findViewById(R.id.tvAddress); + tvAmount = itemView.findViewById(R.id.tx_amount); + itemView.setOnClickListener(this); + itemView.setOnLongClickListener(this); + } + + void bind(int position) { + item = getItem(position); + itemView.setTransitionName(context.getString(R.string.subaddress_item_transition_name, item.getAddressIndex())); + + final String label = item.getDisplayLabel(); + final String address = context.getString(R.string.subbaddress_info_subtitle, + item.getAddressIndex(), item.getSquashedAddress()); + tvName.setText(label.isEmpty() ? address : label); + tvAddress.setText(address); + final long amount = item.getAmount(); + if (amount > 0) + tvAmount.setText(context.getString(R.string.tx_list_amount_positive, + Helper.getDisplayAmount(amount, Helper.DISPLAY_DIGITS_INFO))); + else + tvAmount.setText(""); + } + + @Override + public void onClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + listener.onInteraction(view, getItem(position)); + } + } + } + + @Override + public boolean onLongClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + return listener.onLongInteraction(view, getItem(position)); + } + } + return true; + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/TransactionInfoAdapter.java b/app/src/main/java/com/m2049r/xmrwallet/layout/TransactionInfoAdapter.java new file mode 100644 index 0000000..c4ce06e --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/TransactionInfoAdapter.java @@ -0,0 +1,278 @@ +/* + * 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.layout; + +import android.content.Context; +import android.text.Html; +import android.text.Spanned; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.progressindicator.CircularProgressIndicator; +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.data.Crypto; +import com.m2049r.xmrwallet.data.UserNotes; +import com.m2049r.xmrwallet.model.TransactionInfo; +import com.m2049r.xmrwallet.util.Helper; +import com.m2049r.xmrwallet.util.ThemeHelper; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.TimeZone; + +import timber.log.Timber; + +public class TransactionInfoAdapter extends RecyclerView.Adapter { + private final static SimpleDateFormat DATETIME_FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + private final int outboundColour; + private final int inboundColour; + private final int pendingColour; + private final int failedColour; + + public interface OnInteractionListener { + void onInteraction(View view, TransactionInfo item); + } + + private final List infoItems; + private final OnInteractionListener listener; + + private final Context context; + + public TransactionInfoAdapter(Context context, OnInteractionListener listener) { + this.context = context; + inboundColour = ThemeHelper.getThemedColor(context, R.attr.positiveColor); + outboundColour = ThemeHelper.getThemedColor(context, R.attr.negativeColor); + pendingColour = ThemeHelper.getThemedColor(context, R.attr.neutralColor); + failedColour = ThemeHelper.getThemedColor(context, R.attr.neutralColor); + infoItems = new ArrayList<>(); + this.listener = listener; + Calendar cal = Calendar.getInstance(); + TimeZone tz = cal.getTimeZone(); //get the local time zone. + DATETIME_FORMATTER.setTimeZone(tz); + } + + public boolean needsTransactionUpdateOnNewBlock() { + return (infoItems.size() > 0) && !infoItems.get(0).isConfirmed(); + } + + private static class TransactionInfoDiff extends DiffCallback { + + public TransactionInfoDiff(List oldList, List newList) { + super(oldList, newList); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).hash.equals(mNewList.get(newItemPosition).hash); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + final TransactionInfo oldItem = mOldList.get(oldItemPosition); + final TransactionInfo newItem = mNewList.get(newItemPosition); + return (oldItem.direction == newItem.direction) + && (oldItem.isPending == newItem.isPending) + && (oldItem.isFailed == newItem.isFailed) + && ((oldItem.confirmations == newItem.confirmations) || (oldItem.isConfirmed())) + && (oldItem.subaddressLabel.equals(newItem.subaddressLabel)) + && (Objects.equals(oldItem.notes, newItem.notes)); + } + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_transaction, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(final ViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + return infoItems.size(); + } + + public void setInfos(List newItems) { + if (newItems == null) { + newItems = new ArrayList<>(); + Timber.d("setInfos null"); + } else { + Timber.d("setInfos %s", newItems.size()); + } + Collections.sort(newItems); + final DiffCallback diffCallback = new TransactionInfoAdapter.TransactionInfoDiff(infoItems, newItems); + final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback); + infoItems.clear(); + infoItems.addAll(newItems); + diffResult.dispatchUpdatesTo(this); + } + + public void removeItem(int position) { + List newItems = new ArrayList<>(infoItems); + if (newItems.size() > position) + newItems.remove(position); + setInfos(newItems); // in case the nodeinfo has changed + } + + public TransactionInfo getItem(int position) { + return infoItems.get(position); + } + + class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + final ImageView ivTxType; + final TextView tvAmount; + final TextView tvFailed; + final TextView tvPaymentId; + final TextView tvDateTime; + final CircularProgressIndicator pbConfirmations; + final TextView tvConfirmations; + TransactionInfo infoItem; + + ViewHolder(View itemView) { + super(itemView); + ivTxType = itemView.findViewById(R.id.ivTxType); + tvAmount = itemView.findViewById(R.id.tx_amount); + tvFailed = itemView.findViewById(R.id.tx_failed); + tvPaymentId = itemView.findViewById(R.id.tx_paymentid); + tvDateTime = itemView.findViewById(R.id.tx_datetime); + pbConfirmations = itemView.findViewById(R.id.pbConfirmations); + pbConfirmations.setMax(TransactionInfo.CONFIRMATION); + tvConfirmations = itemView.findViewById(R.id.tvConfirmations); + } + + private String getDateTime(long time) { + return DATETIME_FORMATTER.format(new Date(time * 1000)); + } + + private void setTxColour(int clr) { + tvAmount.setTextColor(clr); + } + + void bind(int position) { + infoItem = infoItems.get(position); + itemView.setTransitionName(context.getString(R.string.tx_item_transition_name, infoItem.hash)); + + UserNotes userNotes = new UserNotes(infoItem.notes); + if (userNotes.xmrtoKey != null) { + final Crypto crypto = Crypto.withSymbol(userNotes.xmrtoCurrency); + if (crypto != null) { + ivTxType.setImageResource(crypto.getIconEnabledId()); + ivTxType.setVisibility(View.VISIBLE); + } else {// otherwirse pretend we don't know it's a shift + ivTxType.setVisibility(View.GONE); + } + } else { + ivTxType.setVisibility(View.GONE); + } + + String displayAmount = Helper.getDisplayAmount(infoItem.amount, Helper.DISPLAY_DIGITS_INFO); + if (infoItem.direction == TransactionInfo.Direction.Direction_Out) { + tvAmount.setText(context.getString(R.string.tx_list_amount_negative, displayAmount)); + } else { + tvAmount.setText(context.getString(R.string.tx_list_amount_positive, displayAmount)); + } + + tvFailed.setVisibility(View.GONE); + if (infoItem.isFailed) { + this.tvAmount.setText(context.getString(R.string.tx_list_amount_failed, displayAmount)); + tvFailed.setVisibility(View.VISIBLE); + setTxColour(failedColour); + pbConfirmations.setVisibility(View.GONE); + tvConfirmations.setVisibility(View.GONE); + } else if (infoItem.isPending) { + setTxColour(pendingColour); + pbConfirmations.setVisibility(View.GONE); + pbConfirmations.setIndeterminate(true); + pbConfirmations.setVisibility(View.VISIBLE); + tvConfirmations.setVisibility(View.GONE); + } else if (infoItem.direction == TransactionInfo.Direction.Direction_In) { + setTxColour(inboundColour); + if (!infoItem.isConfirmed()) { + pbConfirmations.setVisibility(View.VISIBLE); + final int confirmations = (int) infoItem.confirmations; + pbConfirmations.setProgressCompat(confirmations, true); + final String confCount = Integer.toString(confirmations); + tvConfirmations.setText(confCount); + if (confCount.length() == 1) // we only have space for character in the progress circle + tvConfirmations.setVisibility(View.VISIBLE); + else + tvConfirmations.setVisibility(View.GONE); + } else { + pbConfirmations.setVisibility(View.GONE); + tvConfirmations.setVisibility(View.GONE); + } + } else { + setTxColour(outboundColour); + pbConfirmations.setVisibility(View.GONE); + tvConfirmations.setVisibility(View.GONE); + } + + String tag = null; + String info = ""; + if ((infoItem.addressIndex != 0) && (infoItem.direction == TransactionInfo.Direction.Direction_In)) + tag = infoItem.getDisplayLabel(); + if ((userNotes.note.isEmpty())) { + if (!infoItem.paymentId.equals("0000000000000000")) { + info = infoItem.paymentId; + } + } else { + info = userNotes.note; + } + if (tag == null) { + tvPaymentId.setText(info); + } else { + Spanned label = Html.fromHtml(context.getString(R.string.tx_details_notes, + Integer.toHexString(ThemeHelper.getThemedColor(context, R.attr.positiveColor) & 0xFFFFFF), + Integer.toHexString(ThemeHelper.getThemedColor(context, android.R.attr.colorBackground) & 0xFFFFFF), + tag, info.isEmpty() ? "" : ("  " + info))); + tvPaymentId.setText(label); + } + + this.tvDateTime.setText(getDateTime(infoItem.timestamp)); + + itemView.setOnClickListener(this); + } + + @Override + public void onClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + listener.onInteraction(view, infoItems.get(position)); + } + } + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/WalletInfoAdapter.java b/app/src/main/java/com/m2049r/xmrwallet/layout/WalletInfoAdapter.java new file mode 100644 index 0000000..ad885e7 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/WalletInfoAdapter.java @@ -0,0 +1,174 @@ +/* + * 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.layout; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.PopupMenu; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.model.WalletManager; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import timber.log.Timber; + +public class WalletInfoAdapter extends RecyclerView.Adapter { + + private final SimpleDateFormat DATETIME_FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + public interface OnInteractionListener { + void onInteraction(View view, WalletManager.WalletInfo item); + + boolean onContextInteraction(MenuItem item, WalletManager.WalletInfo infoItem); + } + + private final List infoItems; + private final OnInteractionListener listener; + + Context context; + + public WalletInfoAdapter(Context context, OnInteractionListener listener) { + this.context = context; + this.infoItems = new ArrayList<>(); + this.listener = listener; + Calendar cal = Calendar.getInstance(); + TimeZone tz = cal.getTimeZone(); //get the local time zone. + DATETIME_FORMATTER.setTimeZone(tz); + } + + private static class WalletInfoDiff extends DiffCallback { + + public WalletInfoDiff(List oldList, List newList) { + super(oldList, newList); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).getName().equals(mNewList.get(newItemPosition).getName()); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return mOldList.get(oldItemPosition).compareTo(mNewList.get(newItemPosition)) == 0; + } + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new ViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.item_wallet, parent, false) + ); + } + + @Override + public void onBindViewHolder(final ViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + return infoItems.size(); + } + + public WalletManager.WalletInfo getItem(int position) { + return infoItems.get(position); + } + + public void setInfos(List newItems) { + if (newItems == null) { + newItems = new ArrayList<>(); + Timber.d("setInfos null"); + } else { + Timber.d("setInfos %s", newItems.size()); + } + Collections.sort(newItems); + final DiffCallback diffCallback = new WalletInfoAdapter.WalletInfoDiff(infoItems, newItems); + final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(diffCallback); + infoItems.clear(); + infoItems.addAll(newItems); + diffResult.dispatchUpdatesTo(this); + } + + class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + final TextView tvName; + final ImageButton ibOptions; + WalletManager.WalletInfo infoItem; + boolean popupOpen = false; + + ViewHolder(View itemView) { + super(itemView); + tvName = itemView.findViewById(R.id.tvName); + ibOptions = itemView.findViewById(R.id.ibOptions); + ibOptions.setOnClickListener(view -> { + if (popupOpen) return; + //creating a popup menu + PopupMenu popup = new PopupMenu(context, ibOptions); + //inflating menu from xml resource + popup.inflate(R.menu.list_context_menu); + popupOpen = true; + //adding click listener + popup.setOnMenuItemClickListener(item -> { + if (listener != null) { + return listener.onContextInteraction(item, infoItem); + } + return false; + }); + //displaying the popup + popup.show(); + popup.setOnDismissListener(menu -> popupOpen = false); + + }); + itemView.setOnClickListener(this); + } + + private String getDateTime(long time) { + return DATETIME_FORMATTER.format(new Date(time * 1000)); + } + + void bind(int position) { + infoItem = infoItems.get(position); + tvName.setText(infoItem.getName()); + } + + @Override + public void onClick(View view) { + if (listener != null) { + int position = getAdapterPosition(); // gets item position + if (position != RecyclerView.NO_POSITION) { // Check if an item was deleted, but the user clicked it before the UI removed it + listener.onInteraction(view, infoItems.get(position)); + } + } + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/ledger/Instruction.java b/app/src/main/java/com/m2049r/xmrwallet/ledger/Instruction.java new file mode 100644 index 0000000..52c8a6e --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/ledger/Instruction.java @@ -0,0 +1,155 @@ +/* + * 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.ledger; + +public enum Instruction { + + INS_NONE(0x00), + INS_RESET(0x02), + INS_GET_KEY(0x20), + INS_DISPLAY_ADDRESS(0x21), + INS_PUT_KEY(0x22), + INS_GET_CHACHA8_PREKEY(0x24), + INS_VERIFY_KEY(0x26), + INS_MANAGE_SEEDWORDS(0x28), + + INS_SECRET_KEY_TO_PUBLIC_KEY(0x30), + INS_GEN_KEY_DERIVATION(0x32), + INS_DERIVATION_TO_SCALAR(0x34), + INS_DERIVE_PUBLIC_KEY(0x36), + INS_DERIVE_SECRET_KEY(0x38), + INS_GEN_KEY_IMAGE(0x3A), + + INS_SECRET_KEY_ADD(0x3C), + INS_SECRET_KEY_SUB(0x3E), + INS_GENERATE_KEYPAIR(0x40), + INS_SECRET_SCAL_MUL_KEY(0x42), + INS_SECRET_SCAL_MUL_BASE(0x44), + + INS_DERIVE_SUBADDRESS_PUBLIC_KEY(0x46), + INS_GET_SUBADDRESS(0x48), + INS_GET_SUBADDRESS_SPEND_PUBLIC_KEY(0x4A), + INS_GET_SUBADDRESS_SECRET_KEY(0x4C), + + INS_OPEN_TX(0x70), + INS_SET_SIGNATURE_MODE(0x72), + INS_GET_ADDITIONAL_KEY(0x74), + INS_STEALTH(0x76), + INS_GEN_COMMITMENT_MASK(0x77), + INS_BLIND(0x78), + INS_UNBLIND(0x7A), + INS_GEN_TXOUT_KEYS(0x7B), + INS_VALIDATE(0x7C), + INS_PREFIX_HASH(0x7D), + INS_MLSAG(0x7E), + INS_CLOSE_TX(0x80), + + INS_GET_TX_PROOF(0xA0), + + INS_GET_RESPONSE(0xC0), + + INS_UNDEFINED(0xFF); + + public static Instruction fromByte(byte n) { + switch (n & 0xFF) { + case 0x00: + return INS_NONE; + case 0x02: + return INS_RESET; + + case 0x20: + return INS_GET_KEY; + case 0x22: + return INS_PUT_KEY; + case 0x24: + return INS_GET_CHACHA8_PREKEY; + case 0x26: + return INS_VERIFY_KEY; + + case 0x30: + return INS_SECRET_KEY_TO_PUBLIC_KEY; + case 0x32: + return INS_GEN_KEY_DERIVATION; + case 0x34: + return INS_DERIVATION_TO_SCALAR; + case 0x36: + return INS_DERIVE_PUBLIC_KEY; + case 0x38: + return INS_DERIVE_SECRET_KEY; + case 0x3A: + return INS_GEN_KEY_IMAGE; + case 0x3C: + return INS_SECRET_KEY_ADD; + case 0x3E: + return INS_SECRET_KEY_SUB; + case 0x40: + return INS_GENERATE_KEYPAIR; + case 0x42: + return INS_SECRET_SCAL_MUL_KEY; + case 0x44: + return INS_SECRET_SCAL_MUL_BASE; + + case 0x46: + return INS_DERIVE_SUBADDRESS_PUBLIC_KEY; + case 0x48: + return INS_GET_SUBADDRESS; + case 0x4A: + return INS_GET_SUBADDRESS_SPEND_PUBLIC_KEY; + case 0x4C: + return INS_GET_SUBADDRESS_SECRET_KEY; + + case 0x70: + return INS_OPEN_TX; + case 0x72: + return INS_SET_SIGNATURE_MODE; + case 0x74: + return INS_GET_ADDITIONAL_KEY; + case 0x76: + return INS_STEALTH; + case 0x78: + return INS_BLIND; + case 0x7A: + return INS_UNBLIND; + case 0x7C: + return INS_VALIDATE; + case 0x7E: + return INS_MLSAG; + case 0x80: + return INS_CLOSE_TX; + + case 0xc0: + return INS_GET_RESPONSE; + + default: + return INS_UNDEFINED; + } + } + + public int getValue() { + return value; + } + + public byte getByteValue() { + return (byte) (value & 0xFF); + } + + private int value; + + Instruction(int value) { + this.value = value; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/ledger/Ledger.java b/app/src/main/java/com/m2049r/xmrwallet/ledger/Ledger.java new file mode 100644 index 0000000..0358282 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/ledger/Ledger.java @@ -0,0 +1,240 @@ +/* + ******************************************************************************* + * BTChip Bitcoin Hardware Wallet Java API + * (c) 2014 BTChip - 1BTChip7VfTnrPra5jqci7ejnMguuHogTn + * 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.ledger; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import com.btchip.BTChipException; +import com.btchip.comm.BTChipTransport; +import com.btchip.comm.android.BTChipTransportAndroidHID; +import com.m2049r.xmrwallet.BuildConfig; +import com.m2049r.xmrwallet.model.WalletManager; +import com.m2049r.xmrwallet.util.Helper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import timber.log.Timber; + +public class Ledger { + static final public boolean ENABLED = true; + // 5:20 is same as wallet2.cpp::restore() + static public final int LOOKAHEAD_ACCOUNTS = 5; + static public final int LOOKAHEAD_SUBADDRESSES = 20; + static public final String SUBADDRESS_LOOKAHEAD = LOOKAHEAD_ACCOUNTS + ":" + LOOKAHEAD_SUBADDRESSES; + + private static final byte PROTOCOL_VERSION = 0x03; + public static final int SW_OK = 0x9000; + public static final int SW_INS_NOT_SUPPORTED = 0x6D00; + public static final int OK[] = {SW_OK}; + public static final int MINIMUM_LEDGER_VERSION = (1 << 16) + (8 << 8) + (0); // 1.6.0 + + public static UsbDevice findDevice(UsbManager usbManager) { + if (!ENABLED) return null; + return BTChipTransportAndroidHID.getDevice(usbManager); + } + + static private Ledger Instance = null; + + static public String connect(UsbManager usbManager, UsbDevice usbDevice) throws IOException { + if (Instance != null) { + disconnect(); + } + Instance = new Ledger(usbManager, usbDevice); + return Name(); + } + + static public void disconnect() { + // this is not synchronized so as to close immediately + if (Instance != null) { + Instance.close(); + Instance = null; + } + } + + static public boolean isConnected() { + //TODO synchronize with connect/disconnect? + return Instance != null; + } + + static public String Name() { + if (Instance != null) { + return Instance.name; + } else { + return null; + } + } + + static public byte[] Exchange(byte[] apdu) { + if (Instance != null) { + Timber.d("INS: %s", Instruction.fromByte(apdu[1])); + return Instance.exchangeRaw(apdu); + } else { + return null; + } + } + + static public boolean check() { + if (Instance == null) return false; + byte[] moneroVersion = WalletManager.moneroVersion().getBytes(StandardCharsets.US_ASCII); + + try { + byte[] resp = Instance.exchangeApduNoOpt(Instruction.INS_RESET, moneroVersion, OK); + int deviceVersion = (resp[0] << 16) + (resp[1] << 8) + (resp[2]); + if (deviceVersion < MINIMUM_LEDGER_VERSION) + return false; + } catch (BTChipException ex) { // comm error - probably wrong app started on device + return false; + } + return true; + } + + final private BTChipTransport transport; + final private String name; + private int lastSW = 0; + + private Ledger(UsbManager usbManager, UsbDevice usbDevice) throws IOException { + final BTChipTransport transport = BTChipTransportAndroidHID.open(usbManager, usbDevice); + Timber.d("transport opened = %s", transport.toString()); + transport.setDebug(BuildConfig.DEBUG); + this.transport = transport; + this.name = usbDevice.getManufacturerName() + " " + usbDevice.getProductName(); + initKey(); + } + + synchronized private void close() { + initKey(); // don't leak key after we disconnect + transport.close(); + Timber.d("transport closed"); + lastSW = 0; + } + + synchronized private byte[] exchangeRaw(byte[] apdu) { + if (transport == null) + throw new IllegalStateException("No transport (probably closed previously)"); + Timber.d("exchangeRaw %02x", apdu[1]); + Instruction ins = Instruction.fromByte(apdu[1]); + if (listener != null) listener.onInstructionSend(ins, apdu); + sniffOut(ins, apdu); + byte[] data = transport.exchange(apdu); + if (listener != null) listener.onInstructionReceive(ins, data); + sniffIn(data); + return data; + } + + private byte[] exchange(byte[] apdu) throws BTChipException { + byte[] response = exchangeRaw(apdu); + if (response.length < 2) { + throw new BTChipException("Truncated response"); + } + lastSW = ((response[response.length - 2] & 0xff) << 8) | + response[response.length - 1] & 0xff; + byte[] result = new byte[response.length - 2]; + System.arraycopy(response, 0, result, 0, response.length - 2); + return result; + } + + private byte[] exchangeCheck(byte[] apdu, int acceptedSW[]) throws BTChipException { + byte[] response = exchange(apdu); + if (acceptedSW == null) { + return response; + } + for (int SW : acceptedSW) { + if (lastSW == SW) { + return response; + } + } + throw new BTChipException("Invalid status", lastSW); + } + + private byte[] exchangeApduNoOpt(Instruction instruction, byte[] data, int acceptedSW[]) + throws BTChipException { + byte[] apdu = new byte[data.length + 6]; + apdu[0] = PROTOCOL_VERSION; + apdu[1] = instruction.getByteValue(); + apdu[2] = 0; // p1 + apdu[3] = 0; // p2 + apdu[4] = (byte) (data.length + 1); // +1 because the opt byte is part of the data + apdu[5] = 0; // opt + System.arraycopy(data, 0, apdu, 6, data.length); + return exchangeCheck(apdu, acceptedSW); + } + + public interface Listener { + void onInstructionSend(Instruction ins, byte[] apdu); + + void onInstructionReceive(Instruction ins, byte[] data); + } + + Listener listener; + + static public void setListener(Listener listener) { + if (Instance != null) { + Instance.listener = listener; + } + } + + static public void unsetListener(Listener listener) { + if ((Instance != null) && (Instance.listener == listener)) + Instance.listener = null; + } + + // very stupid hack to extract the view key + // without messing around with monero core code + // NB: as all the ledger comm can be sniffed off the USB cable - there is no security issue here + private boolean snoopKey = false; + private byte[] key; + + private void initKey() { + key = Helper.hexToBytes("0000000000000000000000000000000000000000000000000000000000000000"); + } + + static public String Key() { + if (Instance != null) { + return Helper.bytesToHex(Instance.key).toLowerCase(); + } else { + return null; + } + } + + private void sniffOut(Instruction ins, byte[] apdu) { + if (ins == Instruction.INS_GET_KEY) { + snoopKey = (apdu[2] == 2); + } + } + + private void sniffIn(byte[] data) { + // stupid hack to extract the view key + // without messing around with monero core code + if (snoopKey) { + if (data.length == 34) { // 32 key + result code 9000 + long sw = ((data[data.length - 2] & 0xff) << 8) | + (data[data.length - 1] & 0xff); + Timber.e("WS %d", sw); + if (sw == SW_OK) { + System.arraycopy(data, 0, key, 0, 32); + } + } + snoopKey = false; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/ledger/LedgerProgressDialog.java b/app/src/main/java/com/m2049r/xmrwallet/ledger/LedgerProgressDialog.java new file mode 100644 index 0000000..0c81cb2 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/ledger/LedgerProgressDialog.java @@ -0,0 +1,183 @@ +/* + * 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.ledger; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.dialog.ProgressDialog; + +import timber.log.Timber; + +public class LedgerProgressDialog extends ProgressDialog implements Ledger.Listener { + + static public final int TYPE_DEBUG = 0; + static public final int TYPE_RESTORE = 1; + static public final int TYPE_SUBADDRESS = 2; + static public final int TYPE_ACCOUNT = 3; + static public final int TYPE_SEND = 4; + + private final int type; + private Handler uiHandler = new Handler(Looper.getMainLooper()); + + public LedgerProgressDialog(Context context, int type) { + super(context); + this.type = type; + setCancelable(false); + if (type == TYPE_SEND) + setMessage(context.getString(R.string.info_prepare_tx)); + else + setMessage(context.getString(R.string.progress_ledger_progress)); + } + + @Override + public void onBackPressed() { + // prevent back button + } + + private int firstSubaddress = Integer.MAX_VALUE; + + private boolean validate = false; + private boolean validated = false; + + @Override + public void onInstructionSend(final Instruction ins, final byte[] apdu) { + Timber.d("LedgerProgressDialog SEND %s", ins); + uiHandler.post(new Runnable() { + @Override + public void run() { + if (type > TYPE_DEBUG) { + validate = false; + switch (ins) { + case INS_RESET: // ledger may ask for confirmation - maybe a bug? + case INS_GET_KEY: // ledger asks for confirmation to send keys + case INS_DISPLAY_ADDRESS: + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_confirm)); + break; + case INS_GET_SUBADDRESS_SPEND_PUBLIC_KEY: // lookahead + //00 4a 00 00 09 00 01000000 30000000 + // 0 1 2 3 4 5 6 7 8 9 a b c d + int account = bytesToInteger(apdu, 6); + int subaddress = bytesToInteger(apdu, 10); + Timber.d("fetching subaddress (%d, %d)", account, subaddress); + switch (type) { + case TYPE_RESTORE: + setProgress(account * Ledger.LOOKAHEAD_SUBADDRESSES + subaddress + 1, + Ledger.LOOKAHEAD_ACCOUNTS * Ledger.LOOKAHEAD_SUBADDRESSES); + setIndeterminate(false); + break; + case TYPE_ACCOUNT: + final int requestedSubaddress = account * Ledger.LOOKAHEAD_SUBADDRESSES + subaddress; + if (firstSubaddress > requestedSubaddress) { + firstSubaddress = requestedSubaddress; + } + setProgress(requestedSubaddress - firstSubaddress + 1, + Ledger.LOOKAHEAD_ACCOUNTS * Ledger.LOOKAHEAD_SUBADDRESSES); + setIndeterminate(false); + break; + case TYPE_SUBADDRESS: + if (firstSubaddress > subaddress) { + firstSubaddress = subaddress; + } + setProgress(subaddress - firstSubaddress + 1, Ledger.LOOKAHEAD_SUBADDRESSES); + setIndeterminate(false); + break; + default: + setIndeterminate(true); + break; + } + setMessage(getContext().getString(R.string.progress_ledger_lookahead)); + break; + case INS_VERIFY_KEY: + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_verify)); + break; + case INS_OPEN_TX: + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_opentx)); + break; + case INS_MLSAG: + if (validated) { + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_mlsag)); + } + break; + case INS_PREFIX_HASH: + if ((apdu[2] != 1) || (apdu[3] != 0)) break; + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_confirm)); + break; + case INS_VALIDATE: + if ((apdu[2] != 1) || (apdu[3] != 1)) break; + validate = true; + uiHandler.postDelayed(new Runnable() { + @Override + public void run() { + if (validate) { + setIndeterminate(true); + setMessage(getContext().getString(R.string.progress_ledger_confirm)); + validated = true; + } + } + }, 250); + break; + default: + // ignore others and maintain state + } + } else { + setMessage(ins.name()); + } + } + }); + } + + @Override + public void onInstructionReceive(final Instruction ins, final byte[] data) { + Timber.d("LedgerProgressDialog RECV %s", ins); + uiHandler.post(new Runnable() { + @Override + public void run() { + if (type > TYPE_DEBUG) { + switch (ins) { + case INS_GET_SUBADDRESS_SPEND_PUBLIC_KEY: // lookahead + case INS_VERIFY_KEY: + case INS_GET_CHACHA8_PREKEY: + break; + default: + if (type != TYPE_SEND) + setMessage(getContext().getString(R.string.progress_ledger_progress)); + } + } else { + setMessage("Returned from " + ins.name()); + } + } + }); + } + + // TODO: we use ints in Java but the are signed; accounts & subaddresses are unsigned ... + private int bytesToInteger(byte[] bytes, int offset) { + int result = 0; + for (int i = 3; i >= 0; i--) { + result <<= 8; + result |= (bytes[offset + i] & 0xFF); + } + return result; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/NetworkType.java b/app/src/main/java/com/m2049r/xmrwallet/model/NetworkType.java new file mode 100644 index 0000000..ae1c84f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/NetworkType.java @@ -0,0 +1,45 @@ +/* + * 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.model; + +public enum NetworkType { + NetworkType_Mainnet(0), + NetworkType_Testnet(1), + NetworkType_Stagenet(2); + + public static NetworkType fromInteger(int n) { + switch (n) { + case 0: + return NetworkType_Mainnet; + case 1: + return NetworkType_Testnet; + case 2: + return NetworkType_Stagenet; + } + return null; + } + + public int getValue() { + return value; + } + + private int value; + + NetworkType(int value) { + this.value = value; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/PendingTransaction.java b/app/src/main/java/com/m2049r/xmrwallet/model/PendingTransaction.java new file mode 100644 index 0000000..6ad620a --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/PendingTransaction.java @@ -0,0 +1,98 @@ +/* + * 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.model; + +public class PendingTransaction { + static { + System.loadLibrary("monerujo"); + } + + public long handle; + + PendingTransaction(long handle) { + this.handle = handle; + } + + public enum Status { + Status_Ok, + Status_Error, + Status_Critical + } + + public enum Priority { + Priority_Default(0), + Priority_Low(1), + Priority_Medium(2), + Priority_High(3), + Priority_Last(4); + + public static Priority fromInteger(int n) { + switch (n) { + case 0: + return Priority_Default; + case 1: + return Priority_Low; + case 2: + return Priority_Medium; + case 3: + return Priority_High; + } + return null; + } + + public int getValue() { + return value; + } + + private int value; + + Priority(int value) { + this.value = value; + } + + + } + + public Status getStatus() { + return Status.values()[getStatusJ()]; + } + + public native int getStatusJ(); + + public native String getErrorString(); + + // commit transaction or save to file if filename is provided. + public native boolean commit(String filename, boolean overwrite); + + public native long getAmount(); + + public native long getDust(); + + public native long getFee(); + + public String getFirstTxId() { + String id = getFirstTxIdJ(); + if (id == null) + throw new IndexOutOfBoundsException(); + return id; + } + + public native String getFirstTxIdJ(); + + public native long getTxCount(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/TransactionHistory.java b/app/src/main/java/com/m2049r/xmrwallet/model/TransactionHistory.java new file mode 100644 index 0000000..08245f6 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/TransactionHistory.java @@ -0,0 +1,82 @@ +/* + * 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.model; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import timber.log.Timber; + +public class TransactionHistory { + static { + System.loadLibrary("monerujo"); + } + + private long handle; + + int accountIndex; + + public void setAccountFor(Wallet wallet) { + if (accountIndex != wallet.getAccountIndex()) { + this.accountIndex = wallet.getAccountIndex(); + refreshWithNotes(wallet); + } + } + + public TransactionHistory(long handle, int accountIndex) { + this.handle = handle; + this.accountIndex = accountIndex; + } + + private void loadNotes(Wallet wallet) { + for (TransactionInfo info : transactions) { + info.notes = wallet.getUserNote(info.hash); + } + } + + public native int getCount(); // over all accounts/subaddresses + + //private native long getTransactionByIndexJ(int i); + + //private native long getTransactionByIdJ(String id); + + public List getAll() { + return transactions; + } + + private List transactions = new ArrayList<>(); + + void refreshWithNotes(Wallet wallet) { + refresh(); + loadNotes(wallet); + } + + private void refresh() { + List transactionInfos = refreshJ(); + Timber.d("refresh size=%d", transactionInfos.size()); + for (Iterator iterator = transactionInfos.iterator(); iterator.hasNext(); ) { + TransactionInfo info = iterator.next(); + if (info.accountIndex != accountIndex) { + iterator.remove(); + } + } + transactions = transactionInfos; + } + + private native List refreshJ(); +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/TransactionInfo.java b/app/src/main/java/com/m2049r/xmrwallet/model/TransactionInfo.java new file mode 100644 index 0000000..4b904e1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/TransactionInfo.java @@ -0,0 +1,186 @@ +/* + * 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.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.m2049r.xmrwallet.data.Subaddress; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +// this is not the TransactionInfo from the API as that is owned by the TransactionHistory +// this is a POJO for the TransactionInfoAdapter +public class TransactionInfo implements Parcelable, Comparable { + public static final int CONFIRMATION = 10; // blocks + + @RequiredArgsConstructor + public enum Direction { + Direction_In(0), + Direction_Out(1); + + public static Direction fromInteger(int n) { + switch (n) { + case 0: + return Direction_In; + case 1: + return Direction_Out; + } + return null; + } + + @Getter + private final int value; + } + + public Direction direction; + public boolean isPending; + public boolean isFailed; + public long amount; + public long fee; + public long blockheight; + public String hash; + public long timestamp; + public String paymentId; + public int accountIndex; + public int addressIndex; + public long confirmations; + public String subaddressLabel; + public List transfers; + + public String txKey = null; + public String notes = null; + public String address = null; + + public TransactionInfo( + int direction, + boolean isPending, + boolean isFailed, + long amount, + long fee, + long blockheight, + String hash, + long timestamp, + String paymentId, + int accountIndex, + int addressIndex, + long confirmations, + String subaddressLabel, + List transfers) { + this.direction = Direction.values()[direction]; + this.isPending = isPending; + this.isFailed = isFailed; + this.amount = amount; + this.fee = fee; + this.blockheight = blockheight; + this.hash = hash; + this.timestamp = timestamp; + this.paymentId = paymentId; + this.accountIndex = accountIndex; + this.addressIndex = addressIndex; + this.confirmations = confirmations; + this.subaddressLabel = subaddressLabel; + this.transfers = transfers; + } + + public boolean isConfirmed() { + return confirmations >= CONFIRMATION; + } + + public String getDisplayLabel() { + if (subaddressLabel.isEmpty() || (Subaddress.DEFAULT_LABEL_FORMATTER.matcher(subaddressLabel).matches())) + return ("#" + addressIndex); + else + return subaddressLabel; + } + + public String toString() { + return direction + "@" + blockheight + " " + amount; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(direction.getValue()); + out.writeByte((byte) (isPending ? 1 : 0)); + out.writeByte((byte) (isFailed ? 1 : 0)); + out.writeLong(amount); + out.writeLong(fee); + out.writeLong(blockheight); + out.writeString(hash); + out.writeLong(timestamp); + out.writeString(paymentId); + out.writeInt(accountIndex); + out.writeInt(addressIndex); + out.writeLong(confirmations); + out.writeString(subaddressLabel); + out.writeList(transfers); + out.writeString(txKey); + out.writeString(notes); + out.writeString(address); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public TransactionInfo createFromParcel(Parcel in) { + return new TransactionInfo(in); + } + + public TransactionInfo[] newArray(int size) { + return new TransactionInfo[size]; + } + }; + + private TransactionInfo(Parcel in) { + direction = Direction.fromInteger(in.readInt()); + isPending = in.readByte() != 0; + isFailed = in.readByte() != 0; + amount = in.readLong(); + fee = in.readLong(); + blockheight = in.readLong(); + hash = in.readString(); + timestamp = in.readLong(); + paymentId = in.readString(); + accountIndex = in.readInt(); + addressIndex = in.readInt(); + confirmations = in.readLong(); + subaddressLabel = in.readString(); + transfers = in.readArrayList(Transfer.class.getClassLoader()); + txKey = in.readString(); + notes = in.readString(); + address = in.readString(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public int compareTo(TransactionInfo another) { + long b1 = this.timestamp; + long b2 = another.timestamp; + if (b1 > b2) { + return -1; + } else if (b1 < b2) { + return 1; + } else { + return this.hash.compareTo(another.hash); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/Transfer.java b/app/src/main/java/com/m2049r/xmrwallet/model/Transfer.java new file mode 100644 index 0000000..27ce6a0 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/Transfer.java @@ -0,0 +1,57 @@ +/* + * 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.model; + +import android.os.Parcel; +import android.os.Parcelable; + +public class Transfer implements Parcelable { + public long amount; + public String address; + + public Transfer(long amount, String address) { + this.amount = amount; + this.address = address; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeLong(amount); + out.writeString(address); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public Transfer createFromParcel(Parcel in) { + return new Transfer(in); + } + + public Transfer[] newArray(int size) { + return new Transfer[size]; + } + }; + + private Transfer(Parcel in) { + amount = in.readLong(); + address = in.readString(); + } + + @Override + public int describeContents() { + return 0; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/Wallet.java b/app/src/main/java/com/m2049r/xmrwallet/model/Wallet.java new file mode 100644 index 0000000..e85d0f8 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/Wallet.java @@ -0,0 +1,507 @@ +/* + * 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.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.m2049r.xmrwallet.data.Subaddress; +import com.m2049r.xmrwallet.data.TxData; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import timber.log.Timber; + +public class Wallet { + final static public long SWEEP_ALL = Long.MAX_VALUE; + + static { + System.loadLibrary("monerujo"); + } + + static public class Status { + Status(int status, String errorString) { + this.status = StatusEnum.values()[status]; + this.errorString = errorString; + } + + final private StatusEnum status; + final private String errorString; + @Nullable + private ConnectionStatus connectionStatus; // optional + + public StatusEnum getStatus() { + return status; + } + + public String getErrorString() { + return errorString; + } + + public void setConnectionStatus(@Nullable ConnectionStatus connectionStatus) { + this.connectionStatus = connectionStatus; + } + + @Nullable + public ConnectionStatus getConnectionStatus() { + return connectionStatus; + } + + public boolean isOk() { + return (getStatus() == StatusEnum.Status_Ok) + && ((getConnectionStatus() == null) || + (getConnectionStatus() == ConnectionStatus.ConnectionStatus_Connected)); + } + + @Override + @NonNull + public String toString() { + return "Wallet.Status: " + status + "/" + errorString + "/" + connectionStatus; + } + } + + private int accountIndex = 0; + + public int getAccountIndex() { + return accountIndex; + } + + public void setAccountIndex(int accountIndex) { + Timber.d("setAccountIndex(%d)", accountIndex); + this.accountIndex = accountIndex; + getHistory().setAccountFor(this); + } + + public String getName() { + return new File(getPath()).getName(); + } + + private long handle = 0; + private long listenerHandle = 0; + + Wallet(long handle) { + this.handle = handle; + } + + Wallet(long handle, int accountIndex) { + this.handle = handle; + this.accountIndex = accountIndex; + } + + @RequiredArgsConstructor + @Getter + public enum Device { + Device_Undefined(0, 0), + Device_Software(50, 200), + Device_Ledger(5, 20); + private final int accountLookahead; + private final int subaddressLookahead; + } + + public enum StatusEnum { + Status_Ok, + Status_Error, + Status_Critical + } + + public enum ConnectionStatus { + ConnectionStatus_Disconnected, + ConnectionStatus_Connected, + ConnectionStatus_WrongVersion + } + + public native String getSeed(String offset); + + public native String getSeedLanguage(); + + public native void setSeedLanguage(String language); + + public Status getStatus() { + return statusWithErrorString(); + } + + public Status getFullStatus() { + Wallet.Status walletStatus = statusWithErrorString(); + walletStatus.setConnectionStatus(getConnectionStatus()); + return walletStatus; + } + + private native Status statusWithErrorString(); + + public native synchronized boolean setPassword(String password); + + public String getAddress() { + return getAddress(accountIndex); + } + + public String getAddress(int accountIndex) { + return getAddressJ(accountIndex, 0); + } + + public String getSubaddress(int addressIndex) { + return getAddressJ(accountIndex, addressIndex); + } + + public String getSubaddress(int accountIndex, int addressIndex) { + return getAddressJ(accountIndex, addressIndex); + } + + private native String getAddressJ(int accountIndex, int addressIndex); + + public Subaddress getSubaddressObject(int accountIndex, int subAddressIndex) { + return new Subaddress(accountIndex, subAddressIndex, + getSubaddress(subAddressIndex), getSubaddressLabel(subAddressIndex)); + } + + public Subaddress getSubaddressObject(int subAddressIndex) { + Subaddress subaddress = getSubaddressObject(accountIndex, subAddressIndex); + long amount = 0; + for (TransactionInfo info : getHistory().getAll()) { + if ((info.addressIndex == subAddressIndex) + && (info.direction == TransactionInfo.Direction.Direction_In)) { + amount += info.amount; + } + } + subaddress.setAmount(amount); + return subaddress; + } + + public native String getPath(); + + public NetworkType getNetworkType() { + return NetworkType.fromInteger(nettype()); + } + + public native int 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; + + public native String getIntegratedAddress(String payment_id); + + public native String getSecretViewKey(); + + public native String getSecretSpendKey(); + + public boolean store() { + return store(""); + } + + public native synchronized boolean store(String path); + + public boolean close() { + disposePendingTransaction(); + return WalletManager.getInstance().close(this); + } + + public native String getFilename(); + + // virtual std::string keysFilename() const = 0; + public boolean init(long upper_transaction_size_limit) { + return initJ(WalletManager.getInstance().getDaemonAddress(), upper_transaction_size_limit, + WalletManager.getInstance().getDaemonUsername(), + WalletManager.getInstance().getDaemonPassword()); + } + + private native boolean initJ(String daemon_address, long upper_transaction_size_limit, + String daemon_username, String daemon_password); + +// virtual bool createWatchOnly(const std::string &path, const std::string &password, const std::string &language) const = 0; +// virtual void setRefreshFromBlockHeight(uint64_t refresh_from_block_height) = 0; + + public native void setRestoreHeight(long height); + + public native long getRestoreHeight(); + + // virtual void setRecoveringFromSeed(bool recoveringFromSeed) = 0; +// virtual bool connectToDaemon() = 0; + + public ConnectionStatus getConnectionStatus() { + int s = getConnectionStatusJ(); + return Wallet.ConnectionStatus.values()[s]; + } + + private native int getConnectionStatusJ(); + +//TODO virtual void setTrustedDaemon(bool arg) = 0; +//TODO virtual bool trustedDaemon() const = 0; + + public native boolean setProxy(String address); + + public long getBalance() { + return getBalance(accountIndex); + } + + public native long getBalance(int accountIndex); + + public native long getBalanceAll(); + + public long getUnlockedBalance() { + return getUnlockedBalance(accountIndex); + } + + public native long getUnlockedBalanceAll(); + + public native long getUnlockedBalance(int accountIndex); + + public native boolean isWatchOnly(); + + public native long getBlockChainHeight(); + + public native long getApproximateBlockChainHeight(); + + public native long getDaemonBlockChainHeight(); + + public native long getDaemonBlockChainTargetHeight(); + + boolean synced = false; + + public boolean isSynchronized() { + return synced; + } + + public void setSynchronized() { + this.synced = true; + } + + public static native String getDisplayAmount(long amount); + + public static native long getAmountFromString(String amount); + + public static native long getAmountFromDouble(double amount); + + public static native String generatePaymentId(); + + public static native boolean isPaymentIdValid(String payment_id); + + public static boolean isAddressValid(String address) { + return isAddressValid(address, WalletManager.getInstance().getNetworkType().getValue()); + } + + public static native boolean isAddressValid(String address, int networkType); + + public static native String getPaymentIdFromAddress(String address, int networkType); + + public static native long getMaximumAllowedAmount(); + + public native void startRefresh(); + + public native void pauseRefresh(); + + public native boolean refresh(); + + public native void refreshAsync(); + + public native void rescanBlockchainAsyncJ(); + + public void rescanBlockchainAsync() { + synced = false; + rescanBlockchainAsyncJ(); + } + +//TODO virtual void setAutoRefreshInterval(int millis) = 0; +//TODO virtual int autoRefreshInterval() const = 0; + + + private PendingTransaction pendingTransaction = null; + + public PendingTransaction getPendingTransaction() { + return pendingTransaction; + } + + public void disposePendingTransaction() { + if (pendingTransaction != null) { + disposeTransaction(pendingTransaction); + pendingTransaction = null; + } + } + + public PendingTransaction createTransaction(TxData txData) { + return createTransaction( + txData.getDestinationAddress(), + txData.getAmount(), + txData.getMixin(), + txData.getPriority()); + } + + public PendingTransaction createTransaction(String dst_addr, + long amount, int mixin_count, + PendingTransaction.Priority priority) { + disposePendingTransaction(); + int _priority = priority.getValue(); + long txHandle = + (amount == SWEEP_ALL ? + createSweepTransaction(dst_addr, "", mixin_count, _priority, + accountIndex) : + createTransactionJ(dst_addr, "", amount, mixin_count, _priority, + accountIndex)); + pendingTransaction = new PendingTransaction(txHandle); + return pendingTransaction; + } + + private native long createTransactionJ(String dst_addr, String payment_id, + long amount, int mixin_count, + int priority, int accountIndex); + + private native long createSweepTransaction(String dst_addr, String payment_id, + int mixin_count, + int priority, int accountIndex); + + + public PendingTransaction createSweepUnmixableTransaction() { + disposePendingTransaction(); + long txHandle = createSweepUnmixableTransactionJ(); + pendingTransaction = new PendingTransaction(txHandle); + return pendingTransaction; + } + + private native long createSweepUnmixableTransactionJ(); + +//virtual UnsignedTransaction * loadUnsignedTx(const std::string &unsigned_filename) = 0; +//virtual bool submitTransaction(const std::string &fileName) = 0; + + public native void disposeTransaction(PendingTransaction pendingTransaction); + +//virtual bool exportKeyImages(const std::string &filename) = 0; +//virtual bool importKeyImages(const std::string &filename) = 0; + + +//virtual TransactionHistory * history() const = 0; + + private TransactionHistory history = null; + + public TransactionHistory getHistory() { + if (history == null) { + history = new TransactionHistory(getHistoryJ(), accountIndex); + } + return history; + } + + private native long getHistoryJ(); + + public void refreshHistory() { + getHistory().refreshWithNotes(this); + } + +//virtual AddressBook * addressBook() const = 0; +//virtual void setListener(WalletListener *) = 0; + + private native long setListenerJ(WalletListener listener); + + public void setListener(WalletListener listener) { + this.listenerHandle = setListenerJ(listener); + } + + public native int getDefaultMixin(); + + public native void setDefaultMixin(int mixin); + + public native boolean setUserNote(String txid, String note); + + public native String getUserNote(String txid); + + public native String getTxKey(String txid); + +//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; + + private static final String NEW_ACCOUNT_NAME = "Untitled account"; // src/wallet/wallet2.cpp:941 + + public void addAccount() { + addAccount(NEW_ACCOUNT_NAME); + } + + public native void addAccount(String label); + + public String getAccountLabel() { + return getAccountLabel(accountIndex); + } + + public String getAccountLabel(int accountIndex) { + String label = getSubaddressLabel(accountIndex, 0); + if (label.equals(NEW_ACCOUNT_NAME)) { + String address = getAddress(accountIndex); + int len = address.length(); + label = address.substring(0, 6) + + "\u2026" + address.substring(len - 6, len); + } + return label; + } + + public String getSubaddressLabel(int addressIndex) { + return getSubaddressLabel(accountIndex, addressIndex); + } + + public native String getSubaddressLabel(int accountIndex, int addressIndex); + + public void setAccountLabel(String label) { + setAccountLabel(accountIndex, label); + } + + public void setAccountLabel(int accountIndex, String label) { + setSubaddressLabel(accountIndex, 0, label); + } + + public void setSubaddressLabel(int addressIndex, String label) { + setSubaddressLabel(accountIndex, addressIndex, label); + refreshHistory(); + } + + public native void setSubaddressLabel(int accountIndex, int addressIndex, String label); + + public native int getNumAccounts(); + + public int getNumSubaddresses() { + return getNumSubaddresses(accountIndex); + } + + public native int getNumSubaddresses(int accountIndex); + + public String getNewSubaddress() { + return getNewSubaddress(accountIndex); + } + + public String getNewSubaddress(int accountIndex) { + String timeStamp = new SimpleDateFormat("yyyy-MM-dd-HH:mm:ss", Locale.US).format(new Date()); + addSubaddress(accountIndex, timeStamp); + String subaddress = getLastSubaddress(accountIndex); + Timber.d("%d: %s", getNumSubaddresses(accountIndex) - 1, subaddress); + return subaddress; + } + + public native void addSubaddress(int accountIndex, String label); + + public String getLastSubaddress(int accountIndex) { + return getSubaddress(accountIndex, getNumSubaddresses(accountIndex) - 1); + } + + public Wallet.Device getDeviceType() { + int device = getDeviceTypeJ(); + return Wallet.Device.values()[device + 1]; // mapping is monero+1=android + } + + private native int getDeviceTypeJ(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/WalletListener.java b/app/src/main/java/com/m2049r/xmrwallet/model/WalletListener.java new file mode 100644 index 0000000..f7ee66f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/WalletListener.java @@ -0,0 +1,57 @@ +/* + * 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.model; + +public interface WalletListener { + /** + * moneySpent - called when money spent + * @param txId - transaction id + * @param amount - tvAmount + */ + void moneySpent(String txId, long amount); + + /** + * moneyReceived - called when money received + * @param txId - transaction id + * @param amount - tvAmount + */ + void moneyReceived(String txId, long amount); + + /** + * unconfirmedMoneyReceived - called when payment arrived in tx pool + * @param txId - transaction id + * @param amount - tvAmount + */ + void unconfirmedMoneyReceived(String txId, long amount); + + /** + * newBlock - called when new block received + * @param height - block height + */ + void newBlock(long height); + + /** + * updated - generic callback, called when any event (sent/received/block reveived/etc) happened with the wallet; + */ + void updated(); + + /** + * refreshed - called when wallet refreshed by background thread or explicitly refreshed by calling "refresh" synchronously + */ + void refreshed(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/model/WalletManager.java b/app/src/main/java/com/m2049r/xmrwallet/model/WalletManager.java new file mode 100644 index 0000000..f5aa743 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/model/WalletManager.java @@ -0,0 +1,341 @@ +/* + * 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.model; + +import com.m2049r.xmrwallet.XmrWalletApplication; +import com.m2049r.xmrwallet.data.Node; +import com.m2049r.xmrwallet.ledger.Ledger; +import com.m2049r.xmrwallet.util.RestoreHeight; + +import java.io.File; +import java.io.FilenameFilter; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import timber.log.Timber; + +public class WalletManager { + + static { + System.loadLibrary("monerujo"); + } + + // no need to keep a reference to the REAL WalletManager (we get it every tvTime we need it) + private static WalletManager Instance = null; + + public static synchronized WalletManager getInstance() { + if (WalletManager.Instance == null) { + WalletManager.Instance = new WalletManager(); + } + + return WalletManager.Instance; + } + + public String addressPrefix() { + return addressPrefix(getNetworkType()); + } + + static public String addressPrefix(NetworkType networkType) { + switch (networkType) { + case NetworkType_Testnet: + return "9A-"; + case NetworkType_Mainnet: + return "4-"; + case NetworkType_Stagenet: + return "5-"; + default: + throw new IllegalStateException("Unsupported Network: " + networkType); + } + } + + private Wallet managedWallet = null; + + public Wallet getWallet() { + return managedWallet; + } + + private void manageWallet(Wallet wallet) { + Timber.d("Managing %s", wallet.getName()); + managedWallet = wallet; + } + + private void unmanageWallet(Wallet wallet) { + if (wallet == null) { + throw new IllegalArgumentException("Cannot unmanage null!"); + } + if (getWallet() == null) { + throw new IllegalStateException("No wallet under management!"); + } + if (getWallet() != wallet) { + throw new IllegalStateException(wallet.getName() + " not under management!"); + } + Timber.d("Unmanaging %s", managedWallet.getName()); + managedWallet = null; + } + + public Wallet createWallet(File aFile, String password, String language, long height) { + long walletHandle = createWalletJ(aFile.getAbsolutePath(), password, language, getNetworkType().getValue()); + Wallet wallet = new Wallet(walletHandle); + manageWallet(wallet); + if (wallet.getStatus().isOk()) { + // (Re-)Estimate restore height based on what we know + final long oldHeight = wallet.getRestoreHeight(); + // Go back 4 days if we don't have a precise restore height + Calendar restoreDate = Calendar.getInstance(); + restoreDate.add(Calendar.DAY_OF_MONTH, -4); + final long restoreHeight = + (height > -1) ? height : RestoreHeight.getInstance().getHeight(restoreDate.getTime()); + wallet.setRestoreHeight(restoreHeight); + Timber.d("Changed Restore Height from %d to %d", oldHeight, wallet.getRestoreHeight()); + wallet.setPassword(password); // this rewrites the keys file (which contains the restore height) + } else + Timber.e(wallet.getStatus().toString()); + return wallet; + } + + private native long createWalletJ(String path, String password, String language, int networkType); + + public Wallet openAccount(String path, int accountIndex, String password) { + long walletHandle = openWalletJ(path, password, getNetworkType().getValue()); + Wallet wallet = new Wallet(walletHandle, accountIndex); + manageWallet(wallet); + return wallet; + } + + public Wallet openWallet(String path, String password) { + long walletHandle = openWalletJ(path, password, getNetworkType().getValue()); + Wallet wallet = new Wallet(walletHandle); + manageWallet(wallet); + return wallet; + } + + private native long openWalletJ(String path, String password, int networkType); + + public Wallet recoveryWallet(File aFile, String password, + String mnemonic, String offset, + long restoreHeight) { + long walletHandle = recoveryWalletJ(aFile.getAbsolutePath(), password, + mnemonic, offset, + getNetworkType().getValue(), restoreHeight); + Wallet wallet = new Wallet(walletHandle); + manageWallet(wallet); + return wallet; + } + + private native long recoveryWalletJ(String path, String password, + String mnemonic, String offset, + int networkType, long restoreHeight); + + public Wallet createWalletWithKeys(File aFile, String password, String language, long restoreHeight, + String addressString, String viewKeyString, String spendKeyString) { + long walletHandle = createWalletFromKeysJ(aFile.getAbsolutePath(), password, + language, getNetworkType().getValue(), restoreHeight, + addressString, viewKeyString, spendKeyString); + Wallet wallet = new Wallet(walletHandle); + manageWallet(wallet); + return wallet; + } + + private native long createWalletFromKeysJ(String path, String password, + String language, + int networkType, + long restoreHeight, + String addressString, + String viewKeyString, + String spendKeyString); + + public Wallet createWalletFromDevice(File aFile, String password, long restoreHeight, + String deviceName) { + long walletHandle = createWalletFromDeviceJ(aFile.getAbsolutePath(), password, + getNetworkType().getValue(), deviceName, restoreHeight, + Ledger.SUBADDRESS_LOOKAHEAD); + Wallet wallet = new Wallet(walletHandle); + manageWallet(wallet); + return wallet; + } + + private native long createWalletFromDeviceJ(String path, String password, + int networkType, + String deviceName, + long restoreHeight, + String subaddressLookahead); + + + public native boolean closeJ(Wallet wallet); + + public boolean close(Wallet wallet) { + unmanageWallet(wallet); + boolean closed = closeJ(wallet); + if (!closed) { + // in case we could not close it + // we manage it again + manageWallet(wallet); + } + return closed; + } + + public boolean walletExists(File aFile) { + return walletExists(aFile.getAbsolutePath()); + } + + public native boolean walletExists(String path); + + public native boolean verifyWalletPassword(String keys_file_name, String password, boolean watch_only); + + public boolean verifyWalletPasswordOnly(String keys_file_name, String password) { + return queryWalletDeviceJ(keys_file_name, password) >= 0; + } + + public Wallet.Device queryWalletDevice(String keys_file_name, String password) { + int device = queryWalletDeviceJ(keys_file_name, password); + return Wallet.Device.values()[device + 1]; // mapping is monero+1=android + } + + private native int queryWalletDeviceJ(String keys_file_name, String password); + + //public native List findWallets(String path); // this does not work - some error in boost + + public class WalletInfo implements Comparable { + @Getter + final private File path; + @Getter + final private String name; + + public WalletInfo(File wallet) { + path = wallet.getParentFile(); + name = wallet.getName(); + } + + @Override + public int compareTo(WalletInfo another) { + return name.toLowerCase().compareTo(another.name.toLowerCase()); + } + } + + public List findWallets(File path) { + List wallets = new ArrayList<>(); + Timber.d("Scanning: %s", path.getAbsolutePath()); + File[] found = path.listFiles(new FilenameFilter() { + public boolean accept(File dir, String filename) { + return filename.endsWith(".keys"); + } + }); + for (int i = 0; i < found.length; i++) { + String filename = found[i].getName(); + File f = new File(found[i].getParent(), filename.substring(0, filename.length() - 5)); // 5 is length of ".keys"+1 + wallets.add(new WalletInfo(f)); + } + return wallets; + } + +//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; + + private String daemonAddress = null; + private final NetworkType networkType = XmrWalletApplication.getNetworkType(); + + public NetworkType getNetworkType() { + return networkType; + } + + // this should not be called on the main thread as it connects to the node (and takes a long time) + public void setDaemon(Node node) { + if (node != null) { + this.daemonAddress = node.getAddress(); + if (networkType != node.getNetworkType()) + throw new IllegalArgumentException("network type does not match"); + this.daemonUsername = node.getUsername(); + this.daemonPassword = node.getPassword(); + setDaemonAddressJ(daemonAddress); + } else { + this.daemonAddress = null; + this.daemonUsername = ""; + this.daemonPassword = ""; + //setDaemonAddressJ(""); // don't disconnect as monero code blocks for many seconds! + //TODO: need to do something about that later + } + } + + public String getDaemonAddress() { + if (daemonAddress == null) { + throw new IllegalStateException("use setDaemon() to initialise daemon and net first!"); + } + return this.daemonAddress; + } + + private native void setDaemonAddressJ(String address); + + private String daemonUsername = ""; + + public String getDaemonUsername() { + return daemonUsername; + } + + private String daemonPassword = ""; + + public String getDaemonPassword() { + return daemonPassword; + } + + public native int getDaemonVersion(); + + public native long getBlockchainHeight(); + + public native long getBlockchainTargetHeight(); + + public native long getNetworkDifficulty(); + + public native double getMiningHashRate(); + + public native long getBlockTarget(); + + public native boolean isMining(); + + public native boolean startMining(String address, boolean background_mining, boolean ignore_battery); + + public native boolean stopMining(); + + public native String resolveOpenAlias(String address, boolean dnssec_valid); + + public native boolean setProxy(String address); + +//TODO static std::tuple checkUpdates(const std::string &software, const std::string &subdir); + + static public native void initLogger(String argv0, String defaultLogBaseName); + + //TODO: maybe put these in an enum like in monero core - but why? + static public int LOGLEVEL_SILENT = -1; + static public int LOGLEVEL_WARN = 0; + static public int LOGLEVEL_INFO = 1; + static public int LOGLEVEL_DEBUG = 2; + static public int LOGLEVEL_TRACE = 3; + static public int LOGLEVEL_MAX = 4; + + static public native void setLogLevel(int level); + + static public native void logDebug(String category, String message); + + static public native void logInfo(String category, String message); + + static public native void logWarning(String category, String message); + + static public native void logError(String category, String message); + + static public native String moneroVersion(); +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingActivity.java b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingActivity.java new file mode 100644 index 0000000..c2793a1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingActivity.java @@ -0,0 +1,123 @@ +/* + * 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.onboarding; + +import android.content.Intent; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; +import com.m2049r.xmrwallet.LoginActivity; +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.util.KeyStoreHelper; + +public class OnBoardingActivity extends AppCompatActivity implements OnBoardingAdapter.Listener { + + private OnBoardingViewPager pager; + private OnBoardingAdapter pagerAdapter; + private Button nextButton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_on_boarding); + + nextButton = findViewById(R.id.buttonNext); + + pager = findViewById(R.id.pager); + pagerAdapter = new OnBoardingAdapter(this, this); + pager.setAdapter(pagerAdapter); + int pixels = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics()); + pager.setPageMargin(pixels); + pager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + setButtonState(position); + } + }); + + final TabLayout tabLayout = (TabLayout) findViewById(R.id.tabLayout); + if (pagerAdapter.getCount() > 1) { + tabLayout.setupWithViewPager(pager, true); + LinearLayout tabStrip = ((LinearLayout) tabLayout.getChildAt(0)); + for (int i = 0; i < tabStrip.getChildCount(); i++) { + tabStrip.getChildAt(i).setClickable(false); + } + } else { + tabLayout.setVisibility(View.GONE); + } + + nextButton.setOnClickListener(v -> { + final int item = pager.getCurrentItem(); + if (item + 1 >= pagerAdapter.getCount()) { + finishOnboarding(); + } else { + pager.setCurrentItem(item + 1); + } + }); + + // let old users who have fingerprint wallets already agree for fingerprint sending + OnBoardingScreen.FPSEND.setMustAgree(KeyStoreHelper.hasStoredPasswords(this)); + + for (int i = 0; i < OnBoardingScreen.values().length; i++) { + agreed[i] = !OnBoardingScreen.values()[i].isMustAgree(); + } + + setButtonState(0); + } + + private void finishOnboarding() { + nextButton.setEnabled(false); + OnBoardingManager.setOnBoardingShown(getApplicationContext()); + startActivity(new Intent(this, LoginActivity.class)); + finish(); + } + + boolean[] agreed = new boolean[OnBoardingScreen.values().length]; + + @Override + public void setAgreeClicked(int position, boolean isChecked) { + agreed[position] = isChecked; + setButtonState(position); + } + + @Override + public boolean isAgreeClicked(int position) { + return agreed[position]; + } + + @Override + public void setButtonState(int position) { + nextButton.setEnabled(agreed[position]); + if (nextButton.isEnabled()) + pager.setAllowedSwipeDirection(OnBoardingViewPager.SwipeDirection.ALL); + else + pager.setAllowedSwipeDirection(OnBoardingViewPager.SwipeDirection.LEFT); + if (pager.getCurrentItem() + 1 == pagerAdapter.getCount()) { // last page + nextButton.setText(R.string.onboarding_button_ready); + } else { + nextButton.setText(R.string.onboarding_button_next); + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingAdapter.java b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingAdapter.java new file mode 100644 index 0000000..adfc7d9 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingAdapter.java @@ -0,0 +1,92 @@ +/* + * 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.onboarding; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.viewpager.widget.PagerAdapter; + +import com.m2049r.xmrwallet.R; + +import timber.log.Timber; + +public class OnBoardingAdapter extends PagerAdapter { + + interface Listener { + void setAgreeClicked(int position, boolean isChecked); + + boolean isAgreeClicked(int position); + + void setButtonState(int position); + } + + private final Context context; + private Listener listener; + + OnBoardingAdapter(final Context context, final Listener listener) { + this.context = context; + this.listener = listener; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup collection, int position) { + LayoutInflater inflater = LayoutInflater.from(context); + final View view = inflater.inflate(R.layout.view_onboarding, collection, false); + final OnBoardingScreen onBoardingScreen = OnBoardingScreen.values()[position]; + + final Drawable drawable = ContextCompat.getDrawable(context, onBoardingScreen.getDrawable()); + ((ImageView) view.findViewById(R.id.onboardingImage)).setImageDrawable(drawable); + ((TextView) view.findViewById(R.id.onboardingTitle)).setText(onBoardingScreen.getTitle()); + ((TextView) view.findViewById(R.id.onboardingInformation)).setText(onBoardingScreen.getInformation()); + if (onBoardingScreen.isMustAgree()) { + final CheckBox agree = ((CheckBox) view.findViewById(R.id.onboardingAgree)); + agree.setVisibility(View.VISIBLE); + agree.setChecked(listener.isAgreeClicked(position)); + agree.setOnClickListener(v -> { + listener.setAgreeClicked(position, ((CheckBox) v).isChecked()); + }); + } + collection.addView(view); + return view; + } + + @Override + public int getCount() { + return OnBoardingScreen.values().length; + } + + @Override + public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { + Timber.d("destroy " + position); + collection.removeView((View) view); + } + + @Override + public boolean isViewFromObject(@NonNull final View view, @NonNull final Object object) { + return view == object; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingManager.java b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingManager.java new file mode 100644 index 0000000..eb28331 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingManager.java @@ -0,0 +1,51 @@ +/* + * 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.onboarding; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.m2049r.xmrwallet.util.KeyStoreHelper; + +import java.util.Date; + +import timber.log.Timber; + +public class OnBoardingManager { + + private static final String PREFS_ONBOARDING = "PREFS_ONBOARDING"; + private static final String ONBOARDING_SHOWN = "ONBOARDING_SHOWN"; + + public static boolean shouldShowOnBoarding(final Context context) { + return !getSharedPreferences(context).contains(ONBOARDING_SHOWN); + } + + public static void setOnBoardingShown(final Context context) { + Timber.d("Set onboarding shown."); + SharedPreferences sharedPreferences = getSharedPreferences(context); + sharedPreferences.edit().putLong(ONBOARDING_SHOWN, new Date().getTime()).apply(); + } + + public static void clearOnBoardingShown(final Context context) { + SharedPreferences sharedPreferences = getSharedPreferences(context); + sharedPreferences.edit().remove(ONBOARDING_SHOWN).apply(); + } + + private static SharedPreferences getSharedPreferences(final Context context) { + return context.getSharedPreferences(PREFS_ONBOARDING, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingScreen.java b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingScreen.java new file mode 100644 index 0000000..c6227d5 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingScreen.java @@ -0,0 +1,59 @@ +/* + * 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.onboarding; + +import com.m2049r.xmrwallet.R; + +enum OnBoardingScreen { + WELCOME(R.string.onboarding_welcome_title, R.string.onboarding_welcome_information, R.drawable.ic_onboarding_welcome, false), + SEED(R.string.onboarding_seed_title, R.string.onboarding_seed_information, R.drawable.ic_onboarding_seed, true), + FPSEND(R.string.onboarding_fpsend_title, R.string.onboarding_fpsend_information, R.drawable.ic_onboarding_fingerprint, false), + XMRTO(R.string.onboarding_xmrto_title, R.string.onboarding_xmrto_information, R.drawable.ic_onboarding_xmrto, false), + NODES(R.string.onboarding_nodes_title, R.string.onboarding_nodes_information, R.drawable.ic_onboarding_nodes, false); + + private final int title; + private final int information; + private final int drawable; + private boolean mustAgree; + + OnBoardingScreen(final int title, final int information, final int drawable, final boolean mustAgree) { + this.title = title; + this.information = information; + this.drawable = drawable; + this.mustAgree = mustAgree; + } + + public int getTitle() { + return title; + } + + public int getInformation() { + return information; + } + + public int getDrawable() { + return drawable; + } + + public boolean isMustAgree() { + return mustAgree; + } + + public boolean setMustAgree(boolean mustAgree) { + return this.mustAgree = mustAgree; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingViewPager.java b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingViewPager.java new file mode 100644 index 0000000..3e26352 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/onboarding/OnBoardingViewPager.java @@ -0,0 +1,86 @@ +/* + * 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. + */ +// based on https://stackoverflow.com/a/34076649 + +package com.m2049r.xmrwallet.onboarding; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import androidx.viewpager.widget.ViewPager; + +public class OnBoardingViewPager extends ViewPager { + + public enum SwipeDirection { + ALL, LEFT, RIGHT, NONE; + } + + private float initialXValue; + private SwipeDirection direction; + + public OnBoardingViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + this.direction = SwipeDirection.ALL; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (this.IsSwipeAllowed(event)) { + return super.onTouchEvent(event); + } + + return false; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (this.IsSwipeAllowed(event)) { + return super.onInterceptTouchEvent(event); + } + + return false; + } + + private boolean IsSwipeAllowed(MotionEvent event) { + if (this.direction == SwipeDirection.ALL) return true; + + if (direction == SwipeDirection.NONE)//disable any swipe + return false; + + if (event.getAction() == MotionEvent.ACTION_DOWN) { + initialXValue = event.getX(); + return true; + } + + if (event.getAction() == MotionEvent.ACTION_MOVE) { + float diffX = event.getX() - initialXValue; + if (diffX > 0 && direction == SwipeDirection.RIGHT) { + // swipe from left to right detected + return false; + } else if (diffX < 0 && direction == SwipeDirection.LEFT) { + // swipe from right to left detected + return false; + } + } + + return true; + } + + public void setAllowedSwipeDirection(SwipeDirection direction) { + this.direction = direction; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/MoneroHandlerThread.java b/app/src/main/java/com/m2049r/xmrwallet/service/MoneroHandlerThread.java new file mode 100644 index 0000000..79ac246 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/MoneroHandlerThread.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * 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.service; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.Process; + + +/** + * Handy class for starting a new thread that has a looper. The looper can then be + * used to create handler classes. Note that start() must still be called. + * The started Thread has a stck size of STACK_SIZE (=5MB) + */ +public class MoneroHandlerThread extends Thread { + // from src/cryptonote_config.h + static public final long THREAD_STACK_SIZE = 5 * 1024 * 1024; + private int mPriority; + private int mTid = -1; + private Looper mLooper; + + public MoneroHandlerThread(String name) { + super(null, null, name, THREAD_STACK_SIZE); + mPriority = Process.THREAD_PRIORITY_DEFAULT; + } + + /** + * Constructs a MoneroHandlerThread. + * + * @param name + * @param priority The priority to run the thread at. The value supplied must be from + * {@link android.os.Process} and not from java.lang.Thread. + */ + MoneroHandlerThread(String name, int priority) { + super(null, null, name, THREAD_STACK_SIZE); + mPriority = priority; + } + + /** + * Call back method that can be explicitly overridden if needed to execute some + * setup before Looper loops. + */ + + private void onLooperPrepared() { + } + + @Override + public void run() { + mTid = Process.myTid(); + Looper.prepare(); + synchronized (this) { + mLooper = Looper.myLooper(); + notifyAll(); + } + Process.setThreadPriority(mPriority); + onLooperPrepared(); + Looper.loop(); + mTid = -1; + } + + /** + * This method returns the Looper associated with this thread. If this thread not been started + * or for any reason is isAlive() returns false, this method will return null. If this thread + * has been started, this method will block until the looper has been initialized. + * + * @return The looper. + */ + Looper getLooper() { + if (!isAlive()) { + return null; + } + + // If the thread has been started, wait until the looper has been created. + synchronized (this) { + while (isAlive() && mLooper == null) { + try { + wait(); + } catch (InterruptedException e) { + } + } + } + return mLooper; + } + + /** + * Quits the handler thread's looper. + *

+ * Causes the handler thread's looper to terminate without processing any + * more messages in the message queue. + *

+ * Any attempt to post messages to the queue after the looper is asked to quit will fail. + * For example, the {@link Handler#sendMessage(Message)} method will return false. + *

+ * Using this method may be unsafe because some messages may not be delivered + * before the looper terminates. Consider using {@link #quitSafely} instead to ensure + * that all pending work is completed in an orderly manner. + *

+ * + * @return True if the looper looper has been asked to quit or false if the + * thread had not yet started running. + * @see #quitSafely + */ + public boolean quit() { + Looper looper = getLooper(); + if (looper != null) { + looper.quit(); + return true; + } + return false; + } + + /** + * Quits the handler thread's looper safely. + *

+ * Causes the handler thread's looper to terminate as soon as all remaining messages + * in the message queue that are already due to be delivered have been handled. + * Pending delayed messages with due times in the future will not be delivered. + *

+ * Any attempt to post messages to the queue after the looper is asked to quit will fail. + * For example, the {@link Handler#sendMessage(Message)} method will return false. + *

+ * If the thread has not been started or has finished (that is if + * {@link #getLooper} returns null), then false is returned. + * Otherwise the looper is asked to quit and true is returned. + *

+ * + * @return True if the looper looper has been asked to quit or false if the + * thread had not yet started running. + */ + public boolean quitSafely() { + Looper looper = getLooper(); + if (looper != null) { + looper.quitSafely(); + return true; + } + return false; + } + + /** + * Returns the identifier of this thread. See Process.myTid(). + */ + public int getThreadId() { + return mTid; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/WalletService.java b/app/src/main/java/com/m2049r/xmrwallet/service/WalletService.java new file mode 100644 index 0000000..ece003e --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/WalletService.java @@ -0,0 +1,595 @@ +/* + * 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.service; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Process; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.WalletActivity; +import com.m2049r.xmrwallet.data.TxData; +import com.m2049r.xmrwallet.model.PendingTransaction; +import com.m2049r.xmrwallet.model.Wallet; +import com.m2049r.xmrwallet.model.WalletListener; +import com.m2049r.xmrwallet.model.WalletManager; +import com.m2049r.xmrwallet.util.Helper; +import com.m2049r.xmrwallet.util.LocaleHelper; +import com.m2049r.xmrwallet.util.NetCipherHelper; + +import timber.log.Timber; + +public class WalletService extends Service { + public static boolean Running = false; + + final static int NOTIFICATION_ID = 2049; + final static String CHANNEL_ID = "m_service"; + + public static final String REQUEST_WALLET = "wallet"; + public static final String REQUEST = "request"; + + public static final String REQUEST_CMD_LOAD = "load"; + public static final String REQUEST_CMD_LOAD_PW = "walletPassword"; + + public static final String REQUEST_CMD_STORE = "store"; + + public static final String REQUEST_CMD_TX = "createTX"; + public static final String REQUEST_CMD_TX_DATA = "data"; + public static final String REQUEST_CMD_TX_TAG = "tag"; + + public static final String REQUEST_CMD_SWEEP = "sweepTX"; + + public static final String REQUEST_CMD_SEND = "send"; + public static final String REQUEST_CMD_SEND_NOTES = "notes"; + + public static final int START_SERVICE = 1; + public static final int STOP_SERVICE = 2; + + private MyWalletListener listener = null; + + private class MyWalletListener implements WalletListener { + boolean updated = true; + + void start() { + Timber.d("MyWalletListener.start()"); + Wallet wallet = getWallet(); + if (wallet == null) throw new IllegalStateException("No wallet!"); + wallet.setListener(this); + wallet.startRefresh(); + } + + void stop() { + Timber.d("MyWalletListener.stop()"); + Wallet wallet = getWallet(); + if (wallet == null) throw new IllegalStateException("No wallet!"); + wallet.pauseRefresh(); + wallet.setListener(null); + } + + // WalletListener callbacks + public void moneySpent(String txId, long amount) { + Timber.d("moneySpent() %d @ %s", amount, txId); + } + + public void moneyReceived(String txId, long amount) { + Timber.d("moneyReceived() %d @ %s", amount, txId); + } + + public void unconfirmedMoneyReceived(String txId, long amount) { + Timber.d("unconfirmedMoneyReceived() %d @ %s", amount, txId); + } + + private long lastBlockTime = 0; + private int lastTxCount = 0; + + public void newBlock(long height) { + final Wallet wallet = getWallet(); + if (wallet == null) throw new IllegalStateException("No wallet!"); + // don't flood with an update for every block ... + if (lastBlockTime < System.currentTimeMillis() - 2000) { + lastBlockTime = System.currentTimeMillis(); + Timber.d("newBlock() @ %d with observer %s", height, observer); + if (observer != null) { + boolean fullRefresh = false; + updateDaemonState(wallet, wallet.isSynchronized() ? height : 0); + if (!wallet.isSynchronized()) { + updated = true; + // we want to see our transactions as they come in + wallet.refreshHistory(); + int txCount = wallet.getHistory().getCount(); + if (txCount > lastTxCount) { + // update the transaction list only if we have more than before + lastTxCount = txCount; + fullRefresh = true; + } + } + if (observer != null) + observer.onRefreshed(wallet, fullRefresh); + } + } + } + + public void updated() { + Timber.d("updated()"); + Wallet wallet = getWallet(); + if (wallet == null) throw new IllegalStateException("No wallet!"); + updated = true; + } + + public void refreshed() { // this means it's synced + Timber.d("refreshed()"); + final Wallet wallet = getWallet(); + if (wallet == null) throw new IllegalStateException("No wallet!"); + wallet.setSynchronized(); + if (updated) { + updateDaemonState(wallet, wallet.getBlockChainHeight()); + wallet.refreshHistory(); + if (observer != null) { + updated = !observer.onRefreshed(wallet, true); + } + } + } + } + + private long lastDaemonStatusUpdate = 0; + private long daemonHeight = 0; + private Wallet.ConnectionStatus connectionStatus = Wallet.ConnectionStatus.ConnectionStatus_Disconnected; + private static final long STATUS_UPDATE_INTERVAL = 120000; // 120s (blocktime) + + private void updateDaemonState(Wallet wallet, long height) { + long t = System.currentTimeMillis(); + if (height > 0) { // if we get a height, we are connected + daemonHeight = height; + connectionStatus = Wallet.ConnectionStatus.ConnectionStatus_Connected; + lastDaemonStatusUpdate = t; + } else { + if (t - lastDaemonStatusUpdate > STATUS_UPDATE_INTERVAL) { + lastDaemonStatusUpdate = t; + // these calls really connect to the daemon - wasting time + daemonHeight = wallet.getDaemonBlockChainHeight(); + if (daemonHeight > 0) { + // if we get a valid height, then obviously we are connected + connectionStatus = Wallet.ConnectionStatus.ConnectionStatus_Connected; + } else { + connectionStatus = Wallet.ConnectionStatus.ConnectionStatus_Disconnected; + } + } + } + } + + public long getDaemonHeight() { + return this.daemonHeight; + } + + public Wallet.ConnectionStatus getConnectionStatus() { + return this.connectionStatus; + } + + ///////////////////////////////////////////// + // communication back to client (activity) // + ///////////////////////////////////////////// + // NB: This allows for only one observer, i.e. only a single activity bound here + + private Observer observer = null; + + public void setObserver(Observer anObserver) { + observer = anObserver; + Timber.d("setObserver %s", observer); + } + + public interface Observer { + boolean onRefreshed(Wallet wallet, boolean full); + + void onProgress(String text); + + void onProgress(int n); + + void onWalletStored(boolean success); + + void onTransactionCreated(String tag, PendingTransaction pendingTransaction); + + void onTransactionSent(String txid); + + void onSendTransactionFailed(String error); + + void onWalletStarted(Wallet.Status walletStatus); + + void onWalletOpen(Wallet.Device device); + } + + String progressText = null; + int progressValue = -1; + + private void showProgress(String text) { + progressText = text; + if (observer != null) { + observer.onProgress(text); + } + } + + private void showProgress(int n) { + progressValue = n; + if (observer != null) { + observer.onProgress(n); + } + } + + public String getProgressText() { + return progressText; + } + + public int getProgressValue() { + return progressValue; + } + + // + public Wallet getWallet() { + return WalletManager.getInstance().getWallet(); + } + + ///////////////////////////////////////////// + ///////////////////////////////////////////// + + private WalletService.ServiceHandler mServiceHandler; + + private boolean errorState = false; + + // Handler that receives messages from the thread + private final class ServiceHandler extends Handler { + ServiceHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + Timber.d("Handling %s", msg.arg2); + if (errorState) { + Timber.i("In error state."); + // also, we have already stopped ourselves + return; + } + switch (msg.arg2) { + case START_SERVICE: { + Bundle extras = msg.getData(); + String cmd = extras.getString(REQUEST, null); + switch (cmd) { + case REQUEST_CMD_LOAD: + String walletId = extras.getString(REQUEST_WALLET, null); + String walletPw = extras.getString(REQUEST_CMD_LOAD_PW, null); + Timber.d("LOAD wallet %s", walletId); + if (walletId != null) { + showProgress(getString(R.string.status_wallet_loading)); + showProgress(10); + Wallet.Status walletStatus = start(walletId, walletPw); + if (observer != null) observer.onWalletStarted(walletStatus); + if ((walletStatus == null) || !walletStatus.isOk()) { + errorState = true; + stop(); + } + } + break; + case REQUEST_CMD_STORE: { + Wallet myWallet = getWallet(); + if (myWallet == null) break; + Timber.d("STORE wallet: %s", myWallet.getName()); + boolean rc = myWallet.store(); + Timber.d("wallet stored: %s with rc=%b", myWallet.getName(), rc); + if (!rc) { + Timber.w("Wallet store failed: %s", myWallet.getStatus().getErrorString()); + } + if (observer != null) observer.onWalletStored(rc); + break; + } + case REQUEST_CMD_TX: { + Wallet myWallet = getWallet(); + if (myWallet == null) break; + Timber.d("CREATE TX for wallet: %s", myWallet.getName()); + myWallet.disposePendingTransaction(); // remove any old pending tx + + TxData txData = extras.getParcelable(REQUEST_CMD_TX_DATA); + String txTag = extras.getString(REQUEST_CMD_TX_TAG); + PendingTransaction pendingTransaction = myWallet.createTransaction(txData); + PendingTransaction.Status status = pendingTransaction.getStatus(); + Timber.d("transaction status %s", status); + if (status != PendingTransaction.Status.Status_Ok) { + Timber.w("Create Transaction failed: %s", pendingTransaction.getErrorString()); + } + if (observer != null) { + observer.onTransactionCreated(txTag, pendingTransaction); + } else { + myWallet.disposePendingTransaction(); + } + break; + } + case REQUEST_CMD_SWEEP: { + Wallet myWallet = getWallet(); + if (myWallet == null) break; + Timber.d("SWEEP TX for wallet: %s", myWallet.getName()); + myWallet.disposePendingTransaction(); // remove any old pending tx + + String txTag = extras.getString(REQUEST_CMD_TX_TAG); + PendingTransaction pendingTransaction = myWallet.createSweepUnmixableTransaction(); + PendingTransaction.Status status = pendingTransaction.getStatus(); + Timber.d("transaction status %s", status); + if (status != PendingTransaction.Status.Status_Ok) { + Timber.w("Create Transaction failed: %s", pendingTransaction.getErrorString()); + } + if (observer != null) { + observer.onTransactionCreated(txTag, pendingTransaction); + } else { + myWallet.disposePendingTransaction(); + } + break; + } + case REQUEST_CMD_SEND: { + Wallet myWallet = getWallet(); + if (myWallet == null) break; + Timber.d("SEND TX for wallet: %s", myWallet.getName()); + PendingTransaction pendingTransaction = myWallet.getPendingTransaction(); + if (pendingTransaction == null) { + throw new IllegalArgumentException("PendingTransaction is null"); // die + } + if (pendingTransaction.getStatus() != PendingTransaction.Status.Status_Ok) { + Timber.e("PendingTransaction is %s", pendingTransaction.getStatus()); + final String error = pendingTransaction.getErrorString(); + myWallet.disposePendingTransaction(); // it's broken anyway + if (observer != null) observer.onSendTransactionFailed(error); + return; + } + final String txid = pendingTransaction.getFirstTxId(); // tx ids vanish after commit()! + + boolean success = pendingTransaction.commit("", true); + if (success) { + myWallet.disposePendingTransaction(); + if (observer != null) observer.onTransactionSent(txid); + String notes = extras.getString(REQUEST_CMD_SEND_NOTES); + if ((notes != null) && (!notes.isEmpty())) { + myWallet.setUserNote(txid, notes); + } + boolean rc = myWallet.store(); + Timber.d("wallet stored: %s with rc=%b", myWallet.getName(), rc); + if (!rc) { + Timber.w("Wallet store failed: %s", myWallet.getStatus().getErrorString()); + } + if (observer != null) observer.onWalletStored(rc); + listener.updated = true; + } else { + final String error = pendingTransaction.getErrorString(); + myWallet.disposePendingTransaction(); + if (observer != null) observer.onSendTransactionFailed(error); + return; + } + break; + } + } + } + break; + case STOP_SERVICE: + stop(); + break; + default: + Timber.e("UNKNOWN %s", msg.arg2); + } + } + } + + @Override + public void onCreate() { + // We are using a HandlerThread and a Looper to avoid loading and closing + // concurrency + MoneroHandlerThread thread = new MoneroHandlerThread("WalletService", + Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + + // Get the HandlerThread's Looper and use it for our Handler + final Looper serviceLooper = thread.getLooper(); + mServiceHandler = new WalletService.ServiceHandler(serviceLooper); + + Timber.d("Service created"); + } + + @Override + public void onDestroy() { + Timber.d("onDestroy()"); + if (this.listener != null) { + Timber.w("onDestroy() with active listener"); + // no need to stop() here because the wallet closing should have been triggered + // through onUnbind() already + } + } + + @Override + protected void attachBaseContext(Context context) { + super.attachBaseContext(LocaleHelper.setPreferredLocale(context)); + } + + public class WalletServiceBinder extends Binder { + public WalletService getService() { + return WalletService.this; + } + } + + private final IBinder mBinder = new WalletServiceBinder(); + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Running = true; + // when the activity starts the service, it expects to start it for a new wallet + // the service is possibly still occupied with saving the last opened wallet + // so we queue the open request + // this should not matter since the old activity is not getting updates + // and the new one is not listening yet (although it will be bound) + Timber.d("onStartCommand()"); + // For each start request, send a message to start a job and deliver the + // start ID so we know which request we're stopping when we finish the job + Message msg = mServiceHandler.obtainMessage(); + msg.arg2 = START_SERVICE; + if (intent != null) { + msg.setData(intent.getExtras()); + mServiceHandler.sendMessage(msg); + return START_STICKY; + } else { + // process restart - don't do anything - let system kill it again + stop(); + return START_NOT_STICKY; + } + } + + @Override + public IBinder onBind(Intent intent) { + // Very first client binds + Timber.d("onBind()"); + return mBinder; + } + + @Override + public boolean onUnbind(Intent intent) { + Timber.d("onUnbind()"); + // All clients have unbound with unbindService() + Message msg = mServiceHandler.obtainMessage(); + msg.arg2 = STOP_SERVICE; + mServiceHandler.sendMessage(msg); + Timber.d("onUnbind() message sent"); + return true; // true is important so that onUnbind is also called next time + } + + @Nullable + private Wallet.Status start(String walletName, String walletPassword) { + Timber.d("start()"); + startNotfication(); + showProgress(getString(R.string.status_wallet_loading)); + showProgress(10); + if (listener == null) { + Timber.d("start() loadWallet"); + Wallet aWallet = loadWallet(walletName, walletPassword); + if (aWallet == null) return null; + Wallet.Status walletStatus = aWallet.getFullStatus(); + if (!walletStatus.isOk()) { + aWallet.close(); + return walletStatus; + } + listener = new MyWalletListener(); + listener.start(); + showProgress(100); + } + showProgress(getString(R.string.status_wallet_connecting)); + showProgress(101); + // if we try to refresh the history here we get occasional segfaults! + // doesnt matter since we update as soon as we get a new block anyway + Timber.d("start() done"); + return getWallet().getFullStatus(); + } + + public void stop() { + Timber.d("stop()"); + setObserver(null); // in case it was not reset already + if (listener != null) { + listener.stop(); + Wallet myWallet = getWallet(); + Timber.d("stop() closing"); + myWallet.close(); + Timber.d("stop() closed"); + listener = null; + } + stopForeground(true); + stopSelf(); + Running = false; + } + + private Wallet loadWallet(String walletName, String walletPassword) { + Wallet wallet = openWallet(walletName, walletPassword); + if (wallet != null) { + Timber.d("Using daemon %s", WalletManager.getInstance().getDaemonAddress()); + showProgress(55); + wallet.init(0); + wallet.setProxy(NetCipherHelper.getProxy()); + showProgress(90); + } + return wallet; + } + + private Wallet openWallet(String walletName, String walletPassword) { + String path = Helper.getWalletFile(getApplicationContext(), walletName).getAbsolutePath(); + showProgress(20); + Wallet wallet = null; + WalletManager walletMgr = WalletManager.getInstance(); + Timber.d("WalletManager network=%s", walletMgr.getNetworkType().name()); + showProgress(30); + if (walletMgr.walletExists(path)) { + Timber.d("open wallet %s", path); + Wallet.Device device = WalletManager.getInstance().queryWalletDevice(path + ".keys", walletPassword); + Timber.d("device is %s", device.toString()); + if (observer != null) observer.onWalletOpen(device); + wallet = walletMgr.openWallet(path, walletPassword); + showProgress(60); + Timber.d("wallet opened"); + Wallet.Status walletStatus = wallet.getStatus(); + if (!walletStatus.isOk()) { + Timber.d("wallet status is %s", walletStatus); + WalletManager.getInstance().close(wallet); // TODO close() failed? + wallet = null; + // TODO what do we do with the progress?? + // TODO tell the activity this failed + // this crashes in MyWalletListener(Wallet aWallet) as wallet == null + } + } + return wallet; + } + + private void startNotfication() { + Intent notificationIntent = new Intent(this, WalletActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + + String channelId = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? createNotificationChannel() : ""; + Notification notification = new NotificationCompat.Builder(this, channelId) + .setContentTitle(getString(R.string.service_description)) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_monerujo) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) + .build(); + startForeground(NOTIFICATION_ID, notification); + } + + @RequiresApi(Build.VERSION_CODES.O) + private String createNotificationChannel() { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.service_description), + NotificationManager.IMPORTANCE_LOW); + channel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); + notificationManager.createNotificationChannel(channel); + return CHANNEL_ID; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java new file mode 100644 index 0000000..7b7972a --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * 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.service.exchange.api; + + +import androidx.annotation.NonNull; + + +public interface ExchangeApi { + + /** + * Queries the exchnage rate + * + * @param baseCurrency base currency + * @param quoteCurrency quote currency + * @param callback the callback with the exchange rate + */ + void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback); + +} + diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java new file mode 100644 index 0000000..c5b939c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * 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.service.exchange.api; + +public interface ExchangeCallback { + + void onSuccess(ExchangeRate exchangeRate); + + void onError(Exception ex); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java new file mode 100644 index 0000000..905819d --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java @@ -0,0 +1,48 @@ +/* + * 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.service.exchange.api; + +public class ExchangeException extends Exception { + private final int code; + private final String errorMsg; + + public String getErrorMsg() { + return errorMsg; + } + + public ExchangeException(final int code) { + super(); + this.code = code; + this.errorMsg = null; + } + + public ExchangeException(final String errorMsg) { + super(); + this.code = 0; + this.errorMsg = errorMsg; + } + + public ExchangeException(final int code, final String errorMsg) { + super(); + this.code = code; + this.errorMsg = errorMsg; + } + + public int getCode() { + return code; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java new file mode 100644 index 0000000..3c0fadf --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java @@ -0,0 +1,29 @@ +/* + * 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.service.exchange.api; + +public interface ExchangeRate { + + String getServiceName(); + + String getBaseCurrency(); + + String getQuoteCurrency(); + + double getRate(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java new file mode 100644 index 0000000..e355ff5 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2019 m2049r@monerujo.io + * + * 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. + */ + +// https://developer.android.com/training/basics/network-ops/xml + +package com.m2049r.xmrwallet.service.exchange.ecb; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeException; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; +import com.m2049r.xmrwallet.util.NetCipherHelper; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.Response; +import timber.log.Timber; + +public class ExchangeApiImpl implements ExchangeApi { + @NonNull + private final HttpUrl baseUrl; + + //so we can inject the mockserver url + @VisibleForTesting + public ExchangeApiImpl(@NonNull final HttpUrl baseUrl) { + this.baseUrl = baseUrl; + } + + public ExchangeApiImpl() { + this(HttpUrl.parse("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")); + // data is daily and is refreshed around 16:00 CET every working day + } + + public static boolean isSameDay(Calendar calendar, Calendar anotherCalendar) { + return (calendar.get(Calendar.YEAR) == anotherCalendar.get(Calendar.YEAR)) && + (calendar.get(Calendar.DAY_OF_YEAR) == anotherCalendar.get(Calendar.DAY_OF_YEAR)); + } + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + if (!baseCurrency.equals("EUR")) { + callback.onError(new IllegalArgumentException("Only EUR supported as base")); + return; + } + + if (baseCurrency.equals(quoteCurrency)) { + callback.onSuccess(new ExchangeRateImpl(quoteCurrency, 1.0, new Date())); + return; + } + + if (fetchDate != null) { // we have data + boolean useCache = false; + // figure out if we can use the cached values + // data is daily and is refreshed around 16:00 CET every working day + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("CET")); + + int fetchWeekday = fetchDate.get(Calendar.DAY_OF_WEEK); + int fetchDay = fetchDate.get(Calendar.DAY_OF_YEAR); + int fetchHour = fetchDate.get(Calendar.HOUR_OF_DAY); + + int today = now.get(Calendar.DAY_OF_YEAR); + int nowHour = now.get(Calendar.HOUR_OF_DAY); + + if ( + // was it fetched today before 16:00? assume no new data iff now < 16:00 as well + ((today == fetchDay) && (fetchHour < 16) && (nowHour < 16)) + // was it fetched after, 17:00? we can assume there is no newer data + || ((today == fetchDay) && (fetchHour > 17)) + || ((today == fetchDay + 1) && (fetchHour > 17) && (nowHour < 16)) + // is the data itself from today? there can be no newer data + || (fxDate.get(Calendar.DAY_OF_YEAR) == today) + // was it fetched Sat/Sun? we can assume there is no newer data + || ((fetchWeekday == Calendar.SATURDAY) || (fetchWeekday == Calendar.SUNDAY)) + ) { // return cached rate + try { + callback.onSuccess(getRate(quoteCurrency)); + } catch (ExchangeException ex) { + callback.onError(ex); + } + return; + } + } + + final NetCipherHelper.Request httpRequest = new NetCipherHelper.Request(baseUrl); + httpRequest.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(final Call call, final IOException ex) { + callback.onError(ex); + } + + @Override + public void onResponse(final Call call, final Response response) throws IOException { + if (response.isSuccessful()) { + try { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(response.body().byteStream()); + doc.getDocumentElement().normalize(); + parse(doc); + try { + callback.onSuccess(getRate(quoteCurrency)); + } catch (ExchangeException ex) { + callback.onError(ex); + } + } catch (ParserConfigurationException | SAXException ex) { + Timber.w(ex); + callback.onError(new ExchangeException(ex.getLocalizedMessage())); + } + } else { + callback.onError(new ExchangeException(response.code(), response.message())); + } + } + }); + } + + final private Map fxEntries = new HashMap<>(); + private Calendar fxDate = null; + private Calendar fetchDate = null; + + synchronized private ExchangeRate getRate(String currency) throws ExchangeException { + Timber.d("Getting %s", currency); + final Double rate = fxEntries.get(currency); + if (rate == null) throw new ExchangeException(404, "Currency not supported: " + currency); + return new ExchangeRateImpl(currency, rate, fxDate.getTime()); + } + + private final static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + + { + DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private void parse(final Document xmlRootDoc) { + final Map entries = new HashMap<>(); + Calendar date = Calendar.getInstance(TimeZone.getTimeZone("CET")); + try { + NodeList cubes = xmlRootDoc.getElementsByTagName("Cube"); + for (int i = 0; i < cubes.getLength(); i++) { + Node node = cubes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element cube = (Element) node; + if (cube.hasAttribute("time")) { // a time Cube + final Date time = DATE_FORMAT.parse(cube.getAttribute("time")); + date.setTime(time); + } else if (cube.hasAttribute("currency") + && cube.hasAttribute("rate")) { // a rate Cube + String currency = cube.getAttribute("currency"); + double rate = Double.valueOf(cube.getAttribute("rate")); + entries.put(currency, rate); + } // else an empty Cube - ignore + } + } + } catch (ParseException ex) { + Timber.d(ex); + } + synchronized (this) { + if (date != null) { + fetchDate = Calendar.getInstance(TimeZone.getTimeZone("CET")); + fxDate = date; + fxEntries.clear(); + fxEntries.putAll(entries); + } + // else don't change what we have + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java new file mode 100644 index 0000000..4691dfa --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2019 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.service.exchange.ecb; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import java.util.Date; + +class ExchangeRateImpl implements ExchangeRate { + private final Date date; + private final String baseCurrency = "EUR"; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "ecb.europa.eu"; + } + + @Override + public String getBaseCurrency() { + return baseCurrency; + } + + @Override + public String getQuoteCurrency() { + return quoteCurrency; + } + + @Override + public double getRate() { + return rate; + } + + ExchangeRateImpl(@NonNull final String quoteCurrency, double rate, @NonNull final Date date) { + super(); + this.quoteCurrency = quoteCurrency; + this.rate = rate; + this.date = date; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java new file mode 100644 index 0000000..ab36259 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2017-2019 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.service.exchange.kraken; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeException; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; +import com.m2049r.xmrwallet.util.Helper; +import com.m2049r.xmrwallet.util.NetCipherHelper; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.Response; +import timber.log.Timber; + +public class ExchangeApiImpl implements ExchangeApi { + + private final HttpUrl baseUrl; + + //so we can inject the mockserver url + @VisibleForTesting + public ExchangeApiImpl(final HttpUrl baseUrl) { + this.baseUrl = baseUrl; + } + + public ExchangeApiImpl() { + this(HttpUrl.parse("https://api.kraken.com/0/public/Ticker")); + } + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + + if (baseCurrency.equals(quoteCurrency)) { + callback.onSuccess(new ExchangeRateImpl(baseCurrency, quoteCurrency, 1.0)); + return; + } + + boolean invertQuery; + + + if (Helper.BASE_CRYPTO.equals(baseCurrency)) { + invertQuery = false; + } else if (Helper.BASE_CRYPTO.equals(quoteCurrency)) { + invertQuery = true; + } else { + callback.onError(new IllegalArgumentException("no crypto specified")); + return; + } + + Timber.d("queryExchangeRate: i %b, b %s, q %s", invertQuery, baseCurrency, quoteCurrency); + final boolean invert = invertQuery; + final String base = invert ? quoteCurrency : baseCurrency; + final String quote = invert ? baseCurrency : quoteCurrency; + + final HttpUrl url = baseUrl.newBuilder() + .addQueryParameter("pair", base + (quote.equals("BTC") ? "XBT" : quote)) + .build(); + + final NetCipherHelper.Request httpRequest = new NetCipherHelper.Request(url); + httpRequest.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(final Call call, final IOException ex) { + callback.onError(ex); + } + + @Override + public void onResponse(final Call call, final Response response) throws IOException { + if (response.isSuccessful()) { + try { + final JSONObject json = new JSONObject(response.body().string()); + final JSONArray jsonError = json.getJSONArray("error"); + if (jsonError.length() > 0) { + final String errorMsg = jsonError.getString(0); + callback.onError(new ExchangeException(response.code(), errorMsg)); + } else { + final JSONObject jsonResult = json.getJSONObject("result"); + reportSuccess(jsonResult, invert, callback); + } + } catch (JSONException ex) { + callback.onError(new ExchangeException(ex.getLocalizedMessage())); + } + } else { + callback.onError(new ExchangeException(response.code(), response.message())); + } + } + }); + } + + void reportSuccess(JSONObject jsonObject, boolean swapAssets, ExchangeCallback callback) { + try { + final ExchangeRate exchangeRate = new ExchangeRateImpl(jsonObject, swapAssets); + callback.onSuccess(exchangeRate); + } catch (JSONException ex) { + callback.onError(new ExchangeException(ex.getLocalizedMessage())); + } catch (ExchangeException ex) { + callback.onError(ex); + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java new file mode 100644 index 0000000..e3afb5f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java @@ -0,0 +1,94 @@ +/* + * 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.service.exchange.kraken; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeException; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.NoSuchElementException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class ExchangeRateImpl implements ExchangeRate { + + private final String baseCurrency; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "kraken.com"; + } + + @Override + public String getBaseCurrency() { + return baseCurrency; + } + + @Override + public String getQuoteCurrency() { + return quoteCurrency; + } + + @Override + public double getRate() { + return rate; + } + + ExchangeRateImpl(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, double rate) { + super(); + this.baseCurrency = baseCurrency; + this.quoteCurrency = quoteCurrency; + this.rate = rate; + } + + ExchangeRateImpl(final JSONObject jsonObject, final boolean swapAssets) throws JSONException, ExchangeException { + try { + final String key = jsonObject.keys().next(); // we expect only one + Pattern pattern = Pattern.compile("^X(.*?)Z(.*?)$"); + Matcher matcher = pattern.matcher(key); + if (matcher.find()) { + baseCurrency = swapAssets ? matcher.group(2) : matcher.group(1); + quoteCurrency = swapAssets ? matcher.group(1) : matcher.group(2); + } else { + throw new ExchangeException("no pair returned!"); + } + + JSONObject pair = jsonObject.getJSONObject(key); + JSONArray close = pair.getJSONArray("c"); + String closePrice = close.getString(0); + if (closePrice != null) { + try { + double rate = Double.parseDouble(closePrice); + this.rate = swapAssets ? (1 / rate) : rate; + } catch (NumberFormatException ex) { + throw new ExchangeException(ex.getLocalizedMessage()); + } + } else { + throw new ExchangeException("no close price returned!"); + } + } catch (NoSuchElementException ex) { + throw new ExchangeException(ex.getLocalizedMessage()); + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java new file mode 100644 index 0000000..b8021b9 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2019 m2049r@monerujo.io + * + * 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. + */ + +// https://developer.android.com/training/basics/network-ops/xml + +package com.m2049r.xmrwallet.service.exchange.krakenEcb; + +import androidx.annotation.NonNull; + +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 okhttp3.OkHttpClient; +import timber.log.Timber; + +/* + Gets the XMR/EUR rate from kraken and then gets the EUR/fiat rate from the ECB + */ + +public class ExchangeApiImpl implements ExchangeApi { + static public final String BASE_FIAT = "EUR"; + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + Timber.d("B=%s Q=%s", baseCurrency, quoteCurrency); + if (baseCurrency.equals(quoteCurrency)) { + Timber.d("BASE=QUOTE=1"); + callback.onSuccess(new ExchangeRateImpl(baseCurrency, quoteCurrency, 1.0)); + return; + } + + if (!Helper.BASE_CRYPTO.equals(baseCurrency) + && !Helper.BASE_CRYPTO.equals(quoteCurrency)) { + callback.onError(new IllegalArgumentException("no " + Helper.BASE_CRYPTO + " specified")); + return; + } + + final String quote = Helper.BASE_CRYPTO.equals(baseCurrency) ? quoteCurrency : baseCurrency; + + final ExchangeApi krakenApi = + new com.m2049r.xmrwallet.service.exchange.kraken.ExchangeApiImpl(); + krakenApi.queryExchangeRate(Helper.BASE_CRYPTO, BASE_FIAT, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate krakenRate) { + Timber.d("kraken = %f", krakenRate.getRate()); + final ExchangeApi ecbApi = + new com.m2049r.xmrwallet.service.exchange.ecb.ExchangeApiImpl(); + ecbApi.queryExchangeRate(BASE_FIAT, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate ecbRate) { + Timber.d("ECB = %f", ecbRate.getRate()); + double rate = ecbRate.getRate() * krakenRate.getRate(); + Timber.d("Q=%s QC=%s", quote, quoteCurrency); + if (!quote.equals(quoteCurrency)) rate = 1.0d / rate; + Timber.d("rate = %f", rate); + final ExchangeRate exchangeRate = + new ExchangeRateImpl(baseCurrency, quoteCurrency, rate); + callback.onSuccess(exchangeRate); + } + + @Override + public void onError(Exception ex) { + Timber.d(ex); + callback.onError(ex); + } + }); + } + + @Override + public void onError(Exception ex) { + Timber.d(ex); + callback.onError(ex); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java new file mode 100644 index 0000000..48b8ef0 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019 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.service.exchange.krakenEcb; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +class ExchangeRateImpl implements ExchangeRate { + private final String baseCurrency; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "kraken+ecb"; + } + + @Override + public String getBaseCurrency() { + return baseCurrency; + } + + @Override + public String getQuoteCurrency() { + return quoteCurrency; + } + + @Override + public double getRate() { + return rate; + } + + ExchangeRateImpl(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, double rate) { + super(); + this.baseCurrency = baseCurrency; + this.quoteCurrency = quoteCurrency; + this.rate = rate; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/NetworkCallback.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/NetworkCallback.java new file mode 100644 index 0000000..f77128c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/NetworkCallback.java @@ -0,0 +1,27 @@ +/* + * 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.service.shift; + +import org.json.JSONObject; + +public interface NetworkCallback { + + void onSuccess(JSONObject jsonObject); + + void onError(Exception ex); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftApiCall.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftApiCall.java new file mode 100644 index 0000000..c4daeb3 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftApiCall.java @@ -0,0 +1,28 @@ +/* + * 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.service.shift; + +import androidx.annotation.NonNull; + +import org.json.JSONObject; + +public interface ShiftApiCall { + + void call(@NonNull final String path, @NonNull final NetworkCallback callback); + + void call(@NonNull final String path, final JSONObject request, @NonNull final NetworkCallback callback); +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftCallback.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftCallback.java new file mode 100644 index 0000000..4dee50d --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftCallback.java @@ -0,0 +1,24 @@ +/* + * 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.service.shift; + +public interface ShiftCallback { + + void onSuccess(T t); + + void onError(Exception ex); +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftError.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftError.java new file mode 100644 index 0000000..d789ec5 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftError.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2017-2021 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.service.shift; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ShiftError { + @Getter + private final Error errorType; + @Getter + private final String errorMsg; + + public enum Error { + SERVICE, + INFRASTRUCTURE + } + + public boolean isRetryable() { + return errorType == Error.INFRASTRUCTURE; + } + + public ShiftError(final JSONObject jsonObject) throws JSONException { + final JSONObject errorObject = jsonObject.getJSONObject("error"); + errorType = Error.SERVICE; + errorMsg = errorObject.getString("message"); + } + + @Override + @NonNull + public String toString() { + return getErrorMsg(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftException.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftException.java new file mode 100644 index 0000000..3a750b1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/ShiftException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2017-2021 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.service.shift; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ShiftException extends Exception { + @Getter + private final int code; + @Getter + private final ShiftError error; + + public ShiftException(int code) { + this.code = code; + this.error = null; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/CreateOrder.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/CreateOrder.java new file mode 100644 index 0000000..f738956 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/CreateOrder.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.api; + +import java.util.Date; + +public interface CreateOrder { + String TAG = "side"; + + String getBtcCurrency(); + + double getBtcAmount(); + + String getBtcAddress(); + + String getQuoteId(); + + String getOrderId(); + + double getXmrAmount(); + + String getXmrAddress(); + + Date getCreatedAt(); // createdAt + + Date getExpiresAt(); // expiresAt + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderParameters.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderParameters.java new file mode 100644 index 0000000..ebd2d2f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderParameters.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.api; + +public interface QueryOrderParameters { + + double getLowerLimit(); + + double getPrice(); + + double getUpperLimit(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderStatus.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderStatus.java new file mode 100644 index 0000000..acb201c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/QueryOrderStatus.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.api; + +import java.util.Date; + +public interface QueryOrderStatus { + enum State { + WAITING, // Waiting for mempool + PENDING, // Detected (waiting for confirmations) + SETTLING, // Settlement in progress + SETTLED, // Settlement completed + // no refunding in monerujo so theese are ignored: +// REFUND, // Queued for refund +// REFUNDING, // Refund in progress +// REFUNDED // Refund completed + UNDEFINED + } + + boolean isCreated(); + + boolean isTerminal(); + + boolean isWaiting(); + + boolean isPending(); + + boolean isSent(); + + boolean isPaid(); + + boolean isError(); + + QueryOrderStatus.State getState(); + + String getOrderId(); + + Date getCreatedAt(); + + Date getExpiresAt(); + + double getBtcAmount(); + + String getBtcAddress(); + + double getXmrAmount(); + + String getXmrAddress(); + + double getPrice(); +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/RequestQuote.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/RequestQuote.java new file mode 100644 index 0000000..cbbb36f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/RequestQuote.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.api; + +import java.util.Date; + +public interface RequestQuote { + + double getBtcAmount(); // settleAmount + + String getId(); // id + + Date getCreatedAt(); // createdAt + + Date getExpiresAt(); // expiresAt + + double getXmrAmount(); // depositAmount + + double getPrice(); // rate +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/SideShiftApi.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/SideShiftApi.java new file mode 100644 index 0000000..6c9331c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/api/SideShiftApi.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.api; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.shift.ShiftCallback; + +public interface SideShiftApi { + int QUERY_INTERVAL = 5000; // ms + + /** + * Queries the order parameter. + * + * @param callback the callback with the OrderParameter object + */ + void queryOrderParameters(@NonNull final ShiftCallback callback); + + /** + * Creates an order + * + * @param xmrAmount the desired XMR amount + */ + void requestQuote(final double xmrAmount, @NonNull final ShiftCallback callback); + + /** + * Creates an order + * + * @param quoteId the desired XMR amount + * @param btcAddress the target bitcoin address + */ + void createOrder(final String quoteId, @NonNull final String btcAddress, @NonNull final ShiftCallback callback); + + /** + * Queries the order status for given current order + * + * @param orderId the order ID + * @param callback the callback with the OrderStatus object + */ + void queryOrderStatus(@NonNull final String orderId, @NonNull final ShiftCallback callback); + + /* + * Returns the URL for manually querying the order status + * + * @param orderId the order ID + */ + Uri getQueryOrderUri(String orderId); +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/CreateOrderImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/CreateOrderImpl.java new file mode 100644 index 0000000..258cf4d --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/CreateOrderImpl.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.network; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.BuildConfig; +import com.m2049r.xmrwallet.service.shift.NetworkCallback; +import com.m2049r.xmrwallet.service.shift.ShiftApiCall; +import com.m2049r.xmrwallet.service.shift.ShiftCallback; +import com.m2049r.xmrwallet.service.shift.sideshift.api.CreateOrder; +import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi; +import com.m2049r.xmrwallet.util.DateHelper; +import com.m2049r.xmrwallet.util.ServiceHelper; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.text.ParseException; +import java.util.Date; + +import lombok.Getter; + +class CreateOrderImpl implements CreateOrder { + @Getter + private final String btcCurrency; + @Getter + private final double btcAmount; + @Getter + private final String btcAddress; + @Getter + private final String quoteId; + @Getter + private final String orderId; + @Getter + private final double xmrAmount; + @Getter + private final String xmrAddress; + @Getter + private final Date createdAt; + @Getter + private final Date expiresAt; + + CreateOrderImpl(final JSONObject jsonObject) throws JSONException { + // sanity checks + final String depositMethod = jsonObject.getString("depositMethodId"); + final String settleMethod = jsonObject.getString("settleMethodId"); + if (!"xmr".equals(depositMethod) || !ServiceHelper.ASSET.equals(settleMethod)) + throw new IllegalStateException(); + + btcCurrency = settleMethod.toUpperCase(); + btcAmount = jsonObject.getDouble("settleAmount"); + JSONObject settleAddress = jsonObject.getJSONObject("settleAddress"); + btcAddress = settleAddress.getString("address"); + + xmrAmount = jsonObject.getDouble("depositAmount"); + JSONObject depositAddress = jsonObject.getJSONObject("depositAddress"); + xmrAddress = depositAddress.getString("address"); + + quoteId = jsonObject.getString("quoteId"); + + orderId = jsonObject.getString("orderId"); + + try { + final String created = jsonObject.getString("createdAtISO"); + createdAt = DateHelper.parse(created); + final String expires = jsonObject.getString("expiresAtISO"); + expiresAt = DateHelper.parse(expires); + } catch (ParseException ex) { + throw new JSONException(ex.getLocalizedMessage()); + } + } + + public static void call(@NonNull final ShiftApiCall api, final String quoteId, @NonNull final String btcAddress, + @NonNull final ShiftCallback callback) { + try { + final JSONObject request = createRequest(quoteId, btcAddress); + api.call("orders", request, new NetworkCallback() { + @Override + public void onSuccess(JSONObject jsonObject) { + try { + callback.onSuccess(new CreateOrderImpl(jsonObject)); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + @Override + public void onError(Exception ex) { + callback.onError(ex); + } + }); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + static JSONObject createRequest(final String quoteId, final String address) throws JSONException { + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("type", "fixed"); + jsonObject.put("quoteId", quoteId); + jsonObject.put("settleAddress", address); + if (!BuildConfig.ID_A.isEmpty() && !"null".equals(BuildConfig.ID_A)) { + jsonObject.put("affiliateId", BuildConfig.ID_A); + } + return jsonObject; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderParametersImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderParametersImpl.java new file mode 100644 index 0000000..afa5527 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderParametersImpl.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.network; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.shift.NetworkCallback; +import com.m2049r.xmrwallet.service.shift.ShiftApiCall; +import com.m2049r.xmrwallet.service.shift.sideshift.api.QueryOrderParameters; +import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi; +import com.m2049r.xmrwallet.service.shift.ShiftCallback; +import com.m2049r.xmrwallet.util.ServiceHelper; + +import org.json.JSONException; +import org.json.JSONObject; + +class QueryOrderParametersImpl implements QueryOrderParameters { + + private double lowerLimit; + private double price; + private double upperLimit; + + public double getLowerLimit() { + return lowerLimit; + } + + public double getPrice() { + return price; + } + + public double getUpperLimit() { + return upperLimit; + } + + QueryOrderParametersImpl(final JSONObject jsonObject) throws JSONException { + lowerLimit = jsonObject.getDouble("min"); + price = jsonObject.getDouble("rate"); + upperLimit = jsonObject.getDouble("max"); + } + + public static void call(@NonNull final ShiftApiCall api, + @NonNull final ShiftCallback callback) { + api.call("pairs/xmr/" + ServiceHelper.ASSET, new NetworkCallback() { + @Override + public void onSuccess(JSONObject jsonObject) { + try { + callback.onSuccess(new QueryOrderParametersImpl(jsonObject)); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + @Override + public void onError(Exception ex) { + callback.onError(ex); + } + }); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderStatusImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderStatusImpl.java new file mode 100644 index 0000000..439cb93 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/QueryOrderStatusImpl.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.network; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.shift.NetworkCallback; +import com.m2049r.xmrwallet.service.shift.ShiftApiCall; +import com.m2049r.xmrwallet.util.DateHelper; +import com.m2049r.xmrwallet.service.shift.sideshift.api.QueryOrderStatus; +import com.m2049r.xmrwallet.service.shift.ShiftCallback; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.text.ParseException; +import java.util.Date; + +import lombok.Getter; +import timber.log.Timber; + +class QueryOrderStatusImpl implements QueryOrderStatus { + + @Getter + private QueryOrderStatus.State state; + @Getter + private final String orderId; + @Getter + private final Date createdAt; + @Getter + private final Date expiresAt; + @Getter + private final double btcAmount; + @Getter + private final String btcAddress; + @Getter + private final double xmrAmount; + @Getter + private final String xmrAddress; + + public boolean isCreated() { + return true; + } + + public boolean isTerminal() { + return (state.equals(State.SETTLED) || isError()); + } + + public boolean isError() { + return state.equals(State.UNDEFINED); + } + + public boolean isWaiting() { + return state.equals(State.WAITING); + } + + public boolean isPending() { + return state.equals(State.PENDING); + } + + public boolean isSent() { + return state.equals(State.SETTLING); + } + + public boolean isPaid() { + return state.equals(State.SETTLED); + } + + public double getPrice() { + return btcAmount / xmrAmount; + } + + QueryOrderStatusImpl(final JSONObject jsonObject) throws JSONException { + try { + String created = jsonObject.getString("createdAtISO"); + createdAt = DateHelper.parse(created); + String expires = jsonObject.getString("expiresAtISO"); + expiresAt = DateHelper.parse(expires); + } catch (ParseException ex) { + throw new JSONException(ex.getLocalizedMessage()); + } + orderId = jsonObject.getString("orderId"); + + btcAmount = jsonObject.getDouble("settleAmount"); + JSONObject settleAddress = jsonObject.getJSONObject("settleAddress"); + btcAddress = settleAddress.getString("address"); + + xmrAmount = jsonObject.getDouble("depositAmount"); + JSONObject depositAddress = jsonObject.getJSONObject("depositAddress"); + xmrAddress = settleAddress.getString("address"); + + JSONArray deposits = jsonObject.getJSONArray("deposits"); + // we only create one deposit, so die if there are more than one: + if (deposits.length() > 1) + throw new IllegalStateException("more than one deposits"); + + state = State.UNDEFINED; + if (deposits.length() == 0) { + state = State.WAITING; + } else if (deposits.length() == 1) { + // sanity check + if (!orderId.equals(deposits.getJSONObject(0).getString("orderId"))) + throw new IllegalStateException("deposit has different order id!"); + String stateName = deposits.getJSONObject(0).getString("status"); + try { + state = State.valueOf(stateName.toUpperCase()); + } catch (IllegalArgumentException ex) { + state = State.UNDEFINED; + } + } + } + + public static void call(@NonNull final ShiftApiCall api, @NonNull final String orderId, + @NonNull final ShiftCallback callback) { + api.call("orders/" + orderId, new NetworkCallback() { + @Override + public void onSuccess(JSONObject jsonObject) { + try { + callback.onSuccess(new QueryOrderStatusImpl(jsonObject)); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + @Override + public void onError(Exception ex) { + callback.onError(ex); + } + }); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/RequestQuoteImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/RequestQuoteImpl.java new file mode 100644 index 0000000..1cbdf24 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/RequestQuoteImpl.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.network; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.shift.NetworkCallback; +import com.m2049r.xmrwallet.service.shift.ShiftApiCall; +import com.m2049r.xmrwallet.service.shift.ShiftCallback; +import com.m2049r.xmrwallet.service.shift.sideshift.api.RequestQuote; +import com.m2049r.xmrwallet.util.DateHelper; +import com.m2049r.xmrwallet.util.ServiceHelper; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.text.ParseException; +import java.util.Date; +import java.util.Locale; + +import lombok.Getter; + +class RequestQuoteImpl implements RequestQuote { + @Getter + private final double btcAmount; + @Getter + private final String id; + @Getter + private final Date createdAt; + @Getter + private final Date expiresAt; + @Getter + private final double xmrAmount; + @Getter + private final double price; + + // TODO do something with errors - they always seem to send us 500 + + RequestQuoteImpl(final JSONObject jsonObject) throws JSONException { + // sanity checks + final String depositMethod = jsonObject.getString("depositMethod"); + final String settleMethod = jsonObject.getString("settleMethod"); + if (!"xmr".equals(depositMethod) || !ServiceHelper.ASSET.equals(settleMethod)) + throw new IllegalStateException(); + + btcAmount = jsonObject.getDouble("settleAmount"); + id = jsonObject.getString("id"); + + try { + final String created = jsonObject.getString("createdAt"); + createdAt = DateHelper.parse(created); + final String expires = jsonObject.getString("expiresAt"); + expiresAt = DateHelper.parse(expires); + } catch (ParseException ex) { + throw new JSONException(ex.getLocalizedMessage()); + } + xmrAmount = jsonObject.getDouble("depositAmount"); + price = jsonObject.getDouble("rate"); + } + + public static void call(@NonNull final ShiftApiCall api, final double btcAmount, + @NonNull final ShiftCallback callback) { + try { + final JSONObject request = createRequest(btcAmount); + api.call("quotes", request, new NetworkCallback() { + @Override + public void onSuccess(JSONObject jsonObject) { + try { + callback.onSuccess(new RequestQuoteImpl(jsonObject)); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + @Override + public void onError(Exception ex) { + callback.onError(ex); + } + }); + } catch (JSONException ex) { + callback.onError(ex); + } + } + + /** + * Create JSON request object + * + * @param btcAmount how much XMR to shift to BTC + */ + + static JSONObject createRequest(final double btcAmount) throws JSONException { + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("depositMethod", "xmr"); + jsonObject.put("settleMethod", ServiceHelper.ASSET); + // #sideshift is silly and likes numbers as strings + String amount = AmountFormatter.format(btcAmount); + jsonObject.put("settleAmount", amount); + return jsonObject; + } + + static final DecimalFormat AmountFormatter; + + static { + AmountFormatter = new DecimalFormat(); + AmountFormatter.setDecimalFormatSymbols(new DecimalFormatSymbols(Locale.US)); + AmountFormatter.setMinimumIntegerDigits(1); + AmountFormatter.setMaximumFractionDigits(12); + AmountFormatter.setGroupingUsed(false); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/SideShiftApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/SideShiftApiImpl.java new file mode 100644 index 0000000..c22e322 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/shift/sideshift/network/SideShiftApiImpl.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2017-2021 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.service.shift.sideshift.network; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.service.shift.NetworkCallback; +import com.m2049r.xmrwallet.service.shift.ShiftApiCall; +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.QueryOrderParameters; +import com.m2049r.xmrwallet.service.shift.sideshift.api.QueryOrderStatus; +import com.m2049r.xmrwallet.service.shift.sideshift.api.RequestQuote; +import com.m2049r.xmrwallet.service.shift.sideshift.api.SideShiftApi; +import com.m2049r.xmrwallet.util.NetCipherHelper; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.Response; +import timber.log.Timber; + +public class SideShiftApiImpl implements SideShiftApi, ShiftApiCall { + + private final HttpUrl baseUrl; + + public SideShiftApiImpl(final HttpUrl baseUrl) { + this.baseUrl = baseUrl; + } + + @Override + public void queryOrderParameters(@NonNull final ShiftCallback callback) { + QueryOrderParametersImpl.call(this, callback); + } + + @Override + public void requestQuote(final double btcAmount, @NonNull final ShiftCallback callback) { + RequestQuoteImpl.call(this, btcAmount, callback); + } + + @Override + public void createOrder(final String quoteId, @NonNull final String btcAddress, + @NonNull final ShiftCallback callback) { + CreateOrderImpl.call(this, quoteId, btcAddress, callback); + } + + @Override + public void queryOrderStatus(@NonNull final String uuid, + @NonNull final ShiftCallback callback) { + QueryOrderStatusImpl.call(this, uuid, callback); + } + + @Override + public Uri getQueryOrderUri(String orderId) { + return Uri.parse("https://sideshift.ai/orders/" + orderId); + } + + @Override + public void call(@NonNull final String path, @NonNull final NetworkCallback callback) { + call(path, null, callback); + } + + @Override + public void call(@NonNull final String path, final JSONObject request, @NonNull final NetworkCallback callback) { + final HttpUrl url = baseUrl.newBuilder() + .addPathSegments(path) + .build(); + + NetCipherHelper.Request httpRequest = new NetCipherHelper.Request(url, request); + httpRequest.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(final Call call, final IOException ex) { + callback.onError(ex); + } + + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) throws IOException { + Timber.d("onResponse code=%d", response.code()); + if (response.isSuccessful()) { + try { + final JSONObject json = new JSONObject(response.body().string()); + callback.onSuccess(json); + } catch (JSONException ex) { + callback.onError(ex); + } + } else { + try { + final JSONObject json = new JSONObject(response.body().string()); + Timber.d(json.toString(2)); + final ShiftError error = new ShiftError(json); + Timber.w("%s says %d/%s", CreateOrder.TAG, response.code(), error.toString()); + callback.onError(new ShiftException(response.code(), error)); + } catch (JSONException ex) { + callback.onError(new ShiftException(response.code())); + } + } + } + }); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/CrazyPassEncoder.java b/app/src/main/java/com/m2049r/xmrwallet/util/CrazyPassEncoder.java new file mode 100644 index 0000000..37f5329 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/CrazyPassEncoder.java @@ -0,0 +1,68 @@ +/* + * 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.util; + +import java.math.BigInteger; + +public class CrazyPassEncoder { + static final String BASE = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + static final int PW_CHARS = 52; + + // this takes a 32 byte buffer and converts it to 52 alphnumeric characters + // separated by blanks every 4 characters = 13 groups of 4 + // always (padding by Xs if need be + static public String encode(byte[] data) { + if (data.length != 32) throw new IllegalArgumentException("data[] is not 32 bytes long"); + BigInteger rest = new BigInteger(1, data); + BigInteger remainder; + final StringBuilder result = new StringBuilder(); + final BigInteger base = BigInteger.valueOf(BASE.length()); + int i = 0; + do { + if ((i > 0) && (i % 4 == 0)) result.append(' '); + i++; + remainder = rest.remainder(base); + rest = rest.divide(base); + result.append(BASE.charAt(remainder.intValue())); + } while (!BigInteger.ZERO.equals(rest)); + // pad it + while (i < PW_CHARS) { + if ((i > 0) && (i % 4 == 0)) result.append(' '); + result.append('2'); + i++; + } + return result.toString(); + } + + static public String reformat(String password) { + // maybe this is a CrAzYpass without blanks? or lowercase letters + String noBlanks = password.toUpperCase().replaceAll(" ", ""); + if (noBlanks.length() == PW_CHARS) { // looks like a CrAzYpass + // insert blanks every 4 characters + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < PW_CHARS; i++) { + if ((i > 0) && (i % 4 == 0)) sb.append(' '); + char c = noBlanks.charAt(i); + if (BASE.indexOf(c) < 0) return null; // invalid character found + sb.append(c); + } + return sb.toString(); + } else { + return null; // not a CrAzYpass + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/DateHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/DateHelper.java new file mode 100644 index 0000000..e791cea --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/DateHelper.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2017 m2049r er 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.util; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class DateHelper { + public static final SimpleDateFormat DATETIME_FORMATTER = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); + + public static Date parse(String dateString) throws ParseException { + return DATETIME_FORMATTER.parse(dateString.replaceAll("Z$", "+0000")); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/DayNightMode.java b/app/src/main/java/com/m2049r/xmrwallet/util/DayNightMode.java new file mode 100644 index 0000000..6e8cdff --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/DayNightMode.java @@ -0,0 +1,45 @@ +/* + * 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.util; + +import androidx.appcompat.app.AppCompatDelegate; + +public enum DayNightMode { + // order must match R.array.daynight_themes + AUTO(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM), + DAY(AppCompatDelegate.MODE_NIGHT_NO), + NIGHT(AppCompatDelegate.MODE_NIGHT_YES), + UNKNOWN(AppCompatDelegate.MODE_NIGHT_UNSPECIFIED); + + final private int nightMode; + + DayNightMode(int nightMode) { + this.nightMode = nightMode; + } + + public int getNightMode() { + return nightMode; + } + + static public DayNightMode getValue(int nightMode) { + for (DayNightMode mode : DayNightMode.values()) { + if (mode.nightMode == nightMode) + return mode; + } + return UNKNOWN; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/FingerprintHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/FingerprintHelper.java new file mode 100644 index 0000000..906dba8 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/FingerprintHelper.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018-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.util; + +import android.app.KeyguardManager; +import android.content.Context; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; +import android.os.CancellationSignal; + +public class FingerprintHelper { + + public static boolean isDeviceSupported(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false; + } + + FingerprintManager fingerprintManager = context.getSystemService(FingerprintManager.class); + KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class); + + return (keyguardManager != null) && (fingerprintManager != null) && + keyguardManager.isKeyguardSecure() && + fingerprintManager.isHardwareDetected() && + fingerprintManager.hasEnrolledFingerprints(); + } + + public static boolean isFingerPassValid(Context context, String wallet) { + try { + KeyStoreHelper.loadWalletUserPass(context, wallet); + return true; + } catch (KeyStoreHelper.BrokenPasswordStoreException ex) { + return false; + } + } + + public static void authenticate(Context context, CancellationSignal cancelSignal, + FingerprintManager.AuthenticationCallback callback) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + FingerprintManager manager = context.getSystemService(FingerprintManager.class); + if (manager != null) { + manager.authenticate(null, cancelSignal, 0, callback, null); + } + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java b/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java new file mode 100644 index 0000000..4cd90cf --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java @@ -0,0 +1,609 @@ +/* + * 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.util; + +import android.Manifest; +import android.app.Activity; +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.VectorDrawable; +import android.hardware.fingerprint.FingerprintManager; +import android.os.AsyncTask; +import android.os.Build; +import android.os.CancellationSignal; +import android.os.StrictMode; +import android.system.ErrnoException; +import android.system.Os; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputLayout; +import com.m2049r.xmrwallet.BuildConfig; +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.data.Crypto; +import com.m2049r.xmrwallet.model.WalletManager; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.Calendar; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.net.ssl.HttpsURLConnection; + +import timber.log.Timber; + +public class Helper { + static public final String NOCRAZYPASS_FLAGFILE = ".nocrazypass"; + + static public final String BASE_CRYPTO = Crypto.XMR.getSymbol(); + static public final int XMR_DECIMALS = 12; + static public final long ONE_XMR = Math.round(Math.pow(10, Helper.XMR_DECIMALS)); + + static public final boolean SHOW_EXCHANGERATES = true; + static public boolean ALLOW_SHIFT = false; + + static private final String WALLET_DIR = "wallets"; + static private final String MONERO_DIR = "monero"; + + static public int DISPLAY_DIGITS_INFO = 5; + + static public File getWalletRoot(Context context) { + return getStorage(context, WALLET_DIR); + } + + static public File getStorage(Context context, String folderName) { + File dir = new File(context.getFilesDir(), folderName); + if (!dir.exists()) { + Timber.i("Creating %s", dir.getAbsolutePath()); + dir.mkdirs(); // try to make it + } + if (!dir.isDirectory()) { + String msg = "Directory " + dir.getAbsolutePath() + " does not exist."; + Timber.e(msg); + throw new IllegalStateException(msg); + } + return dir; + } + + static public final int PERMISSIONS_REQUEST_CAMERA = 7; + + static public boolean getCameraPermission(Activity context) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + if (context.checkSelfPermission(Manifest.permission.CAMERA) + == PackageManager.PERMISSION_DENIED) { + Timber.w("Permission denied for CAMERA - requesting it"); + String[] permissions = {Manifest.permission.CAMERA}; + context.requestPermissions(permissions, PERMISSIONS_REQUEST_CAMERA); + return false; + } else { + return true; + } + } else { + return true; + } + } + + static public File getWalletFile(Context context, String aWalletName) { + File walletDir = getWalletRoot(context); + File f = new File(walletDir, aWalletName); + Timber.d("wallet=%s size= %d", f.getAbsolutePath(), f.length()); + return f; + } + + static public void showKeyboard(Activity act) { + InputMethodManager imm = (InputMethodManager) act.getSystemService(Context.INPUT_METHOD_SERVICE); + final View focus = act.getCurrentFocus(); + if (focus != null) + imm.showSoftInput(focus, InputMethodManager.SHOW_IMPLICIT); + } + + static public void hideKeyboard(Activity act) { + if (act == null) return; + if (act.getCurrentFocus() == null) { + act.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); + } else { + InputMethodManager imm = (InputMethodManager) act.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow((null == act.getCurrentFocus()) ? null : act.getCurrentFocus().getWindowToken(), + InputMethodManager.HIDE_NOT_ALWAYS); + } + } + + static public void showKeyboard(Dialog dialog) { + dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } + + static public void hideKeyboardAlways(Activity act) { + act.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + } + + static public BigDecimal getDecimalAmount(long amount) { + return new BigDecimal(amount).scaleByPowerOfTen(-XMR_DECIMALS); + } + + static public String getDisplayAmount(long amount) { + return getDisplayAmount(amount, XMR_DECIMALS); + } + + static public String getDisplayAmount(long amount, int maxDecimals) { + // a Java bug does not strip zeros properly if the value is 0 + if (amount == 0) return "0.00"; + BigDecimal d = getDecimalAmount(amount) + .setScale(maxDecimals, BigDecimal.ROUND_HALF_UP) + .stripTrailingZeros(); + if (d.scale() < 2) + d = d.setScale(2, BigDecimal.ROUND_UNNECESSARY); + return d.toPlainString(); + } + + static public String getFormattedAmount(double amount, boolean isCrypto) { + // at this point selection is XMR in case of error + String displayB; + if (isCrypto) { + if ((amount >= 0) || (amount == 0)) { + displayB = String.format(Locale.US, "%,.5f", amount); + } else { + displayB = null; + } + } else { // not crypto + displayB = String.format(Locale.US, "%,.2f", amount); + } + return displayB; + } + + static public String getDisplayAmount(double amount) { + // a Java bug does not strip zeros properly if the value is 0 + BigDecimal d = new BigDecimal(amount) + .setScale(XMR_DECIMALS, BigDecimal.ROUND_HALF_UP) + .stripTrailingZeros(); + if (d.scale() < 1) + d = d.setScale(1, BigDecimal.ROUND_UNNECESSARY); + return d.toPlainString(); + } + + static public Bitmap getBitmap(Context context, int drawableId) { + Drawable drawable = ContextCompat.getDrawable(context, drawableId); + if (drawable instanceof BitmapDrawable) { + return BitmapFactory.decodeResource(context.getResources(), drawableId); + } else if (drawable instanceof VectorDrawable) { + return getBitmap((VectorDrawable) drawable); + } else { + throw new IllegalArgumentException("unsupported drawable type"); + } + } + + static private Bitmap getBitmap(VectorDrawable vectorDrawable) { + Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), + vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + vectorDrawable.draw(canvas); + return bitmap; + } + + static final int HTTP_TIMEOUT = 5000; + + static public String getUrl(String httpsUrl) { + HttpsURLConnection urlConnection = null; + try { + URL url = new URL(httpsUrl); + urlConnection = (HttpsURLConnection) url.openConnection(); + urlConnection.setConnectTimeout(HTTP_TIMEOUT); + urlConnection.setReadTimeout(HTTP_TIMEOUT); + InputStreamReader in = new InputStreamReader(urlConnection.getInputStream()); + StringBuffer sb = new StringBuffer(); + final int BUFFER_SIZE = 512; + char[] buffer = new char[BUFFER_SIZE]; + int length = in.read(buffer, 0, BUFFER_SIZE); + while (length >= 0) { + sb.append(buffer, 0, length); + length = in.read(buffer, 0, BUFFER_SIZE); + } + return sb.toString(); + } catch (SocketTimeoutException ex) { + Timber.w("C %s", ex.getLocalizedMessage()); + } catch (MalformedURLException ex) { + Timber.e("A %s", ex.getLocalizedMessage()); + } catch (IOException ex) { + Timber.e("B %s", ex.getLocalizedMessage()); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + } + return null; + } + + static public void clipBoardCopy(Context context, String label, String text) { + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(label, text); + clipboardManager.setPrimaryClip(clip); + } + + static public String getClipBoardText(Context context) { + final ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + try { + if (clipboardManager.hasPrimaryClip() + && clipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) { + final ClipData.Item item = clipboardManager.getPrimaryClip().getItemAt(0); + return item.getText().toString(); + } + } catch (NullPointerException ex) { + // if we have don't find a text in the clipboard + return null; + } + return null; + } + + static private Animation ShakeAnimation; + + static public Animation getShakeAnimation(Context context) { + if (ShakeAnimation == null) { + synchronized (Helper.class) { + if (ShakeAnimation == null) { + ShakeAnimation = AnimationUtils.loadAnimation(context, R.anim.shake); + } + } + } + return ShakeAnimation; + } + + private final static char[] HexArray = "0123456789ABCDEF".toCharArray(); + + public static String bytesToHex(byte[] data) { + if ((data != null) && (data.length > 0)) + return String.format("%0" + (data.length * 2) + "X", new BigInteger(1, data)); + else return ""; + } + + public static byte[] hexToBytes(String hex) { + final int len = hex.length(); + final byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return data; + } + + static public void setMoneroHome(Context context) { + try { + String home = getStorage(context, MONERO_DIR).getAbsolutePath(); + Os.setenv("HOME", home, true); + } catch (ErrnoException ex) { + throw new IllegalStateException(ex); + } + } + + static public void initLogger(Context context) { + if (BuildConfig.DEBUG) { + initLogger(context, WalletManager.LOGLEVEL_DEBUG); + } + // no logger if not debug + } + + // TODO make the log levels refer to the WalletManagerFactory::LogLevel enum ? + static public void initLogger(Context context, int level) { + String home = getStorage(context, MONERO_DIR).getAbsolutePath(); + WalletManager.initLogger(home + "/monerujo", "monerujo.log"); + if (level >= WalletManager.LOGLEVEL_SILENT) + WalletManager.setLogLevel(level); + } + + static public boolean useCrazyPass(Context context) { + File flagFile = new File(getWalletRoot(context), NOCRAZYPASS_FLAGFILE); + return !flagFile.exists(); + } + + // try to figure out what the real wallet password is given the user password + // which could be the actual wallet password or a (maybe malformed) CrAzYpass + // or the password used to derive the CrAzYpass for the wallet + static public String getWalletPassword(Context context, String walletName, String password) { + String walletPath = new File(getWalletRoot(context), walletName + ".keys").getAbsolutePath(); + + // try with entered password (which could be a legacy password or a CrAzYpass) + if (WalletManager.getInstance().verifyWalletPasswordOnly(walletPath, password)) { + return password; + } + + // maybe this is a malformed CrAzYpass? + String possibleCrazyPass = CrazyPassEncoder.reformat(password); + if (possibleCrazyPass != null) { // looks like a CrAzYpass + if (WalletManager.getInstance().verifyWalletPasswordOnly(walletPath, possibleCrazyPass)) { + return possibleCrazyPass; + } + } + + // generate & try with CrAzYpass + String crazyPass = KeyStoreHelper.getCrazyPass(context, password); + if (WalletManager.getInstance().verifyWalletPasswordOnly(walletPath, crazyPass)) { + return crazyPass; + } + + // or maybe it is a broken CrAzYpass? (of which we have two variants) + String brokenCrazyPass2 = KeyStoreHelper.getBrokenCrazyPass(context, password, 2); + if ((brokenCrazyPass2 != null) + && WalletManager.getInstance().verifyWalletPasswordOnly(walletPath, brokenCrazyPass2)) { + return brokenCrazyPass2; + } + String brokenCrazyPass1 = KeyStoreHelper.getBrokenCrazyPass(context, password, 1); + if ((brokenCrazyPass1 != null) + && WalletManager.getInstance().verifyWalletPasswordOnly(walletPath, brokenCrazyPass1)) { + return brokenCrazyPass1; + } + + return null; + } + + static AlertDialog openDialog = null; // for preventing opening of multiple dialogs + static AsyncTask passwordTask = null; + + static public void promptPassword(final Context context, final String wallet, boolean fingerprintDisabled, final PasswordAction action) { + if (openDialog != null) return; // we are already asking for password + LayoutInflater li = LayoutInflater.from(context); + final View promptsView = li.inflate(R.layout.prompt_password, null); + + AlertDialog.Builder alertDialogBuilder = new MaterialAlertDialogBuilder(context); + alertDialogBuilder.setView(promptsView); + + final TextInputLayout etPassword = promptsView.findViewById(R.id.etPassword); + etPassword.setHint(context.getString(R.string.prompt_password, wallet)); + + final TextView tvOpenPrompt = promptsView.findViewById(R.id.tvOpenPrompt); + final Drawable icFingerprint = context.getDrawable(R.drawable.ic_fingerprint); + final Drawable icError = context.getDrawable(R.drawable.ic_error_red_36dp); + final Drawable icInfo = context.getDrawable(R.drawable.ic_info_white_24dp); + + final boolean fingerprintAuthCheck = FingerprintHelper.isFingerPassValid(context, wallet); + + final boolean fingerprintAuthAllowed = !fingerprintDisabled && fingerprintAuthCheck; + final CancellationSignal cancelSignal = new CancellationSignal(); + + final AtomicBoolean incorrectSavedPass = new AtomicBoolean(false); + + class PasswordTask extends AsyncTask { + private String pass; + private boolean fingerprintUsed; + + PasswordTask(String pass, boolean fingerprintUsed) { + this.pass = pass; + this.fingerprintUsed = fingerprintUsed; + } + + @Override + protected void onPreExecute() { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icInfo, null, null, null); + tvOpenPrompt.setText(context.getText(R.string.prompt_open_wallet)); + tvOpenPrompt.setVisibility(View.VISIBLE); + } + + @Override + protected Boolean doInBackground(Void... unused) { + return processPasswordEntry(context, wallet, pass, fingerprintUsed, action); + } + + @Override + protected void onPostExecute(Boolean result) { + if (result) { + Helper.hideKeyboardAlways((Activity) context); + cancelSignal.cancel(); + openDialog.dismiss(); + openDialog = null; + } else { + if (fingerprintUsed) { + incorrectSavedPass.set(true); + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icError, null, null, null); + tvOpenPrompt.setText(context.getText(R.string.bad_saved_password)); + } else { + if (!fingerprintAuthAllowed) { + tvOpenPrompt.setVisibility(View.GONE); + } else if (incorrectSavedPass.get()) { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icError, null, null, null); + tvOpenPrompt.setText(context.getText(R.string.bad_password)); + } else { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icFingerprint, null, null, null); + tvOpenPrompt.setText(context.getText(R.string.prompt_fingerprint_auth)); + } + etPassword.setError(context.getString(R.string.bad_password)); + } + } + passwordTask = null; + } + } + + etPassword.getEditText().addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + if (etPassword.getError() != null) { + etPassword.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(context.getString(R.string.label_ok), null) + .setNegativeButton(context.getString(R.string.label_cancel), + (dialog, id) -> { + action.fail(wallet); + Helper.hideKeyboardAlways((Activity) context); + cancelSignal.cancel(); + if (passwordTask != null) { + passwordTask.cancel(true); + passwordTask = null; + } + dialog.cancel(); + openDialog = null; + }); + openDialog = alertDialogBuilder.create(); + + final FingerprintManager.AuthenticationCallback fingerprintAuthCallback; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + fingerprintAuthCallback = null; + } else { + fingerprintAuthCallback = new FingerprintManager.AuthenticationCallback() { + @Override + public void onAuthenticationError(int errMsgId, CharSequence errString) { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icError, null, null, null); + tvOpenPrompt.setText(errString); + } + + @Override + public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icError, null, null, null); + tvOpenPrompt.setText(helpString); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) { + try { + String userPass = KeyStoreHelper.loadWalletUserPass(context, wallet); + if (passwordTask == null) { + passwordTask = new PasswordTask(userPass, true); + passwordTask.execute(); + } + } catch (KeyStoreHelper.BrokenPasswordStoreException ex) { + etPassword.setError(context.getString(R.string.bad_password)); + // TODO: better error message here - what would it be? + } + } + + @Override + public void onAuthenticationFailed() { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icError, null, null, null); + tvOpenPrompt.setText(context.getString(R.string.bad_fingerprint)); + } + }; + } + + openDialog.setOnShowListener(dialog -> { + if (fingerprintAuthAllowed && fingerprintAuthCallback != null) { + tvOpenPrompt.setCompoundDrawablesRelativeWithIntrinsicBounds(icFingerprint, null, null, null); + tvOpenPrompt.setText(context.getText(R.string.prompt_fingerprint_auth)); + tvOpenPrompt.setVisibility(View.VISIBLE); + FingerprintHelper.authenticate(context, cancelSignal, fingerprintAuthCallback); + } else { + etPassword.requestFocus(); + } + Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE); + button.setOnClickListener(view -> { + String pass = etPassword.getEditText().getText().toString(); + if (passwordTask == null) { + passwordTask = new PasswordTask(pass, false); + passwordTask.execute(); + } + }); + }); + + // accept keyboard "ok" + etPassword.getEditText().setOnEditorActionListener((v, actionId, event) -> { + if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN)) + || (actionId == EditorInfo.IME_ACTION_DONE)) { + String pass = etPassword.getEditText().getText().toString(); + if (passwordTask == null) { + passwordTask = new PasswordTask(pass, false); + passwordTask.execute(); + } + return true; + } + return false; + }); + + if (Helper.preventScreenshot()) { + openDialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } + + Helper.showKeyboard(openDialog); + openDialog.show(); + } + + public interface PasswordAction { + void act(String walletName, String password, boolean fingerprintUsed); + + void fail(String walletName); + } + + static private boolean processPasswordEntry(Context context, String walletName, String pass, boolean fingerprintUsed, PasswordAction action) { + String walletPassword = Helper.getWalletPassword(context, walletName, pass); + if (walletPassword != null) { + action.act(walletName, walletPassword, fingerprintUsed); + return true; + } else { + action.fail(walletName); + return false; + } + } + + public interface Action { + boolean run(); + } + + static public boolean runWithNetwork(Action action) { + StrictMode.ThreadPolicy currentPolicy = StrictMode.getThreadPolicy(); + StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitNetwork().build(); + StrictMode.setThreadPolicy(policy); + try { + return action.run(); + } finally { + StrictMode.setThreadPolicy(currentPolicy); + } + } + + static public boolean preventScreenshot() { + return !(BuildConfig.DEBUG || BuildConfig.FLAVOR_type.equals("alpha")); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/KeyStoreHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/KeyStoreHelper.java new file mode 100644 index 0000000..896d2d0 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/KeyStoreHelper.java @@ -0,0 +1,351 @@ +/* + * Copyright 2018 m2049r + * Copyright 2013 The Android Open Source Project + * + * 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.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.UnrecoverableEntryException; +import java.security.cert.CertificateException; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.security.auth.x500.X500Principal; + +import timber.log.Timber; + +public class KeyStoreHelper { + + static { + System.loadLibrary("monerujo"); + } + + public static native byte[] slowHash(byte[] data, int brokenVariant); + + static final private String RSA_ALIAS = "MonerujoRSA"; + + private static String getCrazyPass(Context context, String password, int brokenVariant) { + byte[] data = password.getBytes(StandardCharsets.UTF_8); + byte[] sig = null; + try { + KeyStoreHelper.createKeys(context, RSA_ALIAS); + sig = KeyStoreHelper.signData(RSA_ALIAS, data); + byte[] hash = slowHash(sig, brokenVariant); + if (hash == null) { + throw new IllegalStateException("Slow Hash is null!"); + } + return CrazyPassEncoder.encode(hash); + } catch (NoSuchProviderException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | KeyStoreException | + InvalidKeyException | SignatureException ex) { + throw new IllegalStateException(ex); + } + } + + public static String getCrazyPass(Context context, String password) { + if (Helper.useCrazyPass(context)) + return getCrazyPass(context, password, 0); + else + return password; + } + + public static String getBrokenCrazyPass(Context context, String password, int brokenVariant) { + // due to a link bug in the initial implementation, some crazypasses were built with + // prehash & variant == 1 + // since there are wallets out there, we need to keep this here + // yes, it's a mess + if (isArm32() && (brokenVariant != 2)) return null; + return getCrazyPass(context, password, brokenVariant); + } + + private static Boolean isArm32 = null; + + public static boolean isArm32() { + if (isArm32 != null) return isArm32; + synchronized (KeyStoreException.class) { + if (isArm32 != null) return isArm32; + isArm32 = Build.SUPPORTED_ABIS[0].equals("armeabi-v7a"); + return isArm32; + } + } + + public static boolean saveWalletUserPass(@NonNull Context context, String wallet, String password) { + String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet; + byte[] data = password.getBytes(StandardCharsets.UTF_8); + try { + KeyStoreHelper.createKeys(context, walletKeyAlias); + byte[] encrypted = KeyStoreHelper.encrypt(walletKeyAlias, data); + SharedPreferences.Editor e = context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE).edit(); + if (encrypted == null) { + e.remove(wallet).apply(); + return false; + } + e.putString(wallet, Base64.encodeToString(encrypted, Base64.DEFAULT)).apply(); + return true; + } catch (NoSuchProviderException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | KeyStoreException ex) { + Timber.w(ex); + return false; + } + } + + static public class BrokenPasswordStoreException extends Exception { + BrokenPasswordStoreException() { + super(); + } + + BrokenPasswordStoreException(Throwable cause) { + super(cause); + } + } + + public static boolean hasStoredPasswords(@NonNull Context context) { + SharedPreferences prefs = context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getAll().size() > 0; + } + + public static String loadWalletUserPass(@NonNull Context context, String wallet) throws BrokenPasswordStoreException { + String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet; + String encoded = context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE) + .getString(wallet, ""); + if (encoded.isEmpty()) throw new BrokenPasswordStoreException(); + byte[] data = Base64.decode(encoded, Base64.DEFAULT); + byte[] decrypted = KeyStoreHelper.decrypt(walletKeyAlias, data); + if (decrypted == null) throw new BrokenPasswordStoreException(); + return new String(decrypted, StandardCharsets.UTF_8); + } + + public static void removeWalletUserPass(Context context, String wallet) { + String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet; + try { + KeyStoreHelper.deleteKeys(walletKeyAlias); + } catch (KeyStoreException ex) { + Timber.w(ex); + } + context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE).edit() + .remove(wallet).apply(); + } + + public static void copyWalletUserPass(Context context, String srcWallet, String dstWallet) throws BrokenPasswordStoreException { + final String pass = loadWalletUserPass(context, srcWallet); + saveWalletUserPass(context, dstWallet, pass); + } + + /** + * Creates a public and private key and stores it using the Android Key + * Store, so that only this application will be able to access the keys. + */ + private static void createKeys(Context context, String alias) throws NoSuchProviderException, + NoSuchAlgorithmException, InvalidAlgorithmParameterException, KeyStoreException { + KeyStore keyStore = KeyStore.getInstance(SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + try { + keyStore.load(null); + } catch (IOException | CertificateException ex) { + throw new IllegalStateException("Could not load KeySotre", ex); + } + if (!keyStore.containsAlias(alias)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + createKeysJBMR2(context, alias); + } else { + createKeysM(alias); + } + } + } + + private static boolean deleteKeys(String alias) throws KeyStoreException { + KeyStore keyStore = KeyStore.getInstance(SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + try { + keyStore.load(null); + keyStore.deleteEntry(alias); + return true; + } catch (IOException | NoSuchAlgorithmException | CertificateException ex) { + Timber.w(ex); + return false; + } + } + + public static boolean keyExists(String wallet) throws BrokenPasswordStoreException { + try { + KeyStore keyStore = KeyStore.getInstance(KeyStoreHelper.SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + keyStore.load(null); + return keyStore.containsAlias(KeyStoreHelper.SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet); + } catch (IOException | NoSuchAlgorithmException | CertificateException | KeyStoreException ex) { + throw new BrokenPasswordStoreException(ex); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + private static void createKeysJBMR2(Context context, String alias) throws NoSuchProviderException, + NoSuchAlgorithmException, InvalidAlgorithmParameterException { + + Calendar start = new GregorianCalendar(); + Calendar end = new GregorianCalendar(); + end.add(Calendar.YEAR, 300); + + KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(context) + .setAlias(alias) + .setSubject(new X500Principal("CN=" + alias)) + .setSerialNumber(BigInteger.valueOf(Math.abs(alias.hashCode()))) + .setStartDate(start.getTime()).setEndDate(end.getTime()) + .build(); + // defaults to 2048 bit modulus + KeyPairGenerator kpGenerator = KeyPairGenerator.getInstance( + SecurityConstants.TYPE_RSA, + SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + kpGenerator.initialize(spec); + KeyPair kp = kpGenerator.generateKeyPair(); + Timber.d("preM Keys created"); + } + + @TargetApi(Build.VERSION_CODES.M) + private static void createKeysM(String alias) throws NoSuchProviderException, + NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_RSA, SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + keyPairGenerator.initialize( + new KeyGenParameterSpec.Builder( + alias, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setDigests(KeyProperties.DIGEST_SHA256) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + Timber.d("M Keys created"); + } + + private static PrivateKey getPrivateKey(String alias) { + try { + KeyStore ks = KeyStore + .getInstance(SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + ks.load(null); + //KeyStore.Entry entry = ks.getEntry(alias, null); + PrivateKey privateKey = (PrivateKey) ks.getKey(alias, null); + + if (privateKey == null) { + Timber.w("No key found under alias: %s", alias); + return null; + } + + return privateKey; + } catch (IOException | NoSuchAlgorithmException | CertificateException + | UnrecoverableEntryException | KeyStoreException ex) { + throw new IllegalStateException(ex); + } + } + + private static PublicKey getPublicKey(String alias) { + try { + KeyStore ks = KeyStore + .getInstance(SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE); + ks.load(null); + + PublicKey publicKey = ks.getCertificate(alias).getPublicKey(); + + if (publicKey == null) { + Timber.w("No public key"); + return null; + } + return publicKey; + } catch (IOException | NoSuchAlgorithmException | CertificateException + | KeyStoreException ex) { + throw new IllegalStateException(ex); + } + } + + private static byte[] encrypt(String alias, byte[] data) { + try { + PublicKey publicKey = getPublicKey(alias); + Cipher cipher = Cipher.getInstance(SecurityConstants.CIPHER_RSA_ECB_PKCS1); + + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + return cipher.doFinal(data); + } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException + | NoSuchAlgorithmException | NoSuchPaddingException ex) { + Timber.e(ex); + return null; + } + } + + private static byte[] decrypt(String alias, byte[] data) { + try { + PrivateKey privateKey = getPrivateKey(alias); + if (privateKey == null) return null; + Cipher cipher = Cipher.getInstance(SecurityConstants.CIPHER_RSA_ECB_PKCS1); + + cipher.init(Cipher.DECRYPT_MODE, privateKey); + return cipher.doFinal(data); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | + IllegalBlockSizeException | BadPaddingException ex) { + Timber.e(ex); + return null; + } + } + + /** + * Signs the data using the key pair stored in the Android Key Store. This + * signature can be used with the data later to verify it was signed by this + * application. + * + * @return The data signature generated + */ + private static byte[] signData(String alias, byte[] data) throws NoSuchAlgorithmException, + InvalidKeyException, SignatureException { + PrivateKey privateKey = getPrivateKey(alias); + if (privateKey == null) return null; + Signature s = Signature.getInstance(SecurityConstants.SIGNATURE_SHA256withRSA); + s.initSign(privateKey); + s.update(data); + return s.sign(); + } + + public interface SecurityConstants { + String KEYSTORE_PROVIDER_ANDROID_KEYSTORE = "AndroidKeyStore"; + String TYPE_RSA = "RSA"; + String SIGNATURE_SHA256withRSA = "SHA256withRSA"; + String CIPHER_RSA_ECB_PKCS1 = "RSA/ECB/PKCS1Padding"; + String WALLET_PASS_PREFS_NAME = "wallet"; + String WALLET_PASS_KEY_PREFIX = "walletKey-"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/LegacyStorageHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/LegacyStorageHelper.java new file mode 100644 index 0000000..20ea61c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/LegacyStorageHelper.java @@ -0,0 +1,170 @@ +package com.m2049r.xmrwallet.util; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.preference.PreferenceManager; + +import com.m2049r.xmrwallet.BuildConfig; +import com.m2049r.xmrwallet.model.WalletManager; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import lombok.RequiredArgsConstructor; +import timber.log.Timber; + +@RequiredArgsConstructor +public class LegacyStorageHelper { + final private File srcDir; + final private File dstDir; + + static public void migrateWallets(Context context) { + try { + if (isStorageMigrated(context)) return; + if (!hasReadPermission(context)) { + // can't migrate - don't remember this, as the user may turn on permissions later + return; + } + final File oldRoot = getWalletRoot(); + if (!oldRoot.exists()) { + // nothing to migrate, so don't try again + setStorageMigrated(context); + return; + } + final File newRoot = Helper.getWalletRoot(context); + (new LegacyStorageHelper(oldRoot, newRoot)).migrate(); + setStorageMigrated(context); // done it once - don't try again + } catch (IllegalStateException ex) { + Timber.d(ex); + // nothing we can do here + } + } + + public void migrate() { + String addressPrefix = WalletManager.getInstance().addressPrefix(); + File[] wallets = srcDir.listFiles((dir, filename) -> filename.endsWith(".keys")); + if (wallets == null) return; + for (File wallet : wallets) { + final String walletName = wallet.getName().substring(0, wallet.getName().length() - ".keys".length()); + if (addressPrefix.indexOf(getAddress(walletName).charAt(0)) < 0) { + Timber.d("skipping %s", walletName); + continue; + } + try { + copy(walletName); + } catch (IOException ex) { // something failed - try to clean up + deleteDst(walletName); + } + } + } + + // return "@" by default so we don't need to deal with null stuff + private String getAddress(String walletName) { + File addressFile = new File(srcDir, walletName + ".address.txt"); + if (!addressFile.exists()) return "@"; + try (BufferedReader addressReader = new BufferedReader(new FileReader(addressFile))) { + return addressReader.readLine(); + } catch (IOException ex) { + Timber.d(ex.getLocalizedMessage()); + } + return "@"; + } + + private void copy(String walletName) throws IOException { + final String dstName = getUniqueName(dstDir, walletName); + copyFile(new File(srcDir, walletName), new File(dstDir, dstName)); + copyFile(new File(srcDir, walletName + ".keys"), new File(dstDir, dstName + ".keys")); + } + + private void deleteDst(String walletName) { + // do our best, but if it fails, it fails + (new File(dstDir, walletName)).delete(); + (new File(dstDir, walletName + ".keys")).delete(); + } + + private void copyFile(File src, File dst) throws IOException { + if (!src.exists()) return; + Timber.d("%s => %s", src.getAbsolutePath(), dst.getAbsolutePath()); + try (FileChannel inChannel = new FileInputStream(src).getChannel(); + FileChannel outChannel = new FileOutputStream(dst).getChannel()) { + inChannel.transferTo(0, inChannel.size(), outChannel); + } + } + + private static boolean isExternalStorageWritable() { + String state = Environment.getExternalStorageState(); + return Environment.MEDIA_MOUNTED.equals(state); + } + + private static File getWalletRoot() { + if (!isExternalStorageWritable()) + throw new IllegalStateException(); + + // wallet folder for legacy (pre-Q) installations + final String FLAVOR_SUFFIX = + (BuildConfig.FLAVOR.startsWith("prod") ? "" : "." + BuildConfig.FLAVOR) + + (BuildConfig.DEBUG ? "-debug" : ""); + final String WALLET_DIR = "monerujo" + FLAVOR_SUFFIX; + + File dir = new File(Environment.getExternalStorageDirectory(), WALLET_DIR); + if (!dir.exists() || !dir.isDirectory()) + throw new IllegalStateException(); + return dir; + } + + private static boolean hasReadPermission(Context context) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_DENIED; + } else { + return true; + } + } + + private static final Pattern WALLET_PATTERN = Pattern.compile("^(.+) \\(([0-9]+)\\).keys$"); + + private static String getUniqueName(File root, String name) { + if (!(new File(root, name + ".keys")).exists()) // does not exist => it's ok to use + return name; + + File[] wallets = root.listFiles( + (dir, filename) -> { + Matcher m = WALLET_PATTERN.matcher(filename); + if (m.find()) + return m.group(1).equals(name); + else return false; + }); + if (wallets.length == 0) return name + " (1)"; + int maxIndex = 0; + for (File wallet : wallets) { + try { + final Matcher m = WALLET_PATTERN.matcher(wallet.getName()); + if (!m.find()) + throw new IllegalStateException("this must match as it did before"); + final int index = Integer.parseInt(m.group(2)); + if (index > maxIndex) maxIndex = index; + } catch (NumberFormatException ex) { + // this cannot happen & we can ignore it if it does + } + } + return name + " (" + (maxIndex + 1) + ")"; + } + + private static final String MIGRATED_KEY = "migrated_legacy_storage"; + + public static boolean isStorageMigrated(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(MIGRATED_KEY, false); + } + + public static void setStorageMigrated(Context context) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(MIGRATED_KEY, true).apply(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/LocaleHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/LocaleHelper.java new file mode 100644 index 0000000..fb79cf9 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/LocaleHelper.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2018-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.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.preference.PreferenceManager; + +import com.m2049r.xmrwallet.R; + +import java.util.ArrayList; +import java.util.Locale; + +public class LocaleHelper { + private static Locale SYSTEM_DEFAULT_LOCALE = Locale.getDefault(); + + public static ArrayList getAvailableLocales(Context context) { + ArrayList locales = new ArrayList<>(); + // R.string.available_locales gets generated in build.gradle by enumerating values-* folders + String[] availableLocales = context.getString(R.string.available_locales).split(","); + + for (String localeName : availableLocales) { + locales.add(Locale.forLanguageTag(localeName)); + } + + return locales; + } + + public static String getDisplayName(Locale locale, boolean sentenceCase) { + String displayName = locale.getDisplayName(locale); + + if (sentenceCase) { + displayName = toSentenceCase(displayName, locale); + } + + return displayName; + } + + public static Context setPreferredLocale(Context context) { + return setLocale(context, getPreferredLanguageTag(context)); + } + + public static Context setAndSaveLocale(Context context, String langaugeTag) { + savePreferredLangaugeTag(context, langaugeTag); + return setLocale(context, langaugeTag); + } + + private static Context setLocale(Context context, String languageTag) { + Locale locale = (languageTag.isEmpty()) ? SYSTEM_DEFAULT_LOCALE : Locale.forLanguageTag(languageTag); + Locale.setDefault(locale); + + Configuration configuration = context.getResources().getConfiguration(); + configuration.setLocale(locale); + configuration.setLayoutDirection(locale); + + return context.createConfigurationContext(configuration); + } + + public static void updateSystemDefaultLocale(Locale locale) { + SYSTEM_DEFAULT_LOCALE = locale; + } + + private static String toSentenceCase(String str, Locale locale) { + if (str.isEmpty()) { + return str; + } + + int firstCodePointLen = str.offsetByCodePoints(0, 1); + return str.substring(0, firstCodePointLen).toUpperCase(locale) + + str.substring(firstCodePointLen); + } + + public static Locale getPreferredLocale(Context context) { + String languageTag = getPreferredLanguageTag(context); + return languageTag.isEmpty() ? SYSTEM_DEFAULT_LOCALE : Locale.forLanguageTag(languageTag); + } + + public static String getPreferredLanguageTag(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString("preferred_locale", ""); + // cannot access getString here as it's done BEFORE string locale is set + } + + @SuppressLint("ApplySharedPref") + private static void savePreferredLangaugeTag(Context context, String locale) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(context.getString(R.string.preferred_locale), locale).commit(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/MoneroThreadPoolExecutor.java b/app/src/main/java/com/m2049r/xmrwallet/util/MoneroThreadPoolExecutor.java new file mode 100644 index 0000000..27bb64c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/MoneroThreadPoolExecutor.java @@ -0,0 +1,56 @@ +/* + * 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.util; + +import com.m2049r.xmrwallet.service.MoneroHandlerThread; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + + +public class MoneroThreadPoolExecutor { + private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); + private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4)); + private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; + private static final int KEEP_ALIVE_SECONDS = 30; + + private static final ThreadFactory sThreadFactory = new ThreadFactory() { + private final AtomicInteger mCount = new AtomicInteger(1); + + public Thread newThread(Runnable r) { + return new Thread(null, r, "MoneroTask #" + mCount.getAndIncrement(), MoneroHandlerThread.THREAD_STACK_SIZE); + } + }; + + private static final BlockingQueue sPoolWorkQueue = + new LinkedBlockingQueue<>(128); + + public static final Executor MONERO_THREAD_POOL_EXECUTOR; + + static { + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, + sPoolWorkQueue, sThreadFactory); + threadPoolExecutor.allowCoreThreadTimeOut(true); + MONERO_THREAD_POOL_EXECUTOR = threadPoolExecutor; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/NetCipherHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/NetCipherHelper.java new file mode 100644 index 0000000..418e97f --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/NetCipherHelper.java @@ -0,0 +1,393 @@ +/* + * 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.util; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import com.burgstaller.okhttp.AuthenticationCacheInterceptor; +import com.burgstaller.okhttp.CachingAuthenticatorDecorator; +import com.burgstaller.okhttp.digest.CachingAuthenticator; +import com.burgstaller.okhttp.digest.Credentials; +import com.burgstaller.okhttp.digest.DigestAuthenticator; + +import org.json.JSONObject; + +import java.io.IOException; +import java.net.Proxy; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import info.guardianproject.netcipher.client.StrongOkHttpClientBuilder; +import info.guardianproject.netcipher.proxy.OrbotHelper; +import info.guardianproject.netcipher.proxy.SignatureUtils; +import info.guardianproject.netcipher.proxy.StatusCallback; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.Response; +import timber.log.Timber; + +@RequiredArgsConstructor +public class NetCipherHelper implements StatusCallback { + public static final String USER_AGENT = "Monerujo/1.0"; + public static final int HTTP_TIMEOUT = 1000; //ms + public static final int TOR_TIMEOUT_CONNECT = 5000; //ms + public static final int TOR_TIMEOUT = 2000; //ms + + public interface OnStatusChangedListener { + void connected(); + + void disconnected(); + + void notInstalled(); + + void notEnabled(); + } + + final private Context context; + final private OrbotHelper orbot; + + @SuppressLint("StaticFieldLeak") + private static NetCipherHelper Instance; + + public static void createInstance(Context context) { + if (Instance == null) { + synchronized (NetCipherHelper.class) { + if (Instance == null) { + final Context applicationContext = context.getApplicationContext(); + Instance = new NetCipherHelper(applicationContext, OrbotHelper.get(context).statusTimeout(5000)); + } + } + } + } + + public static NetCipherHelper getInstance() { + if (Instance == null) throw new IllegalStateException("NetCipherHelper is null"); + return Instance; + } + + private OkHttpClient client; + + private void createTorClient(Intent statusIntent) { + String orbotStatus = statusIntent.getStringExtra(OrbotHelper.EXTRA_STATUS); + if (orbotStatus == null) throw new IllegalStateException("status is null"); + if (!orbotStatus.equals(OrbotHelper.STATUS_ON)) + throw new IllegalStateException("Orbot is not ON"); + try { + final OkHttpClient.Builder okBuilder = new OkHttpClient.Builder() + .connectTimeout(TOR_TIMEOUT_CONNECT, TimeUnit.MILLISECONDS) + .writeTimeout(TOR_TIMEOUT, TimeUnit.MILLISECONDS) + .readTimeout(TOR_TIMEOUT, TimeUnit.MILLISECONDS); + client = new StrongOkHttpClientBuilder(context) + .withSocksProxy() + .applyTo(okBuilder, statusIntent) + .build(); + Helper.ALLOW_SHIFT = false; // no shifting with Tor + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private void createClearnetClient() { + try { + client = new OkHttpClient.Builder() + .connectTimeout(HTTP_TIMEOUT, TimeUnit.MILLISECONDS) + .writeTimeout(HTTP_TIMEOUT, TimeUnit.MILLISECONDS) + .readTimeout(HTTP_TIMEOUT, TimeUnit.MILLISECONDS) + .build(); + Helper.ALLOW_SHIFT = true; + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private OnStatusChangedListener onStatusChangedListener; + + public static void deregister() { + getInstance().onStatusChangedListener = null; + } + + public static void register(OnStatusChangedListener listener) { + final NetCipherHelper me = getInstance(); + me.onStatusChangedListener = listener; + + // NOT_INSTALLED is dealt with through the callbacks + me.orbot.removeStatusCallback(me) // make sure we are registered just once + .addStatusCallback(me); + + // deal with org.torproject.android.intent.action.STATUS = STARTS_DISABLED + me.context.registerReceiver(orbotStatusReceiver, new IntentFilter(OrbotHelper.ACTION_STATUS)); + + me.startTor(); + } + + // for StatusCallback + public enum Status { + STARTING, + ENABLED, + STOPPING, + DISABLED, + NOT_INSTALLED, + NOT_ENABLED, + UNKNOWN; + } + + private Status status = Status.UNKNOWN; + + @Override + public void onStarting() { + Timber.d("onStarting"); + status = Status.STARTING; + } + + @Override + public void onEnabled(Intent statusIntent) { + Timber.d("onEnabled"); + if (getTorPref() != Status.ENABLED) return; // do we want Tor? + createTorClient(statusIntent); + status = Status.ENABLED; + if (onStatusChangedListener != null) { + new Thread(() -> onStatusChangedListener.connected()).start(); + } + } + + @Override + public void onStopping() { + Timber.d("onStopping"); + status = Status.STOPPING; + } + + @Override + public void onDisabled() { + Timber.d("onDisabled"); + createClearnetClient(); + status = Status.DISABLED; + if (onStatusChangedListener != null) { + new Thread(() -> onStatusChangedListener.disconnected()).start(); + } + } + + @Override + public void onStatusTimeout() { + Timber.d("onStatusTimeout"); + createClearnetClient(); + // (timeout does not not change the status) + if (onStatusChangedListener != null) { + new Thread(() -> onStatusChangedListener.disconnected()).start(); + } + orbotInit = false; // do init() next time we try to open Tor + } + + @Override + public void onNotYetInstalled() { + Timber.d("onNotYetInstalled"); + // never mind then + orbot.removeStatusCallback(this); + createClearnetClient(); + status = Status.NOT_INSTALLED; + if (onStatusChangedListener != null) { + new Thread(() -> onStatusChangedListener.notInstalled()).start(); + } + } + + // user has not enabled background Orbot starts + public void onNotEnabled() { + Timber.d("onNotEnabled"); + // keep the callback in case they turn it on manually + setTorPref(Status.DISABLED); + createClearnetClient(); + status = Status.NOT_ENABLED; + if (onStatusChangedListener != null) { + new Thread(() -> onStatusChangedListener.notEnabled()).start(); + } + } + + static public Status getStatus() { + return getInstance().status; + } + + public void toggle() { + switch (getStatus()) { + case ENABLED: + onDisabled(); + setTorPref(Status.DISABLED); + break; + case DISABLED: + setTorPref(Status.ENABLED); + startTor(); + break; + } + } + + private boolean orbotInit = false; + + private void startTor() { + if (!isOrbotInstalled()) { + onNotYetInstalled(); + } else if (getTorPref() == Status.DISABLED) { + onDisabled(); + } else if (!orbotInit) { + orbotInit = orbot.init(); + } else { + orbot.requestStart(context); + } + } + + // extracted from OrbotHelper + private boolean isOrbotInstalled() { + ArrayList hashes = new ArrayList<>(); + // Tor Project signing key + hashes.add("A4:54:B8:7A:18:47:A8:9E:D7:F5:E7:0F:BA:6B:BA:96:F3:EF:29:C2:6E:09:81:20:4F:E3:47:BF:23:1D:FD:5B"); + // f-droid.org signing key + hashes.add("A7:02:07:92:4F:61:FF:09:37:1D:54:84:14:5C:4B:EE:77:2C:55:C1:9E:EE:23:2F:57:70:E1:82:71:F7:CB:AE"); + + return null != SignatureUtils.validateBroadcastIntent(context, + OrbotHelper.getOrbotStartIntent(context), + hashes, false); + } + + + static public boolean hasClient() { + return getInstance().client != null; + } + + static public boolean isTor() { + return getStatus() == Status.ENABLED; + } + + static public String getProxy() { + if (!isTor()) return ""; + final Proxy proxy = getInstance().client.proxy(); + if (proxy == null) return ""; + return proxy.address().toString().substring(1); + } + + @ToString + static public class Request { + final HttpUrl url; + final String json; + final String username; + final String password; + + public Request(final HttpUrl url, final String json, final String username, final String password) { + this.url = url; + this.json = json; + this.username = username; + this.password = password; + } + + public Request(final HttpUrl url, final JSONObject json) { + this(url, json == null ? null : json.toString(), null, null); + } + + public Request(final HttpUrl url) { + this(url, null, null, null); + } + + public void enqueue(Callback callback) { + newCall().enqueue(callback); + } + + public Response execute() throws IOException { + return newCall().execute(); + } + + private Call newCall() { + return getClient().newCall(getRequest()); + } + + private OkHttpClient getClient() { + if (mockClient != null) return mockClient; // Unit-test mode + final OkHttpClient client = getInstance().client; + if ((username != null) && (!username.isEmpty())) { + final DigestAuthenticator authenticator = new DigestAuthenticator(new Credentials(username, password)); + final Map authCache = new ConcurrentHashMap<>(); + return client.newBuilder() + .authenticator(new CachingAuthenticatorDecorator(authenticator, authCache)) + .addInterceptor(new AuthenticationCacheInterceptor(authCache)) + .build(); + // TODO: maybe cache & reuse the client for these credentials? + } else { + return client; + } + } + + private okhttp3.Request getRequest() { + final okhttp3.Request.Builder builder = + new okhttp3.Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT); + if (json != null) { + builder.post(RequestBody.create(json, MediaType.parse("application/json"))); + } else { + builder.get(); + } + return builder.build(); + } + + // for unit tests only + static public OkHttpClient mockClient = null; + } + + private static final String PREFS_NAME = "tor"; + private static final String PREFS_STATUS = "status"; + private Status currentPref = Status.UNKNOWN; + + private Status getTorPref() { + if (currentPref != Status.UNKNOWN) return currentPref; + currentPref = Status.valueOf(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(PREFS_STATUS, "DISABLED")); + return currentPref; + } + + private void setTorPref(Status status) { + if (getTorPref() == status) return; // no change + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(PREFS_STATUS, status.name()) + .apply(); + currentPref = status; + } + + private static final BroadcastReceiver orbotStatusReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Timber.d("%s/%s", intent.getAction(), intent.getStringExtra(OrbotHelper.EXTRA_STATUS)); + if (OrbotHelper.ACTION_STATUS.equals(intent.getAction())) { + if (OrbotHelper.STATUS_STARTS_DISABLED.equals(intent.getStringExtra(OrbotHelper.EXTRA_STATUS))) { + getInstance().onNotEnabled(); + } + } + } + }; + + public void installOrbot(Activity host) { + host.startActivity(OrbotHelper.getOrbotInstallIntent(context)); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/NightmodeHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/NightmodeHelper.java new file mode 100644 index 0000000..afe4ce4 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/NightmodeHelper.java @@ -0,0 +1,53 @@ +/* + * 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.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.appcompat.app.AppCompatDelegate; + +import com.m2049r.xmrwallet.R; + +public class NightmodeHelper { + public static DayNightMode getPreferredNightmode(Context context) { + return DayNightMode.valueOf(PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.preferred_nightmode), "UNKNOWN")); + } + + public static void setPreferredNightmode(Context context) { + final DayNightMode mode = DayNightMode.valueOf(PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.preferred_nightmode), "UNKNOWN")); + if (mode == DayNightMode.UNKNOWN) + setAndSavePreferredNightmode(context, DayNightMode.AUTO); + else + setNightMode(mode); + } + + public static void setAndSavePreferredNightmode(Context context, DayNightMode mode) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(context.getString(R.string.preferred_nightmode), mode.name()).apply(); + setNightMode(mode); + } + + @SuppressLint("WrongConstant") + public static void setNightMode(DayNightMode mode) { + AppCompatDelegate.setDefaultNightMode(mode.getNightMode()); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/NodePinger.java b/app/src/main/java/com/m2049r/xmrwallet/util/NodePinger.java new file mode 100644 index 0000000..e353de1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/NodePinger.java @@ -0,0 +1,53 @@ +/* + * 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.util; + +import com.m2049r.xmrwallet.data.NodeInfo; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import timber.log.Timber; + +public class NodePinger { + static final public int NUM_THREADS = 10; + static final public long MAX_TIME = 5L; // seconds + + public interface Listener { + void publish(NodeInfo node); + } + + static public void execute(Collection nodes, final Listener listener) { + final ExecutorService exeService = Executors.newFixedThreadPool(NUM_THREADS); + List> taskList = new ArrayList<>(); + for (NodeInfo node : nodes) { + taskList.add(() -> node.testRpcService(listener)); + } + + try { + exeService.invokeAll(taskList, MAX_TIME, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Timber.w(ex); + } + exeService.shutdownNow(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/Notice.java b/app/src/main/java/com/m2049r/xmrwallet/util/Notice.java new file mode 100644 index 0000000..727c395 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/Notice.java @@ -0,0 +1,127 @@ +/* + * 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.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.dialog.HelpFragment; +import com.m2049r.xmrwallet.ledger.Ledger; + +import java.util.ArrayList; +import java.util.List; + +public class Notice { + private static final String PREFS_NAME = "notice"; + private static List notices = null; + + private static final String NOTICE_SHOW_XMRTO_ENABLED_SEND = "notice_xmrto_enabled_send"; + private static final String NOTICE_SHOW_LEDGER = "notice_ledger_enabled_login"; + + private static void init() { + synchronized (Notice.class) { + if (notices != null) return; + notices = new ArrayList<>(); + if (Helper.ALLOW_SHIFT) + notices.add( + new Notice(NOTICE_SHOW_XMRTO_ENABLED_SEND, + R.string.info_xmrto_enabled, + R.string.help_xmrto, + 1) + ); + if (Ledger.ENABLED) + notices.add( + new Notice(NOTICE_SHOW_LEDGER, + R.string.info_ledger_enabled, + R.string.help_create_ledger, + 1) + ); + } + } + + public static void showAll(ViewGroup parent, String selector) { + if (notices == null) init(); + for (Notice notice : notices) { + if (notice.id.matches(selector)) + notice.show(parent); + } + } + + private final String id; + private final int textResId; + private final int helpResId; + private final int defaultCount; + private transient int count = -1; + + private Notice(final String id, final int textResId, final int helpResId, final int defaultCount) { + this.id = id; + this.textResId = textResId; + this.helpResId = helpResId; + this.defaultCount = defaultCount; + } + + // show this notice as a child of the given parent view + // NB: it assumes the parent is in a Fragment + private void show(final ViewGroup parent) { + final Context context = parent.getContext(); + if (getCount(context) <= 0) return; // don't add it + + final LinearLayout ll = + (LinearLayout) LayoutInflater.from(context) + .inflate(R.layout.template_notice, parent, false); + + ((TextView) ll.findViewById(R.id.tvNotice)).setText(textResId); + + final FragmentManager fragmentManager = + ((FragmentActivity) context).getSupportFragmentManager(); + ll.setOnClickListener(v -> HelpFragment.display(fragmentManager, helpResId)); + + ImageButton ib = ll.findViewById(R.id.ibClose); + ib.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ll.setVisibility(View.GONE); + decCount(context); + } + }); + parent.addView(ll); + } + + private int getCount(final Context context) { + count = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getInt(id, defaultCount); + return count; + } + + private void decCount(final Context context) { + final SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + if (count < 0) // not initialized yet + count = prefs.getInt(id, defaultCount); + if (count > 0) + prefs.edit().putInt(id, count - 1).apply(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/OnionHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/OnionHelper.java new file mode 100644 index 0000000..f5a416a --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/OnionHelper.java @@ -0,0 +1,24 @@ +/* + * 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.util; + +public class OnionHelper { + + public static boolean isOnionHost(String hostname) { + return hostname.endsWith(".onion"); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/OpenAliasHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/OpenAliasHelper.java new file mode 100644 index 0000000..b710fbc --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/OpenAliasHelper.java @@ -0,0 +1,244 @@ +/* + * 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. + */ + +// Specs from https://openalias.org/ + +package com.m2049r.xmrwallet.util; + +import android.os.AsyncTask; + +import com.m2049r.xmrwallet.data.BarcodeData; +import com.m2049r.xmrwallet.data.Crypto; + +import org.jitsi.dnssec.validator.ValidatingResolver; +import org.xbill.DNS.DClass; +import org.xbill.DNS.Flags; +import org.xbill.DNS.Message; +import org.xbill.DNS.Name; +import org.xbill.DNS.RRset; +import org.xbill.DNS.Rcode; +import org.xbill.DNS.Record; +import org.xbill.DNS.Section; +import org.xbill.DNS.SimpleResolver; +import org.xbill.DNS.TXTRecord; +import org.xbill.DNS.Type; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import timber.log.Timber; + +public class OpenAliasHelper { + public static final String OA1_SCHEME = "oa1:"; + public static final String OA1_ASSET = "asset"; + public static final String OA1_ADDRESS = "recipient_address"; + public static final String OA1_NAME = "recipient_name"; + public static final String OA1_DESCRIPTION = "tx_description"; + public static final String OA1_AMOUNT = "tx_amount"; + + public static final int DNS_LOOKUP_TIMEOUT = 2500; // ms + + public static void resolve(String name, OnResolvedListener resolvedListener) { + new DnsTxtResolver(resolvedListener).execute(name); + } + + public static Map parse(String oaString) { + return new OpenAliasParser(oaString).parse(); + } + + public interface OnResolvedListener { + void onResolved(Map dataMap); + + void onFailure(); + } + + private static class DnsTxtResolver extends AsyncTask { + List txts = new ArrayList<>(); + boolean dnssec = false; + + private final OnResolvedListener resolvedListener; + + private DnsTxtResolver(OnResolvedListener resolvedListener) { + this.resolvedListener = resolvedListener; + } + + // trust anchor of the root zone + // http://data.iana.org/root-anchors/root-anchors.xml + final String ROOT = + ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDDE32F24E8FB5\n" + + ". IN DS 20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D"; + final String[] DNSSEC_SERVERS = { + "4.2.2.1", // Level3 + "4.2.2.2", // Level3 + "4.2.2.6", // Level3 + "1.1.1.1", // cloudflare + "9.9.9.9", // quad9 + "8.8.4.4", // google + "8.8.8.8" // google + }; + + @Override + protected Boolean doInBackground(String... args) { + //main(); + if (args.length != 1) return false; + String name = args[0]; + if ((name == null) || (name.isEmpty())) + return false; //pointless trying to lookup nothing + Timber.d("Resolving %s", name); + try { + SimpleResolver sr = new SimpleResolver(DNSSEC_SERVERS[new Random().nextInt(DNSSEC_SERVERS.length)]); + ValidatingResolver vr = new ValidatingResolver(sr); + vr.setTimeout(0, DNS_LOOKUP_TIMEOUT); + vr.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes("ASCII"))); + Record qr = Record.newRecord(Name.fromConstantString(name + "."), Type.TXT, DClass.IN); + Message response = vr.send(Message.newQuery(qr)); + final int rcode = response.getRcode(); + if (rcode != Rcode.NOERROR) { + Timber.i("Rcode: %s", Rcode.string(rcode)); + for (RRset set : response.getSectionRRsets(Section.ADDITIONAL)) { + if (set.getName().equals(Name.root) && set.getType() == Type.TXT + && set.getDClass() == ValidatingResolver.VALIDATION_REASON_QCLASS) { + Timber.i("Reason: %s", ((TXTRecord) set.first()).getStrings().get(0)); + } + } + return false; + } else { + dnssec = response.getHeader().getFlag(Flags.AD); + for (Record record : response.getSectionArray(Section.ANSWER)) { + if (record.getType() == Type.TXT) { + txts.addAll(((TXTRecord) record).getStrings()); + } + } + } + } catch (IOException | IllegalArgumentException ex) { + return false; + } + return true; + } + + @Override + public void onPostExecute(Boolean success) { + if (resolvedListener != null) + if (success) { + Map dataMap = new HashMap<>(); + for (String txt : txts) { + BarcodeData bc = BarcodeData.parseOpenAlias(txt, dnssec); + if (bc != null) { + if (!dataMap.containsKey(bc.asset)) { + dataMap.put(bc.asset, bc); + } + } + } + resolvedListener.onResolved(dataMap); + } else { + resolvedListener.onFailure(); + } + } + } + + private static class OpenAliasParser { + int currentPos = 0; + final String oaString; + StringBuilder sb = new StringBuilder(); + + OpenAliasParser(String oaString) { + this.oaString = oaString; + } + + Map parse() { + if ((oaString == null) || !oaString.startsWith(OA1_SCHEME)) return null; + if (oaString.charAt(oaString.length() - 1) != ';') return null; + + Map oaAttributes = new HashMap<>(); + + final int assetEnd = oaString.indexOf(' '); + if (assetEnd > 20) return null; // random sanity check + String asset = oaString.substring(OA1_SCHEME.length(), assetEnd); + oaAttributes.put(OA1_ASSET, asset); + + boolean inQuote = false; + boolean inKey = true; + String key = null; + for (currentPos = assetEnd; currentPos < oaString.length() - 1; currentPos++) { + char c = currentChar(); + if (inKey) { + if ((sb.length() == 0) && Character.isWhitespace(c)) continue; + if ((c == '\\') || (c == ';')) return null; + if (c == '=') { + key = sb.toString(); + if (oaAttributes.containsKey(key)) return null; // no duplicate keys allowed + sb.setLength(0); + inKey = false; + } else { + sb.append(c); + } + continue; + } + + // now we are in the value + if ((sb.length() == 0) && (c == '"')) { + inQuote = true; + continue; + } + if ((!inQuote || ((sb.length() > 0) && (c == '"'))) && (nextChar() == ';')) { + if (!inQuote) appendCurrentEscapedChar(); + oaAttributes.put(key, sb.toString()); + sb.setLength(0); + currentPos++; // skip the next ; + inQuote = false; + inKey = true; + key = null; + continue; + } + appendCurrentEscapedChar(); + } + if (inQuote) return null; + + if (key != null) { + oaAttributes.put(key, sb.toString()); + } + + return oaAttributes; + } + + char currentChar() { + return oaString.charAt(currentPos); + } + + char nextChar() throws IndexOutOfBoundsException { + int pos = currentPos; + char c = oaString.charAt(pos); + if (c == '\\') { + pos++; + } + return oaString.charAt(pos + 1); + } + + void appendCurrentEscapedChar() throws IndexOutOfBoundsException { + char c = oaString.charAt(currentPos); + if (c == '\\') { + c = oaString.charAt(++currentPos); + } + sb.append(c); + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/RestoreHeight.java b/app/src/main/java/com/m2049r/xmrwallet/util/RestoreHeight.java new file mode 100644 index 0000000..00e5952 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/RestoreHeight.java @@ -0,0 +1,211 @@ +/* + * 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.util; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +public class RestoreHeight { + static final int DIFFICULTY_TARGET = 120; // seconds + + static private RestoreHeight Singleton = null; + + static public RestoreHeight getInstance() { + if (Singleton == null) { + synchronized (RestoreHeight.class) { + if (Singleton == null) { + Singleton = new RestoreHeight(); + } + } + } + return Singleton; + } + + private Map blockheight = new HashMap<>(); + + RestoreHeight() { + blockheight.put("2014-05-01", 18844L); + blockheight.put("2014-06-01", 65406L); + blockheight.put("2014-07-01", 108882L); + blockheight.put("2014-08-01", 153594L); + blockheight.put("2014-09-01", 198072L); + blockheight.put("2014-10-01", 241088L); + blockheight.put("2014-11-01", 285305L); + blockheight.put("2014-12-01", 328069L); + blockheight.put("2015-01-01", 372369L); + blockheight.put("2015-02-01", 416505L); + blockheight.put("2015-03-01", 456631L); + blockheight.put("2015-04-01", 501084L); + blockheight.put("2015-05-01", 543973L); + blockheight.put("2015-06-01", 588326L); + blockheight.put("2015-07-01", 631187L); + blockheight.put("2015-08-01", 675484L); + blockheight.put("2015-09-01", 719725L); + blockheight.put("2015-10-01", 762463L); + blockheight.put("2015-11-01", 806528L); + blockheight.put("2015-12-01", 849041L); + blockheight.put("2016-01-01", 892866L); + blockheight.put("2016-02-01", 936736L); + blockheight.put("2016-03-01", 977691L); + blockheight.put("2016-04-01", 1015848L); + blockheight.put("2016-05-01", 1037417L); + blockheight.put("2016-06-01", 1059651L); + blockheight.put("2016-07-01", 1081269L); + blockheight.put("2016-08-01", 1103630L); + blockheight.put("2016-09-01", 1125983L); + blockheight.put("2016-10-01", 1147617L); + blockheight.put("2016-11-01", 1169779L); + blockheight.put("2016-12-01", 1191402L); + blockheight.put("2017-01-01", 1213861L); + blockheight.put("2017-02-01", 1236197L); + blockheight.put("2017-03-01", 1256358L); + blockheight.put("2017-04-01", 1278622L); + blockheight.put("2017-05-01", 1300239L); + blockheight.put("2017-06-01", 1322564L); + blockheight.put("2017-07-01", 1344225L); + blockheight.put("2017-08-01", 1366664L); + blockheight.put("2017-09-01", 1389113L); + blockheight.put("2017-10-01", 1410738L); + blockheight.put("2017-11-01", 1433039L); + blockheight.put("2017-12-01", 1454639L); + blockheight.put("2018-01-01", 1477201L); + blockheight.put("2018-02-01", 1499599L); + blockheight.put("2018-03-01", 1519796L); + blockheight.put("2018-04-01", 1542067L); + blockheight.put("2018-05-01", 1562861L); + blockheight.put("2018-06-01", 1585135L); + blockheight.put("2018-07-01", 1606715L); + blockheight.put("2018-08-01", 1629017L); + blockheight.put("2018-09-01", 1651347L); + blockheight.put("2018-10-01", 1673031L); + blockheight.put("2018-11-01", 1695128L); + blockheight.put("2018-12-01", 1716687L); + blockheight.put("2019-01-01", 1738923L); + blockheight.put("2019-02-01", 1761435L); + blockheight.put("2019-03-01", 1781681L); + blockheight.put("2019-04-01", 1803081L); + blockheight.put("2019-05-01", 1824671L); + blockheight.put("2019-06-01", 1847005L); + blockheight.put("2019-07-01", 1868590L); + blockheight.put("2019-08-01", 1890878L); + blockheight.put("2019-09-01", 1913201L); + blockheight.put("2019-10-01", 1934732L); + blockheight.put("2019-11-01", 1957051L); + blockheight.put("2019-12-01", 1978433L); + blockheight.put("2020-01-01", 2001315L); + blockheight.put("2020-02-01", 2023656L); + blockheight.put("2020-03-01", 2044552L); + blockheight.put("2020-04-01", 2066806L); + blockheight.put("2020-05-01", 2088411L); + blockheight.put("2020-06-01", 2110702L); + blockheight.put("2020-07-01", 2132318L); + blockheight.put("2020-08-01", 2154590L); + blockheight.put("2020-09-01", 2176790L); + blockheight.put("2020-10-01", 2198370L); + blockheight.put("2020-11-01", 2220670L); + blockheight.put("2020-12-01", 2242241L); + blockheight.put("2021-01-01", 2264584L); + blockheight.put("2021-02-01", 2286892L); + blockheight.put("2021-03-01", 2307079L); + blockheight.put("2021-04-01", 2329385L); + blockheight.put("2021-05-01", 2351004L); + blockheight.put("2021-06-01", 2373306L); + blockheight.put("2021-07-01", 2394882L); + blockheight.put("2021-08-01", 2417162L); + blockheight.put("2021-09-01", 2439490L); + blockheight.put("2021-10-01", 2461020L); + blockheight.put("2021-11-01", 2483377L); + blockheight.put("2021-12-01", 2504932L); + blockheight.put("2022-01-01", 2527316L); + blockheight.put("2022-02-01", 2549605L); + blockheight.put("2022-03-01", 2569711L); + } + + public long getHeight(String date) { + SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd"); + parser.setTimeZone(TimeZone.getTimeZone("UTC")); + parser.setLenient(false); + try { + return getHeight(parser.parse(date)); + } catch (ParseException ex) { + throw new IllegalArgumentException(ex); + } + } + + public long getHeight(final Date date) { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cal.set(Calendar.DST_OFFSET, 0); + cal.setTime(date); + cal.add(Calendar.DAY_OF_MONTH, -4); // give it some leeway + if (cal.get(Calendar.YEAR) < 2014) + return 0; + if ((cal.get(Calendar.YEAR) == 2014) && (cal.get(Calendar.MONTH) <= 3)) + // before May 2014 + return 0; + + Calendar query = (Calendar) cal.clone(); + + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + formatter.setTimeZone(TimeZone.getTimeZone("UTC")); + + String queryDate = formatter.format(date); + + cal.set(Calendar.DAY_OF_MONTH, 1); + long prevTime = cal.getTimeInMillis(); + String prevDate = formatter.format(prevTime); + // lookup blockheight at first of the month + Long prevBc = blockheight.get(prevDate); + if (prevBc == null) { + // if too recent, go back in time and find latest one we have + while (prevBc == null) { + cal.add(Calendar.MONTH, -1); + if (cal.get(Calendar.YEAR) < 2014) { + throw new IllegalStateException("endless loop looking for blockheight"); + } + prevTime = cal.getTimeInMillis(); + prevDate = formatter.format(prevTime); + prevBc = blockheight.get(prevDate); + } + } + long height = prevBc; + // now we have a blockheight & a date ON or BEFORE the restore date requested + if (queryDate.equals(prevDate)) return height; + // see if we have a blockheight after this date + cal.add(Calendar.MONTH, 1); + long nextTime = cal.getTimeInMillis(); + String nextDate = formatter.format(nextTime); + Long nextBc = blockheight.get(nextDate); + if (nextBc != null) { // we have a range - interpolate the blockheight we are looking for + long diff = nextBc - prevBc; + long diffDays = TimeUnit.DAYS.convert(nextTime - prevTime, TimeUnit.MILLISECONDS); + long days = TimeUnit.DAYS.convert(query.getTimeInMillis() - prevTime, + TimeUnit.MILLISECONDS); + height = Math.round(prevBc + diff * (1.0 * days / diffDays)); + } else { + long days = TimeUnit.DAYS.convert(query.getTimeInMillis() - prevTime, + TimeUnit.MILLISECONDS); + height = Math.round(prevBc + 1.0 * days * (24f * 60 * 60 / DIFFICULTY_TARGET)); + } + return height; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ServiceHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/ServiceHelper.java new file mode 100644 index 0000000..2762291 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ServiceHelper.java @@ -0,0 +1,24 @@ +package com.m2049r.xmrwallet.util; + +import com.m2049r.xmrwallet.model.NetworkType; +import com.m2049r.xmrwallet.model.WalletManager; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; + +import okhttp3.HttpUrl; + +public class ServiceHelper { + public static String ASSET = null; + + static public HttpUrl getXmrToBaseUrl() { + if ((WalletManager.getInstance() == null) + || (WalletManager.getInstance().getNetworkType() != NetworkType.NetworkType_Mainnet)) { + throw new IllegalStateException("Only mainnet not supported"); + } else { + return HttpUrl.parse("https://sideshift.ai/api/v1/"); + } + } + + static public ExchangeApi getExchangeApi() { + return new com.m2049r.xmrwallet.service.exchange.krakenEcb.ExchangeApiImpl(); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ThemeHelper.java b/app/src/main/java/com/m2049r/xmrwallet/util/ThemeHelper.java new file mode 100644 index 0000000..21ff733 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ThemeHelper.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019 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.util; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Color; +import android.preference.PreferenceManager; +import android.util.TypedValue; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.R; + +public class ThemeHelper { + static public int getThemedResourceId(Context ctx, int attrId) { + final TypedValue typedValue = new TypedValue(); + if (ctx.getTheme().resolveAttribute(attrId, typedValue, true)) + return typedValue.resourceId; + else + return 0; + } + + @ColorInt + static public int getThemedColor(Context ctx, int attrId) { + final TypedValue typedValue = new TypedValue(); + if (ctx.getTheme().resolveAttribute(attrId, typedValue, true)) + return typedValue.data; + else + return Color.BLACK; + } + + public static void setTheme(@NonNull Activity activity, @NonNull String theme) { + switch (theme) { + case "Classic": + activity.setTheme(R.style.MyMaterialThemeClassic); + break; + case "Oled": + activity.setTheme(R.style.MyMaterialThemeOled); + break; + } + } + + public static void setPreferred(Activity activity) { + final String theme = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(activity.getString(R.string.preferred_theme), "Classic"); + setTheme(activity, theme); + } + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ZipBackup.java b/app/src/main/java/com/m2049r/xmrwallet/util/ZipBackup.java new file mode 100644 index 0000000..cfdc705 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ZipBackup.java @@ -0,0 +1,64 @@ +package com.m2049r.xmrwallet.util; + +import android.content.Context; +import android.net.Uri; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ZipBackup { + final private Context context; + final private String walletName; + + private ZipOutputStream zip; + + public void writeTo(Uri zipUri) throws IOException { + if (zip != null) + throw new IllegalStateException("zip already initialized"); + try { + zip = new ZipOutputStream(context.getContentResolver().openOutputStream(zipUri)); + + final File walletRoot = Helper.getWalletRoot(context); + addFile(new File(walletRoot, walletName + ".keys")); + addFile(new File(walletRoot, walletName)); + + zip.close(); + } finally { + if (zip != null) zip.close(); + } + } + + private void addFile(File file) throws IOException { + if (!file.exists()) return; // ignore missing files (e.g. the cache file might not exist) + ZipEntry entry = new ZipEntry(file.getName()); + zip.putNextEntry(entry); + writeFile(file); + zip.closeEntry(); + } + + private void writeFile(File source) throws IOException { + try (InputStream is = new FileInputStream(source)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = is.read(buffer)) > 0) { + zip.write(buffer, 0, length); + } + } + } + + private static final SimpleDateFormat DATETIME_FORMATTER = + new SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss"); + + public String getBackupName() { + return walletName + " " + DATETIME_FORMATTER.format(new Date()); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ZipRestore.java b/app/src/main/java/com/m2049r/xmrwallet/util/ZipRestore.java new file mode 100644 index 0000000..1b9148b --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ZipRestore.java @@ -0,0 +1,139 @@ +package com.m2049r.xmrwallet.util; + +import android.content.Context; +import android.net.Uri; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import lombok.RequiredArgsConstructor; +import timber.log.Timber; + +@RequiredArgsConstructor +public class ZipRestore { + final private Context context; + final private Uri zipUri; + + private File walletRoot; + + private ZipInputStream zip; + + public boolean restore() throws IOException { + walletRoot = Helper.getWalletRoot(context); + String walletName = testArchive(); + if (walletName == null) return false; + + walletName = getUniqueName(walletName); + + if (zip != null) + throw new IllegalStateException("zip already initialized"); + try { + zip = new ZipInputStream(context.getContentResolver().openInputStream(zipUri)); + for (ZipEntry entry = zip.getNextEntry(); entry != null; zip.closeEntry(), entry = zip.getNextEntry()) { + File destination; + final String name = entry.getName(); + if (name.endsWith(".keys")) { + destination = new File(walletRoot, walletName + ".keys"); + } else if (name.endsWith(".address.txt")) { + destination = new File(walletRoot, walletName + ".address.txt"); + } else { + destination = new File(walletRoot, walletName); + } + writeFile(destination); + } + } finally { + if (zip != null) zip.close(); + } + return true; + } + + private void writeFile(File destination) throws IOException { + try (OutputStream os = new FileOutputStream(destination)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = zip.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + } + } + + // test the archive to contain files we expect & return the name of the contained wallet or null + private String testArchive() { + String walletName = null; + boolean keys = false; + ZipInputStream zipStream = null; + try { + zipStream = new ZipInputStream(context.getContentResolver().openInputStream(zipUri)); + for (ZipEntry entry = zipStream.getNextEntry(); entry != null; + zipStream.closeEntry(), entry = zipStream.getNextEntry()) { + if (entry.isDirectory()) + return null; + final String name = entry.getName(); + if ((new File(name)).getParentFile() != null) + return null; + if (walletName == null) { + if (name.endsWith(".keys")) { + walletName = name.substring(0, name.length() - ".keys".length()); + keys = true; // we have they keys + } else if (name.endsWith(".address.txt")) { + walletName = name.substring(0, name.length() - ".address.txt".length()); + } else { + walletName = name; + } + } else { // we have a wallet name + if (name.endsWith(".keys")) { + if (!name.equals(walletName + ".keys")) return null; + keys = true; // we have they keys + } else if (name.endsWith(".address.txt")) { + if (!name.equals(walletName + ".address.txt")) return null; + } else if (!name.equals(walletName)) return null; + } + } + } catch (IOException ex) { + return null; + } finally { + try { + if (zipStream != null) zipStream.close(); + } catch (IOException ex) { + Timber.w(ex); + } + } + // we need the keys at least + if (keys) return walletName; + else return null; + } + + final static Pattern WALLET_PATTERN = Pattern.compile("^(.+) \\(([0-9]+)\\).keys$"); + + private String getUniqueName(String name) { + if (!(new File(walletRoot, name + ".keys")).exists()) // does not exist => it's ok to use + return name; + + File[] wallets = walletRoot.listFiles( + (dir, filename) -> { + Matcher m = WALLET_PATTERN.matcher(filename); + if (m.find()) + return m.group(1).equals(name); + else return false; + }); + if (wallets.length == 0) return name + " (1)"; + int maxIndex = 0; + for (File wallet : wallets) { + try { + final Matcher m = WALLET_PATTERN.matcher(wallet.getName()); + m.find(); + final int index = Integer.parseInt(m.group(2)); + if (index > maxIndex) maxIndex = index; + } catch (NumberFormatException ex) { + // this cannot happen & we can ignore it if it does + } + } + return name + " (" + (maxIndex + 1) + ")"; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ledger/ECsecp256k1.java b/app/src/main/java/com/m2049r/xmrwallet/util/ledger/ECsecp256k1.java new file mode 100644 index 0000000..1aef005 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ledger/ECsecp256k1.java @@ -0,0 +1,81 @@ +/* + * Based on + * https://stackoverflow.com/a/19943894 + * + * Curve parameters from + * https://en.bitcoin.it/wiki/Secp256k1 + * + * Copyright (c) 2019 m2049r + * Copyright (c) 2013 ChiaraHsieh + * + * 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.util.ledger; + +import java.math.BigInteger; +import java.security.spec.ECPoint; + +public class ECsecp256k1 { + static private final BigInteger TWO = new BigInteger("2"); + static private final BigInteger THREE = new BigInteger("3"); + static public final BigInteger p = new BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16); + static public final BigInteger a = new BigInteger("0000000000000000000000000000000000000000000000000000000000000000", 16); + static public final BigInteger b = new BigInteger("0000000000000000000000000000000000000000000000000000000000000007", 16); + static public final BigInteger n = new BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16); + static public final ECPoint G = new ECPoint( + new BigInteger("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 16), + new BigInteger("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 16)); + + public static ECPoint scalmult(BigInteger kin, ECPoint P) { + ECPoint R = ECPoint.POINT_INFINITY, S = P; + BigInteger k = kin.mod(n); // not necessary b/c that's how curves work + int length = k.bitLength(); + byte[] binarray = new byte[length]; + for (int i = 0; i <= length - 1; i++) { + binarray[i] = k.mod(TWO).byteValue(); + k = k.divide(TWO); + } + for (int i = length - 1; i >= 0; i--) { + // i should start at length-1 not -2 because the MSB of binary may not be 1 + R = doublePoint(R); + if (binarray[i] == 1) + R = addPoint(R, S); + } + return R; + } + + public static ECPoint addPoint(ECPoint r, ECPoint s) { + if (r.equals(s)) + return doublePoint(r); + else if (r.equals(ECPoint.POINT_INFINITY)) + return s; + else if (s.equals(ECPoint.POINT_INFINITY)) + return r; + BigInteger slope = (r.getAffineY().subtract(s.getAffineY())) + .multiply(r.getAffineX().subtract(s.getAffineX()).modInverse(p)); + BigInteger Xout = (slope.modPow(TWO, p).subtract(r.getAffineX())).subtract(s.getAffineX()).mod(p); + BigInteger Yout = s.getAffineY().negate().add(slope.multiply(s.getAffineX().subtract(Xout))).mod(p); + return new ECPoint(Xout, Yout); + } + + public static ECPoint doublePoint(ECPoint r) { + if (r.equals(ECPoint.POINT_INFINITY)) + return r; + BigInteger slope = (r.getAffineX().pow(2)).multiply(THREE).add(a) + .multiply((r.getAffineY().multiply(TWO)).modInverse(p)); + BigInteger Xout = slope.pow(2).subtract(r.getAffineX().multiply(TWO)).mod(p); + BigInteger Yout = (r.getAffineY().negate()).add(slope.multiply(r.getAffineX().subtract(Xout))).mod(p); + return new ECPoint(Xout, Yout); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/ledger/Monero.java b/app/src/main/java/com/m2049r/xmrwallet/util/ledger/Monero.java new file mode 100644 index 0000000..f4a3fdd --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/ledger/Monero.java @@ -0,0 +1,1866 @@ +/* + * A quick and hacky Java implementation of most of + * https://github.com/LedgerHQ/ledger-app-monero/blob/master/tools/python/src/ledger/monero/seedconv.py + * + * Copyright (c) 2019 m2049r + * Copyright 2018 Cedric Mesnil , Ledger SAS + * + * 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.util.ledger; + +import com.theromus.sha.Keccak; +import com.theromus.sha.Parameters; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.ECPoint; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.CRC32; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import timber.log.Timber; + +public class Monero { + + static public String convert(String mnemonic, String passphrase) { + String[] words = mnemonic.toLowerCase().split("\\s"); + StringBuilder normalizedMnemonic = new StringBuilder(); + int wordCount = 0; + for (String word : words) { + if (word.length() == 0) continue; + if (wordCount > 0) normalizedMnemonic.append(" "); + wordCount++; + normalizedMnemonic.append(word); + } + if ((wordCount != 12) && (wordCount != 24) && (wordCount != 18)) return null; + Monero seed = new Monero(); + try { + return seed.getMnemonic(normalizedMnemonic.toString(), passphrase); + } catch (IllegalStateException | IllegalArgumentException ex) { + return null; + } + } + + private byte[] seed; + private byte[] mkey; + private byte[] mchain; + private byte[] monero_ki; + private byte[] monero_ci; + private byte[] view_key; + private byte[] spend_key; + + private static byte[] NKFDbytes(String str) { + return Normalizer.normalize(str, Normalizer.Form.NFKD).getBytes(); + } + + private static char[] NKFDchars(String str) { + return Normalizer.normalize(str, Normalizer.Form.NFKD).toCharArray(); + } + + private static byte[] fixByteArray32(byte[] b) { + if ((b.length > 33)) throw new IllegalStateException(); + if ((b.length == 33) && (b[0] != 0)) throw new IllegalStateException(); + if (b.length == 33) + return Arrays.copyOfRange(b, 1, 33); + else + return b; + } + + private static byte[] intToBytes(int i, int bytes) { + ByteBuffer buffer = ByteBuffer.allocate(bytes); + buffer.putInt(i); + return buffer.array(); + } + + private static void reverse(byte[] b) { + for (int i = 0; i < b.length / 2; i++) { + byte temp = b[i]; + b[i] = b[b.length - i - 1]; + b[b.length - i - 1] = temp; + } + } + + private void derive(String path) + throws NoSuchAlgorithmException, InvalidKeyException { + byte[] kpar = Arrays.copyOf(mkey, 32); + byte[] cpar = Arrays.copyOf(mchain, 32); + + String[] pathSegments = path.split("/"); + if (!pathSegments[0].equals("m")) + throw new IllegalArgumentException("Path must start with 'm'"); + for (int i = 1; i < pathSegments.length; i++) { + String child = pathSegments[i]; + boolean hardened = child.charAt(child.length() - 1) == '\''; + + byte[] data = new byte[33 + 4]; + if (hardened) { + int c = Integer.parseInt(child.substring(0, child.length() - 1)); + c += 0x80000000; + data[0] = 0; + System.arraycopy(kpar, 0, data, 1, kpar.length); + System.arraycopy(intToBytes(c, 4), 0, data, 1 + kpar.length, 4); + } else { + int c = Integer.parseInt(child); + BigInteger k = new BigInteger(1, kpar); + ECPoint kG = ECsecp256k1.scalmult(k, ECsecp256k1.G); + byte[] xBytes = fixByteArray32(kG.getAffineX().toByteArray()); + byte[] Wpar = new byte[33]; + System.arraycopy(xBytes, 0, Wpar, 33 - xBytes.length, xBytes.length); + byte[] yBytes = fixByteArray32(kG.getAffineY().toByteArray()); + if ((yBytes[yBytes.length - 1] & 1) == 0) + Wpar[0] = 0x02; + else + Wpar[0] = 0x03; + System.arraycopy(Wpar, 0, data, 0, Wpar.length); + System.arraycopy(intToBytes(c, 4), 0, data, Wpar.length, 4); + } + + SecretKeySpec keySpec = new SecretKeySpec(cpar, "HmacSHA512"); + Mac mac = Mac.getInstance("HmacSHA512"); + mac.init(keySpec); + byte[] I = mac.doFinal(data); + BigInteger Il = new BigInteger(1, Arrays.copyOfRange(I, 0, 32)); + BigInteger kparInt = new BigInteger(1, kpar); + Il = Il.add(kparInt).mod(ECsecp256k1.n); + byte[] IlBytes = fixByteArray32(Il.toByteArray()); + kpar = new byte[32]; + System.arraycopy(IlBytes, 0, kpar, 0, 32); + System.arraycopy(I, 32, cpar, 0, I.length - 32); + } + monero_ki = kpar; + monero_ci = cpar; + } + + private void makeSeed(String mnemonic, String passphrase) + throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2withHmacSHA512"); + KeySpec spec = new PBEKeySpec(NKFDchars(mnemonic), NKFDbytes("mnemonic" + passphrase), 2048, 512); + seed = skf.generateSecret(spec).getEncoded(); + } + + private void makeMasterKey() + throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec keySpec = new SecretKeySpec( + NKFDbytes("Bitcoin seed"), + "HmacSHA512"); + Mac mac = Mac.getInstance("HmacSHA512"); + mac.init(keySpec); + byte[] result = mac.doFinal(seed); + mkey = Arrays.copyOfRange(result, 0, 32); + mchain = Arrays.copyOfRange(result, 32, 64); + } + + private void makeKeys() { + BigInteger l = new BigInteger("1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed", 16); + Keccak keccak = new Keccak(); + final byte[] b = keccak.getHash(monero_ki, Parameters.KECCAK_256); + reverse(b); + BigInteger ble = new BigInteger(1, b).mod(l); + spend_key = fixByteArray32(ble.toByteArray()); + reverse(spend_key); + byte[] a = keccak.getHash(spend_key, Parameters.KECCAK_256); + reverse(a); + BigInteger ale = new BigInteger(1, a).mod(l); + view_key = fixByteArray32(ale.toByteArray()); + reverse(view_key); + } + + private String getWords() { + if (spend_key.length != 32) throw new IllegalArgumentException(); + String[] wordList = ENGLISH_WORDS; + List words = new ArrayList<>(); + for (int i = 0; i < spend_key.length / 4; i++) { + long val = ((long) (spend_key[i * 4 + 0] & 0xff) << 0) | + ((long) (spend_key[i * 4 + 1] & 0xff) << 8) | + ((long) (spend_key[i * 4 + 2] & 0xff) << 16) | + ((long) (spend_key[i * 4 + 3] & 0xff) << 24); + long w1 = val % wordList.length; + long w2 = ((val / wordList.length) + w1) % wordList.length; + long w3 = (((val / wordList.length) / wordList.length) + w2) % wordList.length; + + words.add(wordList[(int) w1]); + words.add(wordList[(int) w2]); + words.add(wordList[(int) w3]); + } + + StringBuilder mnemonic = new StringBuilder(); + StringBuilder trimmedWords = new StringBuilder(); + + for (String word : words) { + mnemonic.append(word).append(" "); + trimmedWords.append(word.substring(0, ENGLISH_PREFIX_LENGTH)); + } + CRC32 crc32 = new CRC32(); + crc32.update(trimmedWords.toString().getBytes(StandardCharsets.UTF_8)); + long checksum = crc32.getValue(); + mnemonic.append(words.get((int) (checksum % 24))); + return mnemonic.toString(); + } + + private String getMnemonic(String ledgerMnemonic, String passphrase) { + try { + makeSeed(ledgerMnemonic, passphrase); + makeMasterKey(); + derive("m/44'/128'/0'/0/0"); + makeKeys(); + return getWords(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidKeyException ex) { + Timber.e(ex); + } + return null; + } + + public static final int ENGLISH_PREFIX_LENGTH = 3; + public static final String[] ENGLISH_WORDS = { + "abbey", + "abducts", + "ability", + "ablaze", + "abnormal", + "abort", + "abrasive", + "absorb", + "abyss", + "academy", + "aces", + "aching", + "acidic", + "acoustic", + "acquire", + "across", + "actress", + "acumen", + "adapt", + "addicted", + "adept", + "adhesive", + "adjust", + "adopt", + "adrenalin", + "adult", + "adventure", + "aerial", + "afar", + "affair", + "afield", + "afloat", + "afoot", + "afraid", + "after", + "against", + "agenda", + "aggravate", + "agile", + "aglow", + "agnostic", + "agony", + "agreed", + "ahead", + "aided", + "ailments", + "aimless", + "airport", + "aisle", + "ajar", + "akin", + "alarms", + "album", + "alchemy", + "alerts", + "algebra", + "alkaline", + "alley", + "almost", + "aloof", + "alpine", + "already", + "also", + "altitude", + "alumni", + "always", + "amaze", + "ambush", + "amended", + "amidst", + "ammo", + "amnesty", + "among", + "amply", + "amused", + "anchor", + "android", + "anecdote", + "angled", + "ankle", + "annoyed", + "answers", + "antics", + "anvil", + "anxiety", + "anybody", + "apart", + "apex", + "aphid", + "aplomb", + "apology", + "apply", + "apricot", + "aptitude", + "aquarium", + "arbitrary", + "archer", + "ardent", + "arena", + "argue", + "arises", + "army", + "around", + "arrow", + "arsenic", + "artistic", + "ascend", + "ashtray", + "aside", + "asked", + "asleep", + "aspire", + "assorted", + "asylum", + "athlete", + "atlas", + "atom", + "atrium", + "attire", + "auburn", + "auctions", + "audio", + "august", + "aunt", + "austere", + "autumn", + "avatar", + "avidly", + "avoid", + "awakened", + "awesome", + "awful", + "awkward", + "awning", + "awoken", + "axes", + "axis", + "axle", + "aztec", + "azure", + "baby", + "bacon", + "badge", + "baffles", + "bagpipe", + "bailed", + "bakery", + "balding", + "bamboo", + "banjo", + "baptism", + "basin", + "batch", + "bawled", + "bays", + "because", + "beer", + "befit", + "begun", + "behind", + "being", + "below", + "bemused", + "benches", + "berries", + "bested", + "betting", + "bevel", + "beware", + "beyond", + "bias", + "bicycle", + "bids", + "bifocals", + "biggest", + "bikini", + "bimonthly", + "binocular", + "biology", + "biplane", + "birth", + "biscuit", + "bite", + "biweekly", + "blender", + "blip", + "bluntly", + "boat", + "bobsled", + "bodies", + "bogeys", + "boil", + "boldly", + "bomb", + "border", + "boss", + "both", + "bounced", + "bovine", + "bowling", + "boxes", + "boyfriend", + "broken", + "brunt", + "bubble", + "buckets", + "budget", + "buffet", + "bugs", + "building", + "bulb", + "bumper", + "bunch", + "business", + "butter", + "buying", + "buzzer", + "bygones", + "byline", + "bypass", + "cabin", + "cactus", + "cadets", + "cafe", + "cage", + "cajun", + "cake", + "calamity", + "camp", + "candy", + "casket", + "catch", + "cause", + "cavernous", + "cease", + "cedar", + "ceiling", + "cell", + "cement", + "cent", + "certain", + "chlorine", + "chrome", + "cider", + "cigar", + "cinema", + "circle", + "cistern", + "citadel", + "civilian", + "claim", + "click", + "clue", + "coal", + "cobra", + "cocoa", + "code", + "coexist", + "coffee", + "cogs", + "cohesive", + "coils", + "colony", + "comb", + "cool", + "copy", + "corrode", + "costume", + "cottage", + "cousin", + "cowl", + "criminal", + "cube", + "cucumber", + "cuddled", + "cuffs", + "cuisine", + "cunning", + "cupcake", + "custom", + "cycling", + "cylinder", + "cynical", + "dabbing", + "dads", + "daft", + "dagger", + "daily", + "damp", + "dangerous", + "dapper", + "darted", + "dash", + "dating", + "dauntless", + "dawn", + "daytime", + "dazed", + "debut", + "decay", + "dedicated", + "deepest", + "deftly", + "degrees", + "dehydrate", + "deity", + "dejected", + "delayed", + "demonstrate", + "dented", + "deodorant", + "depth", + "desk", + "devoid", + "dewdrop", + "dexterity", + "dialect", + "dice", + "diet", + "different", + "digit", + "dilute", + "dime", + "dinner", + "diode", + "diplomat", + "directed", + "distance", + "ditch", + "divers", + "dizzy", + "doctor", + "dodge", + "does", + "dogs", + "doing", + "dolphin", + "domestic", + "donuts", + "doorway", + "dormant", + "dosage", + "dotted", + "double", + "dove", + "down", + "dozen", + "dreams", + "drinks", + "drowning", + "drunk", + "drying", + "dual", + "dubbed", + "duckling", + "dude", + "duets", + "duke", + "dullness", + "dummy", + "dunes", + "duplex", + "duration", + "dusted", + "duties", + "dwarf", + "dwelt", + "dwindling", + "dying", + "dynamite", + "dyslexic", + "each", + "eagle", + "earth", + "easy", + "eating", + "eavesdrop", + "eccentric", + "echo", + "eclipse", + "economics", + "ecstatic", + "eden", + "edgy", + "edited", + "educated", + "eels", + "efficient", + "eggs", + "egotistic", + "eight", + "either", + "eject", + "elapse", + "elbow", + "eldest", + "eleven", + "elite", + "elope", + "else", + "eluded", + "emails", + "ember", + "emerge", + "emit", + "emotion", + "empty", + "emulate", + "energy", + "enforce", + "enhanced", + "enigma", + "enjoy", + "enlist", + "enmity", + "enough", + "enraged", + "ensign", + "entrance", + "envy", + "epoxy", + "equip", + "erase", + "erected", + "erosion", + "error", + "eskimos", + "espionage", + "essential", + "estate", + "etched", + "eternal", + "ethics", + "etiquette", + "evaluate", + "evenings", + "evicted", + "evolved", + "examine", + "excess", + "exhale", + "exit", + "exotic", + "exquisite", + "extra", + "exult", + "fabrics", + "factual", + "fading", + "fainted", + "faked", + "fall", + "family", + "fancy", + "farming", + "fatal", + "faulty", + "fawns", + "faxed", + "fazed", + "feast", + "february", + "federal", + "feel", + "feline", + "females", + "fences", + "ferry", + "festival", + "fetches", + "fever", + "fewest", + "fiat", + "fibula", + "fictional", + "fidget", + "fierce", + "fifteen", + "fight", + "films", + "firm", + "fishing", + "fitting", + "five", + "fixate", + "fizzle", + "fleet", + "flippant", + "flying", + "foamy", + "focus", + "foes", + "foggy", + "foiled", + "folding", + "fonts", + "foolish", + "fossil", + "fountain", + "fowls", + "foxes", + "foyer", + "framed", + "friendly", + "frown", + "fruit", + "frying", + "fudge", + "fuel", + "fugitive", + "fully", + "fuming", + "fungal", + "furnished", + "fuselage", + "future", + "fuzzy", + "gables", + "gadget", + "gags", + "gained", + "galaxy", + "gambit", + "gang", + "gasp", + "gather", + "gauze", + "gave", + "gawk", + "gaze", + "gearbox", + "gecko", + "geek", + "gels", + "gemstone", + "general", + "geometry", + "germs", + "gesture", + "getting", + "geyser", + "ghetto", + "ghost", + "giant", + "giddy", + "gifts", + "gigantic", + "gills", + "gimmick", + "ginger", + "girth", + "giving", + "glass", + "gleeful", + "glide", + "gnaw", + "gnome", + "goat", + "goblet", + "godfather", + "goes", + "goggles", + "going", + "goldfish", + "gone", + "goodbye", + "gopher", + "gorilla", + "gossip", + "gotten", + "gourmet", + "governing", + "gown", + "greater", + "grunt", + "guarded", + "guest", + "guide", + "gulp", + "gumball", + "gur", + "gusts", + "gutter", + "guys", + "gymnast", + "gypsy", + "gyrate", + "habitat", + "hacksaw", + "haggled", + "hairy", + "hamburger", + "happens", + "hashing", + "hatchet", + "haunted", + "having", + "hawk", + "haystack", + "hazard", + "hectare", + "hedgehog", + "heels", + "hefty", + "height", + "hemlock", + "hence", + "heron", + "hesitate", + "hexagon", + "hickory", + "hiding", + "highway", + "hijack", + "hiker", + "hills", + "himself", + "hinder", + "hippo", + "hire", + "history", + "hitched", + "hive", + "hoax", + "hobby", + "hockey", + "hoisting", + "hold", + "honked", + "hookup", + "hope", + "hornet", + "hospital", + "hotel", + "hounded", + "hover", + "howls", + "hubcaps", + "huddle", + "huge", + "hull", + "humid", + "hunter", + "hurried", + "husband", + "huts", + "hybrid", + "hydrogen", + "hyper", + "iceberg", + "icing", + "icon", + "identity", + "idiom", + "idled", + "idols", + "igloo", + "ignore", + "iguana", + "illness", + "imagine", + "imbalance", + "imitate", + "impel", + "inactive", + "inbound", + "incur", + "industrial", + "inexact", + "inflamed", + "ingested", + "initiate", + "injury", + "inkling", + "inline", + "inmate", + "innocent", + "inorganic", + "input", + "inquest", + "inroads", + "insult", + "intended", + "inundate", + "invoke", + "inwardly", + "ionic", + "irate", + "iris", + "irony", + "irritate", + "island", + "isolated", + "issued", + "italics", + "itches", + "items", + "itinerary", + "itself", + "ivory", + "jabbed", + "jackets", + "jaded", + "jagged", + "jailed", + "jamming", + "january", + "jargon", + "jaunt", + "javelin", + "jaws", + "jazz", + "jeans", + "jeers", + "jellyfish", + "jeopardy", + "jerseys", + "jester", + "jetting", + "jewels", + "jigsaw", + "jingle", + "jittery", + "jive", + "jobs", + "jockey", + "jogger", + "joining", + "joking", + "jolted", + "jostle", + "journal", + "joyous", + "jubilee", + "judge", + "juggled", + "juicy", + "jukebox", + "july", + "jump", + "junk", + "jury", + "justice", + "juvenile", + "kangaroo", + "karate", + "keep", + "kennel", + "kept", + "kernels", + "kettle", + "keyboard", + "kickoff", + "kidneys", + "king", + "kiosk", + "kisses", + "kitchens", + "kiwi", + "knapsack", + "knee", + "knife", + "knowledge", + "knuckle", + "koala", + "laboratory", + "ladder", + "lagoon", + "lair", + "lakes", + "lamb", + "language", + "laptop", + "large", + "last", + "later", + "launching", + "lava", + "lawsuit", + "layout", + "lazy", + "lectures", + "ledge", + "leech", + "left", + "legion", + "leisure", + "lemon", + "lending", + "leopard", + "lesson", + "lettuce", + "lexicon", + "liar", + "library", + "licks", + "lids", + "lied", + "lifestyle", + "light", + "likewise", + "lilac", + "limits", + "linen", + "lion", + "lipstick", + "liquid", + "listen", + "lively", + "loaded", + "lobster", + "locker", + "lodge", + "lofty", + "logic", + "loincloth", + "long", + "looking", + "lopped", + "lordship", + "losing", + "lottery", + "loudly", + "love", + "lower", + "loyal", + "lucky", + "luggage", + "lukewarm", + "lullaby", + "lumber", + "lunar", + "lurk", + "lush", + "luxury", + "lymph", + "lynx", + "lyrics", + "macro", + "madness", + "magically", + "mailed", + "major", + "makeup", + "malady", + "mammal", + "maps", + "masterful", + "match", + "maul", + "maverick", + "maximum", + "mayor", + "maze", + "meant", + "mechanic", + "medicate", + "meeting", + "megabyte", + "melting", + "memoir", + "men", + "merger", + "mesh", + "metro", + "mews", + "mice", + "midst", + "mighty", + "mime", + "mirror", + "misery", + "mittens", + "mixture", + "moat", + "mobile", + "mocked", + "mohawk", + "moisture", + "molten", + "moment", + "money", + "moon", + "mops", + "morsel", + "mostly", + "motherly", + "mouth", + "movement", + "mowing", + "much", + "muddy", + "muffin", + "mugged", + "mullet", + "mumble", + "mundane", + "muppet", + "mural", + "musical", + "muzzle", + "myriad", + "mystery", + "myth", + "nabbing", + "nagged", + "nail", + "names", + "nanny", + "napkin", + "narrate", + "nasty", + "natural", + "nautical", + "navy", + "nearby", + "necklace", + "needed", + "negative", + "neither", + "neon", + "nephew", + "nerves", + "nestle", + "network", + "neutral", + "never", + "newt", + "nexus", + "nibs", + "niche", + "niece", + "nifty", + "nightly", + "nimbly", + "nineteen", + "nirvana", + "nitrogen", + "nobody", + "nocturnal", + "nodes", + "noises", + "nomad", + "noodles", + "northern", + "nostril", + "noted", + "nouns", + "novelty", + "nowhere", + "nozzle", + "nuance", + "nucleus", + "nudged", + "nugget", + "nuisance", + "null", + "number", + "nuns", + "nurse", + "nutshell", + "nylon", + "oaks", + "oars", + "oasis", + "oatmeal", + "obedient", + "object", + "obliged", + "obnoxious", + "observant", + "obtains", + "obvious", + "occur", + "ocean", + "october", + "odds", + "odometer", + "offend", + "often", + "oilfield", + "ointment", + "okay", + "older", + "olive", + "olympics", + "omega", + "omission", + "omnibus", + "onboard", + "oncoming", + "oneself", + "ongoing", + "onion", + "online", + "onslaught", + "onto", + "onward", + "oozed", + "opacity", + "opened", + "opposite", + "optical", + "opus", + "orange", + "orbit", + "orchid", + "orders", + "organs", + "origin", + "ornament", + "orphans", + "oscar", + "ostrich", + "otherwise", + "otter", + "ouch", + "ought", + "ounce", + "ourselves", + "oust", + "outbreak", + "oval", + "oven", + "owed", + "owls", + "owner", + "oxidant", + "oxygen", + "oyster", + "ozone", + "pact", + "paddles", + "pager", + "pairing", + "palace", + "pamphlet", + "pancakes", + "paper", + "paradise", + "pastry", + "patio", + "pause", + "pavements", + "pawnshop", + "payment", + "peaches", + "pebbles", + "peculiar", + "pedantic", + "peeled", + "pegs", + "pelican", + "pencil", + "people", + "pepper", + "perfect", + "pests", + "petals", + "phase", + "pheasants", + "phone", + "phrases", + "physics", + "piano", + "picked", + "pierce", + "pigment", + "piloted", + "pimple", + "pinched", + "pioneer", + "pipeline", + "pirate", + "pistons", + "pitched", + "pivot", + "pixels", + "pizza", + "playful", + "pledge", + "pliers", + "plotting", + "plus", + "plywood", + "poaching", + "pockets", + "podcast", + "poetry", + "point", + "poker", + "polar", + "ponies", + "pool", + "popular", + "portents", + "possible", + "potato", + "pouch", + "poverty", + "powder", + "pram", + "present", + "pride", + "problems", + "pruned", + "prying", + "psychic", + "public", + "puck", + "puddle", + "puffin", + "pulp", + "pumpkins", + "punch", + "puppy", + "purged", + "push", + "putty", + "puzzled", + "pylons", + "pyramid", + "python", + "queen", + "quick", + "quote", + "rabbits", + "racetrack", + "radar", + "rafts", + "rage", + "railway", + "raking", + "rally", + "ramped", + "randomly", + "rapid", + "rarest", + "rash", + "rated", + "ravine", + "rays", + "razor", + "react", + "rebel", + "recipe", + "reduce", + "reef", + "refer", + "regular", + "reheat", + "reinvest", + "rejoices", + "rekindle", + "relic", + "remedy", + "renting", + "reorder", + "repent", + "request", + "reruns", + "rest", + "return", + "reunion", + "revamp", + "rewind", + "rhino", + "rhythm", + "ribbon", + "richly", + "ridges", + "rift", + "rigid", + "rims", + "ringing", + "riots", + "ripped", + "rising", + "ritual", + "river", + "roared", + "robot", + "rockets", + "rodent", + "rogue", + "roles", + "romance", + "roomy", + "roped", + "roster", + "rotate", + "rounded", + "rover", + "rowboat", + "royal", + "ruby", + "rudely", + "ruffled", + "rugged", + "ruined", + "ruling", + "rumble", + "runway", + "rural", + "rustled", + "ruthless", + "sabotage", + "sack", + "sadness", + "safety", + "saga", + "sailor", + "sake", + "salads", + "sample", + "sanity", + "sapling", + "sarcasm", + "sash", + "satin", + "saucepan", + "saved", + "sawmill", + "saxophone", + "sayings", + "scamper", + "scenic", + "school", + "science", + "scoop", + "scrub", + "scuba", + "seasons", + "second", + "sedan", + "seeded", + "segments", + "seismic", + "selfish", + "semifinal", + "sensible", + "september", + "sequence", + "serving", + "session", + "setup", + "seventh", + "sewage", + "shackles", + "shelter", + "shipped", + "shocking", + "shrugged", + "shuffled", + "shyness", + "siblings", + "sickness", + "sidekick", + "sieve", + "sifting", + "sighting", + "silk", + "simplest", + "sincerely", + "sipped", + "siren", + "situated", + "sixteen", + "sizes", + "skater", + "skew", + "skirting", + "skulls", + "skydive", + "slackens", + "sleepless", + "slid", + "slower", + "slug", + "smash", + "smelting", + "smidgen", + "smog", + "smuggled", + "snake", + "sneeze", + "sniff", + "snout", + "snug", + "soapy", + "sober", + "soccer", + "soda", + "software", + "soggy", + "soil", + "solved", + "somewhere", + "sonic", + "soothe", + "soprano", + "sorry", + "southern", + "sovereign", + "sowed", + "soya", + "space", + "speedy", + "sphere", + "spiders", + "splendid", + "spout", + "sprig", + "spud", + "spying", + "square", + "stacking", + "stellar", + "stick", + "stockpile", + "strained", + "stunning", + "stylishly", + "subtly", + "succeed", + "suddenly", + "suede", + "suffice", + "sugar", + "suitcase", + "sulking", + "summon", + "sunken", + "superior", + "surfer", + "sushi", + "suture", + "swagger", + "swept", + "swiftly", + "sword", + "swung", + "syllabus", + "symptoms", + "syndrome", + "syringe", + "system", + "taboo", + "tacit", + "tadpoles", + "tagged", + "tail", + "taken", + "talent", + "tamper", + "tanks", + "tapestry", + "tarnished", + "tasked", + "tattoo", + "taunts", + "tavern", + "tawny", + "taxi", + "teardrop", + "technical", + "tedious", + "teeming", + "tell", + "template", + "tender", + "tepid", + "tequila", + "terminal", + "testing", + "tether", + "textbook", + "thaw", + "theatrics", + "thirsty", + "thorn", + "threaten", + "thumbs", + "thwart", + "ticket", + "tidy", + "tiers", + "tiger", + "tilt", + "timber", + "tinted", + "tipsy", + "tirade", + "tissue", + "titans", + "toaster", + "tobacco", + "today", + "toenail", + "toffee", + "together", + "toilet", + "token", + "tolerant", + "tomorrow", + "tonic", + "toolbox", + "topic", + "torch", + "tossed", + "total", + "touchy", + "towel", + "toxic", + "toyed", + "trash", + "trendy", + "tribal", + "trolling", + "truth", + "trying", + "tsunami", + "tubes", + "tucks", + "tudor", + "tuesday", + "tufts", + "tugs", + "tuition", + "tulips", + "tumbling", + "tunnel", + "turnip", + "tusks", + "tutor", + "tuxedo", + "twang", + "tweezers", + "twice", + "twofold", + "tycoon", + "typist", + "tyrant", + "ugly", + "ulcers", + "ultimate", + "umbrella", + "umpire", + "unafraid", + "unbending", + "uncle", + "under", + "uneven", + "unfit", + "ungainly", + "unhappy", + "union", + "unjustly", + "unknown", + "unlikely", + "unmask", + "unnoticed", + "unopened", + "unplugs", + "unquoted", + "unrest", + "unsafe", + "until", + "unusual", + "unveil", + "unwind", + "unzip", + "upbeat", + "upcoming", + "update", + "upgrade", + "uphill", + "upkeep", + "upload", + "upon", + "upper", + "upright", + "upstairs", + "uptight", + "upwards", + "urban", + "urchins", + "urgent", + "usage", + "useful", + "usher", + "using", + "usual", + "utensils", + "utility", + "utmost", + "utopia", + "uttered", + "vacation", + "vague", + "vain", + "value", + "vampire", + "vane", + "vapidly", + "vary", + "vastness", + "vats", + "vaults", + "vector", + "veered", + "vegan", + "vehicle", + "vein", + "velvet", + "venomous", + "verification", + "vessel", + "veteran", + "vexed", + "vials", + "vibrate", + "victim", + "video", + "viewpoint", + "vigilant", + "viking", + "village", + "vinegar", + "violin", + "vipers", + "virtual", + "visited", + "vitals", + "vivid", + "vixen", + "vocal", + "vogue", + "voice", + "volcano", + "vortex", + "voted", + "voucher", + "vowels", + "voyage", + "vulture", + "wade", + "waffle", + "wagtail", + "waist", + "waking", + "wallets", + "wanted", + "warped", + "washing", + "water", + "waveform", + "waxing", + "wayside", + "weavers", + "website", + "wedge", + "weekday", + "weird", + "welders", + "went", + "wept", + "were", + "western", + "wetsuit", + "whale", + "when", + "whipped", + "whole", + "wickets", + "width", + "wield", + "wife", + "wiggle", + "wildly", + "winter", + "wipeout", + "wiring", + "wise", + "withdrawn", + "wives", + "wizard", + "wobbly", + "woes", + "woken", + "wolf", + "womanly", + "wonders", + "woozy", + "worry", + "wounded", + "woven", + "wrap", + "wrist", + "wrong", + "yacht", + "yahoo", + "yanks", + "yard", + "yawning", + "yearbook", + "yellow", + "yesterday", + "yeti", + "yields", + "yodel", + "yoga", + "younger", + "yoyo", + "zapped", + "zeal", + "zebra", + "zero", + "zesty", + "zigzags", + "zinger", + "zippers", + "zodiac", + "zombie", + "zones", + "zoom" + }; +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressType.java b/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressType.java new file mode 100644 index 0000000..a1415b1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressType.java @@ -0,0 +1,51 @@ +package com.m2049r.xmrwallet.util.validator; + +import lombok.Getter; + +public enum BitcoinAddressType { + BTC(Type.BTC, Type.BTC_BECH32_PREFIX), + LTC(Type.LTC, Type.LTC_BECH32_PREFIX), + DASH(Type.DASH, null), + DOGE(Type.DOGE, null); + + @Getter + private final byte[] production; + @Getter + private final byte[] testnet; + + @Getter + private final String productionBech32Prefix; + @Getter + private final String testnetBech32Prefix; + + public boolean hasBech32() { + return productionBech32Prefix != null; + } + + public String getBech32Prefix(boolean testnet) { + return testnet ? testnetBech32Prefix : productionBech32Prefix; + } + + BitcoinAddressType(byte[][] types, String[] bech32Prefix) { + production = types[0]; + testnet = types[1]; + if (bech32Prefix != null) { + productionBech32Prefix = bech32Prefix[0]; + testnetBech32Prefix = bech32Prefix[1]; + } else { + productionBech32Prefix = null; + testnetBech32Prefix = null; + } + } + + // Java is silly and doesn't allow array initializers in the construction + private static class Type { + private static final byte[][] BTC = {{0x00, 0x05}, {0x6f, (byte) 0xc4}}; + private static final String[] BTC_BECH32_PREFIX = {"bc", "tb"}; + private static final byte[][] LTC = {{0x30, 0x05, 0x32}, {0x6f, (byte) 0xc4, 0x3a}}; + private static final String[] LTC_BECH32_PREFIX = {"ltc", "tltc"}; + private static final byte[][] DASH = {{0x4c, 0x10}, {(byte) 0x8c, 0x13}}; + private static final byte[][] DOGE = {{0x1e, 0x16}, {0x71, (byte) 0xc4}}; + } + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressValidator.java b/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressValidator.java new file mode 100644 index 0000000..ce5cec0 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/validator/BitcoinAddressValidator.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2017 m2049r er 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.util.validator; + +// mostly based on https://rosettacode.org/wiki/Bitcoin/address_validation#Java + +import com.m2049r.xmrwallet.data.Crypto; +import com.m2049r.xmrwallet.model.NetworkType; +import com.m2049r.xmrwallet.model.WalletManager; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class BitcoinAddressValidator { + private static final String ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + public static Crypto validate(String address) { + for (BitcoinAddressType type : BitcoinAddressType.values()) { + if (validate(address, type)) + return Crypto.valueOf(type.name()); + } + return null; + } + + // just for tests + public static boolean validateBTC(String addrress, boolean testnet) { + return validate(addrress, BitcoinAddressType.BTC, testnet); + } + + public static boolean validate(String addrress, BitcoinAddressType type, boolean testnet) { + if (validate(addrress, testnet ? type.getTestnet() : type.getProduction())) + return true; + if (type.hasBech32()) + return validateBech32Segwit(addrress, type, testnet); + else + return false; + } + + public static boolean validate(String addrress, BitcoinAddressType type) { + final boolean testnet = WalletManager.getInstance().getNetworkType() != NetworkType.NetworkType_Mainnet; + return validate(addrress, type, testnet); + } + + public static boolean validate(String addrress, byte[] addressTypes) { + if (addrress.length() < 26 || addrress.length() > 35) + return false; + byte[] decoded = decodeBase58To25Bytes(addrress); + if (decoded == null) + return false; + int v = decoded[0] & 0xFF; + boolean nok = true; + for (byte b : addressTypes) { + nok = nok && (v != (b & 0xFF)); + } + if (nok) return false; + + byte[] hash1 = sha256(Arrays.copyOfRange(decoded, 0, 21)); + byte[] hash2 = sha256(hash1); + + return Arrays.equals(Arrays.copyOfRange(hash2, 0, 4), Arrays.copyOfRange(decoded, 21, 25)); + } + + private static byte[] decodeBase58To25Bytes(String input) { + BigInteger num = BigInteger.ZERO; + for (char t : input.toCharArray()) { + int p = ALPHABET.indexOf(t); + if (p == -1) + return null; + num = num.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(p)); + } + + byte[] result = new byte[25]; + byte[] numBytes = num.toByteArray(); + if (num.bitLength() > 200) return null; + + if (num.bitLength() == 200) { + System.arraycopy(numBytes, 1, result, 0, 25); + } else { + System.arraycopy(numBytes, 0, result, result.length - numBytes.length, numBytes.length); + } + return result; + } + + private static byte[] sha256(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(data); + return md.digest(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + // + // validate Bech32 segwit + // see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki for spec + // + + private static final String DATA_CHARS = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + public static boolean validateBech32Segwit(String bech32, BitcoinAddressType type, boolean testnet) { + if (!bech32.equals(bech32.toLowerCase()) && !bech32.equals(bech32.toUpperCase())) { + return false; // mixing upper and lower case not allowed + } + bech32 = bech32.toLowerCase(); + + if (!bech32.startsWith(type.getBech32Prefix(testnet))) return false; + + final int hrpLength = type.getBech32Prefix(testnet).length(); + + if ((bech32.length() < (12 + hrpLength)) || (bech32.length() > (72 + hrpLength))) + return false; + int mod = (bech32.length() - hrpLength) % 8; + if ((mod == 6) || (mod == 1) || (mod == 3)) return false; + + int sep = -1; + final byte[] bytes = bech32.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < bytes.length; i++) { + if ((bytes[i] < 33) || (bytes[i] > 126)) { + return false; + } + if (bytes[i] == 49) sep = i; // 49 := '1' in ASCII + } + + if (sep != hrpLength) return false; + if (sep > bytes.length - 7) { + return false; // min 6 bytes data + } + if (bytes.length < 8) { // hrp{min}=1 + sep=1 + data{min}=6 := 8 + return false; // too short + } + if (bytes.length > 90) { + return false; // too long + } + + final byte[] hrp = Arrays.copyOfRange(bytes, 0, sep); + + final byte[] data = Arrays.copyOfRange(bytes, sep + 1, bytes.length); + for (int i = 0; i < data.length; i++) { + int b = DATA_CHARS.indexOf(data[i]); + if (b < 0) return false; // invalid character + data[i] = (byte) b; + } + + if (!validateBech32Data(data)) return false; + + return verifyChecksum(hrp, data); + } + + private static int polymod(byte[] values) { + final int[] GEN = {0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}; + int chk = 1; + for (byte v : values) { + byte b = (byte) (chk >> 25); + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (int i = 0; i < 5; i++) { + chk ^= ((b >> i) & 1) == 1 ? GEN[i] : 0; + } + } + return chk; + } + + private static byte[] hrpExpand(byte[] hrp) { + final byte[] expanded = new byte[(2 * hrp.length) + 1]; + int i = 0; + for (byte b : hrp) { + expanded[i++] = (byte) (b >> 5); + } + expanded[i++] = 0; + for (byte b : hrp) { + expanded[i++] = (byte) (b & 0x1f); + } + return expanded; + } + + private static boolean verifyChecksum(byte[] hrp, byte[] data) { + final byte[] hrpExpanded = hrpExpand(hrp); + final byte[] values = new byte[hrpExpanded.length + data.length]; + System.arraycopy(hrpExpanded, 0, values, 0, hrpExpanded.length); + System.arraycopy(data, 0, values, hrpExpanded.length, data.length); + return (polymod(values) == 1); + } + + private static boolean validateBech32Data(final byte[] data) { + if ((data[0] < 0) || (data[0] > 16)) return false; // witness version + final int programLength = data.length - 1 - 6; // 1-byte version at beginning & 6-byte checksum at end + + // since we are coming from our own decoder, we don't need to verify data is 5-bit bytes + + final int convertedSize = programLength * 5 / 8; + final int remainderSize = programLength * 5 % 8; + + if ((convertedSize < 2) || (convertedSize > 40)) return false; + + if ((data[0] == 0) && (convertedSize != 20) && (convertedSize != 32)) return false; + + if (remainderSize >= 5) return false; + // ignore checksum at end and get last byte of program + if ((data[data.length - 1 - 6] & ((1 << remainderSize) - 1)) != 0) return false; + + return true; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/validator/EthAddressValidator.java b/app/src/main/java/com/m2049r/xmrwallet/util/validator/EthAddressValidator.java new file mode 100644 index 0000000..3e4c476 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/util/validator/EthAddressValidator.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2017 m2049r er 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.util.validator; + +// mostly based on https://github.com/ognus/wallet-address-validator/blob/master/src/ethereum_validator.js + +import com.theromus.sha.Keccak; +import com.theromus.sha.Parameters; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +public class EthAddressValidator { + static private final Pattern ETH_ADDRESS = Pattern.compile("^0x[0-9a-fA-F]{40}$"); + static private final Pattern ETH_ALLLOWER = Pattern.compile("^0x[0-9a-f]{40}$"); + static private final Pattern ETH_ALLUPPER = Pattern.compile("^0x[0-9A-F]{40}$"); + + public static boolean validate(String address) { + // Check if it has the basic requirements of an address + if (!ETH_ADDRESS.matcher(address).matches()) + return false; + + // If it's all small caps or all all caps, return true + if (ETH_ALLLOWER.matcher(address).matches() || ETH_ALLUPPER.matcher(address).matches()) { + return true; + } + + // Otherwise check each case + return validateChecksum(address); + } + + private static boolean validateChecksum(String address) { + // Check each case + address = address.substring(2); // strip 0x + + Keccak keccak = new Keccak(); + final byte[] addressHash = keccak.getHash( + address.toLowerCase().getBytes(StandardCharsets.US_ASCII), + Parameters.KECCAK_256); + for (int i = 0; i < 40; i++) { + boolean upper = (addressHash[i / 2] & ((i % 2) == 0 ? 128 : 8)) != 0; + char c = address.charAt(i); + if (Character.isAlphabetic(c)) { + if (Character.isUpperCase(c) && !upper) return false; + if (Character.isLowerCase(c) && upper) return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/CTextInputLayout.java b/app/src/main/java/com/m2049r/xmrwallet/widget/CTextInputLayout.java new file mode 100644 index 0000000..59b884d --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/CTextInputLayout.java @@ -0,0 +1,44 @@ +/* + * 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. + */ + +// based on from https://stackoverflow.com/a/45325876 (which did not work for me) + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import com.google.android.material.textfield.TextInputLayout; +import android.util.AttributeSet; +import android.widget.EditText; + +public class CTextInputLayout extends TextInputLayout { + public CTextInputLayout(Context context) { + super(context); + } + + public CTextInputLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CTextInputLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public int getBaseline() { + EditText editText = getEditText(); + return editText.getBaseline() - (getMeasuredHeight() - editText.getMeasuredHeight()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/DotBar.java b/app/src/main/java/com/m2049r/xmrwallet/widget/DotBar.java new file mode 100644 index 0000000..0baf2d3 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/DotBar.java @@ -0,0 +1,156 @@ +/* + * 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. + */ + +// based on https://github.com/marcokstephen/StepProgressBar + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; + +import com.m2049r.xmrwallet.R; + +import timber.log.Timber; + +public class DotBar extends View { + + final private int inactiveColor; + final private int activeColor; + + final private float dotSize; + private float dotSpacing; + + final private int numDots; + private int activeDot; + + final private Paint paint; + + public DotBar(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DotBar, 0, 0); + try { + inactiveColor = ta.getInt(R.styleable.DotBar_inactiveColor, 0); + activeColor = ta.getInt(R.styleable.DotBar_activeColor, 0); + dotSize = ta.getDimensionPixelSize(R.styleable.DotBar_dotSize, 8); + numDots = ta.getInt(R.styleable.DotBar_numberDots, 5); + activeDot = ta.getInt(R.styleable.DotBar_activeDot, 0); + } finally { + ta.recycle(); + } + + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setStyle(Paint.Style.FILL); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desiredWidth = (int) ((numDots * dotSize) + getPaddingLeft() + getPaddingRight()); + int desiredHeight = (int) (dotSize + getPaddingBottom() + getPaddingTop()); + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int width; + int height; + + //Measure Width + if (widthMode == MeasureSpec.EXACTLY) { + //Must be this size + width = widthSize; + } else if (widthMode == MeasureSpec.AT_MOST) { + //Can't be bigger than... + width = Math.min(desiredWidth, widthSize); + } else { + //Be whatever you want + width = desiredWidth; + } + + //Measure Height + if (heightMode == MeasureSpec.EXACTLY) { + //Must be this size + height = heightSize; + } else if (heightMode == MeasureSpec.AT_MOST) { + //Can't be bigger than... + height = Math.min(desiredHeight, heightSize); + } else { + //Be whatever you want + height = desiredHeight; + } + + dotSpacing = (int) (((1.0 * width - (getPaddingLeft() + getPaddingRight())) / numDots - dotSize) / (numDots - 1)); + + Timber.d("dotSpacing=%f", dotSpacing); + //MUST CALL THIS + setMeasuredDimension(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // Centering the dots in the middle of the canvas + float singleDotSize = dotSpacing + dotSize; + float combinedDotSize = singleDotSize * numDots - dotSpacing; + int startingX = (int) ((canvas.getWidth() - combinedDotSize) / 2); + int startingY = (int) ((canvas.getHeight() - dotSize) / 2); + + for (int i = 0; i < numDots; i++) { + int x = (int) (startingX + i * singleDotSize); + if (i == activeDot) { + paint.setColor(activeColor); + } else { + paint.setColor(inactiveColor); + } + canvas.drawCircle(x + dotSize / 2, startingY + dotSize / 2, dotSize / 2, paint); + } + } + + public void next() { + if (activeDot < numDots - 2) { + activeDot++; + invalidate(); + } // else no next - stay stuck at end + } + + public void previous() { + if (activeDot >= 0) { + activeDot--; + invalidate(); + } // else no previous - stay stuck at beginning + } + + public void setActiveDot(int i) { + if ((i >= 0) && (i < numDots)) { + activeDot = i; + invalidate(); + } + } + + public int getActiveDot() { + return activeDot; + } + + public int getNumDots() { + return numDots; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/DropDownEditText.java b/app/src/main/java/com/m2049r/xmrwallet/widget/DropDownEditText.java new file mode 100644 index 0000000..9a767f5 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/DropDownEditText.java @@ -0,0 +1,54 @@ +/* + * 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. + */ + +// https://stackoverflow.com/questions/2126717/android-autocompletetextview-show-suggestions-when-no-text-entered + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.graphics.Rect; +import androidx.appcompat.widget.AppCompatAutoCompleteTextView; +import android.util.AttributeSet; + +public class DropDownEditText extends AppCompatAutoCompleteTextView { + + public DropDownEditText(Context context) { + super(context); + } + + public DropDownEditText(Context arg0, AttributeSet arg1) { + super(arg0, arg1); + } + + public DropDownEditText(Context arg0, AttributeSet arg1, int arg2) { + super(arg0, arg1, arg2); + } + + @Override + public boolean enoughToFilter() { + return true; + } + + @Override + protected void onFocusChanged(boolean focused, int direction, + Rect previouslyFocusedRect) { + super.onFocusChanged(focused, direction, previouslyFocusedRect); + if (focused && getAdapter() != null) { + performFiltering("", 0); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java new file mode 100644 index 0000000..00b1b19 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +// based on https://code.tutsplus.com/tutorials/creating-compound-views-on-android--cms-22889 + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.textfield.TextInputLayout; +import com.m2049r.xmrwallet.R; +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 java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import timber.log.Timber; + +public class ExchangeEditText extends LinearLayout { + + private double getEnteredAmount() { + String enteredAmount = etAmountA.getEditText().getText().toString(); + try { + return Double.parseDouble(enteredAmount); + } catch (NumberFormatException ex) { + Timber.i(ex.getLocalizedMessage()); + } + return 0; + } + + public boolean validate(double max, double min) { + Timber.d("inProgress=%b", isExchangeInProgress()); + if (isExchangeInProgress()) { + shakeExchangeField(); + return false; + } + boolean ok = true; + String nativeAmount = getNativeAmount(); + if (nativeAmount == null) { + ok = false; + } else { + try { + double amount = Double.parseDouble(nativeAmount); + if ((amount < min) || (amount > max)) { + ok = false; + } + } catch (NumberFormatException ex) { + // this cannot be + Timber.e(ex.getLocalizedMessage()); + ok = false; + } + } + if (!ok) { + shakeAmountField(); + } + return ok; + } + + void shakeAmountField() { + etAmountA.startAnimation(Helper.getShakeAnimation(getContext())); + } + + void shakeExchangeField() { + tvAmountB.startAnimation(Helper.getShakeAnimation(getContext())); + } + + public void setAmount(String nativeAmount) { + if (nativeAmount != null) { + etAmountA.getEditText().setText(nativeAmount); + tvAmountB.setText(null); + if (sCurrencyA.getSelectedItemPosition() != 0) + sCurrencyA.setSelection(0, true); // set native currency & trigger exchange + else + doExchange(); + } else { + tvAmountB.setText(null); + } + } + + public void setEditable(boolean editable) { + etAmountA.setEnabled(editable); + } + + public String getNativeAmount() { + if (isExchangeInProgress()) return null; + if (getCurrencyA() == 0) + return getCleanAmountString(etAmountA.getEditText().getText().toString()); + else + return getCleanAmountString(tvAmountB.getText().toString()); + } + + TextInputLayout etAmountA; + TextView tvAmountB; + Spinner sCurrencyA; + Spinner sCurrencyB; + ImageView evExchange; + ProgressBar pbExchange; + + public int getCurrencyA() { + return sCurrencyA.getSelectedItemPosition(); + } + + public int getCurrencyB() { + return sCurrencyB.getSelectedItemPosition(); + } + + public ExchangeEditText(Context context) { + super(context); + initializeViews(context); + } + + public ExchangeEditText(Context context, AttributeSet attrs) { + super(context, attrs); + initializeViews(context); + } + + public ExchangeEditText(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + initializeViews(context); + } + + /** + * Inflates the views in the layout. + * + * @param context the current context for the view. + */ + void initializeViews(Context context) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + inflater.inflate(R.layout.view_exchange_edit, this); + } + + void setCurrencyAdapter(Spinner spinner) { + List currencies = new ArrayList<>(); + currencies.add(Helper.BASE_CRYPTO); + setCurrencyAdapter(spinner, currencies); + } + + protected void setCurrencyAdapter(Spinner spinner, List currencies) { + if (Helper.SHOW_EXCHANGERATES) + currencies.addAll(Arrays.asList(getResources().getStringArray(R.array.currency))); + ArrayAdapter spinnerAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_item, currencies); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(spinnerAdapter); + } + + void setInitialSpinnerSelections(Spinner baseSpinner, Spinner quoteSpinner) { + baseSpinner.setSelection(0, true); + quoteSpinner.setSelection(0, true); + } + + private boolean isInitialized = false; + + void postInitialize() { + setInitialSpinnerSelections(sCurrencyA, sCurrencyB); + isInitialized = true; + startExchange(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + etAmountA = findViewById(R.id.etAmountA); + etAmountA.getEditText().addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + doExchange(); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + }); + tvAmountB = findViewById(R.id.tvAmountB); + sCurrencyA = findViewById(R.id.sCurrencyA); + sCurrencyB = findViewById(R.id.sCurrencyB); + evExchange = findViewById(R.id.evExchange); + pbExchange = findViewById(R.id.pbExchange); + + setCurrencyAdapter(sCurrencyA); + setCurrencyAdapter(sCurrencyB); + + post(this::postInitialize); + + // make progress circle gray + pbExchange.getIndeterminateDrawable(). + setColorFilter(ThemeHelper.getThemedColor(getContext(), R.attr.colorPrimaryVariant), + android.graphics.PorterDuff.Mode.MULTIPLY); + + sCurrencyA.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parentView, View selectedItemView, int position, long id) { + if (!isInitialized) return; + if (position != 0) { // if not native, select native on other + sCurrencyB.setSelection(0, true); + } + doExchange(); + } + + @Override + public void onNothingSelected(AdapterView parentView) { + // nothing + } + }); + + sCurrencyB.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parentView, View selectedItemView, int position, long id) { + if (!isInitialized) return; + if (position != 0) { // if not native, select native on other + sCurrencyA.setSelection(0, true); + } + doExchange(); + } + + @Override + public void onNothingSelected(AdapterView parentView) { + // nothing + } + }); + } + + private boolean exchangeRateCacheIsUsable() { + return (exchangeRateCache != null) && + ((exchangeRateCache.getBaseCurrency().equals(sCurrencyA.getSelectedItem()) && + exchangeRateCache.getQuoteCurrency().equals(sCurrencyB.getSelectedItem())) || + (exchangeRateCache.getBaseCurrency().equals(sCurrencyB.getSelectedItem()) && + exchangeRateCache.getQuoteCurrency().equals(sCurrencyA.getSelectedItem()))); + } + + private double exchangeRateFromCache() { + if (!exchangeRateCacheIsUsable()) return 0; + if (exchangeRateCache.getBaseCurrency().equals(sCurrencyA.getSelectedItem())) { + return exchangeRateCache.getRate(); + } else { + return 1.0d / exchangeRateCache.getRate(); + } + } + + public void doExchange() { + if (!isInitialized) return; + tvAmountB.setText(null); + if (getCurrencyA() == getCurrencyB()) { + exchange(1); + return; + } + // use cached exchange rate if we have it + if (!isExchangeInProgress()) { + double rate = exchangeRateFromCache(); + if (rate > 0) { + if (prepareExchange()) { + exchange(rate); + } + } else { + startExchange(); + } + } + } + + private final ExchangeApi exchangeApi = ServiceHelper.getExchangeApi(); + + // starts exchange through exchange api + void startExchange() { + String currencyA = (String) sCurrencyA.getSelectedItem(); + String currencyB = (String) sCurrencyB.getSelectedItem(); + if ((currencyA == null) || (currencyB == null)) return; // nothing to do + execExchange(currencyA, currencyB); + } + + void execExchange(String currencyA, String currencyB) { + showProgress(); + queryExchangeRate(currencyA, currencyB, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAttachedToWindow()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchange(exchangeRate); + } + }); + } + + @Override + public void onError(final Exception e) { + Timber.e(e.getLocalizedMessage()); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchangeFailed(); + } + }); + } + }); + } + + void queryExchangeRate(final String base, final String quote, ExchangeCallback callback) { + exchangeApi.queryExchangeRate(base, quote, callback); + } + + private void exchange(double rate) { + double amount = getEnteredAmount(); + if (rate > 0) { + tvAmountB.setText(Helper.getFormattedAmount(rate * amount, getCurrencyB() == 0)); + } else { + tvAmountB.setText("--"); + Timber.d("No rate!"); + } + } + + private static final String CLEAN_FORMAT = "%." + Helper.XMR_DECIMALS + "f"; + + private String getCleanAmountString(String enteredAmount) { + try { + double amount = Double.parseDouble(enteredAmount); + if (amount >= 0) { + return String.format(Locale.US, CLEAN_FORMAT, amount); + } else { + return null; + } + } catch (NumberFormatException ex) { + return null; + } + } + + boolean prepareExchange() { + Timber.d("prepareExchange()"); + String enteredAmount = etAmountA.getEditText().getText().toString(); + if (!enteredAmount.isEmpty()) { + String cleanAmount = getCleanAmountString(enteredAmount); + Timber.d("cleanAmount = %s", cleanAmount); + if (cleanAmount == null) { + shakeAmountField(); + return false; + } + } else { + return false; + } + return true; + } + + public void exchangeFailed() { + hideProgress(); + exchange(0); + } + + // cache for exchange rate + ExchangeRate exchangeRateCache = null; + + public void exchange(ExchangeRate exchangeRate) { + hideProgress(); + // make sure this is what we want + if (!exchangeRate.getBaseCurrency().equals(sCurrencyA.getSelectedItem()) || + !exchangeRate.getQuoteCurrency().equals(sCurrencyB.getSelectedItem())) { + // something's wrong + Timber.i("Currencies don't match! A: %s==%s B: %s==%s", + exchangeRate.getBaseCurrency(), sCurrencyA.getSelectedItem(), + exchangeRate.getQuoteCurrency(), sCurrencyB.getSelectedItem()); + return; + } + + exchangeRateCache = exchangeRate; + if (prepareExchange()) { + exchange(exchangeRate.getRate()); + } + } + + void showProgress() { + pbExchange.setVisibility(View.VISIBLE); + } + + private boolean isExchangeInProgress() { + return pbExchange.getVisibility() == View.VISIBLE; + } + + private void hideProgress() { + pbExchange.setVisibility(View.INVISIBLE); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java new file mode 100644 index 0000000..3dd344e --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +// based on https://code.tutsplus.com/tutorials/creating-compound-views-on-android--cms-22889 + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.widget.Spinner; + +import androidx.annotation.NonNull; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; +import com.m2049r.xmrwallet.util.Helper; + +import java.util.ArrayList; +import java.util.List; + +import timber.log.Timber; + +public class ExchangeOtherEditText extends ExchangeEditText { + /* + all exchanges are done through XMR + baseCurrency is the native currency + */ + + String baseCurrency = null; // not XMR + private double exchangeRate = 0; // baseCurrency to XMR + + public void setExchangeRate(double rate) { + exchangeRate = rate; + post(this::startExchange); + } + + public void setBaseCurrency(@NonNull String symbol) { + if (symbol.equals(baseCurrency)) return; + baseCurrency = symbol; + setCurrencyAdapter(sCurrencyA); + setCurrencyAdapter(sCurrencyB); + post(this::postInitialize); + } + + private void setBaseCurrency(Context context, AttributeSet attrs) { + TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ExchangeEditText, 0, 0); + try { + baseCurrency = ta.getString(R.styleable.ExchangeEditText_baseSymbol); + if (baseCurrency == null) + throw new IllegalArgumentException("base currency must be set"); + } finally { + ta.recycle(); + } + } + + public ExchangeOtherEditText(Context context, AttributeSet attrs) { + super(context, attrs); + setBaseCurrency(context, attrs); + } + + public ExchangeOtherEditText(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + setBaseCurrency(context, attrs); + } + + @Override + void setCurrencyAdapter(Spinner spinner) { + List currencies = new ArrayList<>(); + if (!baseCurrency.equals(Helper.BASE_CRYPTO)) currencies.add(baseCurrency); + currencies.add(Helper.BASE_CRYPTO); + setCurrencyAdapter(spinner, currencies); + } + + @Override + void setInitialSpinnerSelections(Spinner baseSpinner, Spinner quoteSpinner) { + baseSpinner.setSelection(0, true); + quoteSpinner.setSelection(1, true); + } + + private void localExchange(final String base, final String quote, final double rate) { + exchange(new ExchangeRate() { + @Override + public String getServiceName() { + return "Local"; + } + + @Override + public String getBaseCurrency() { + return base; + } + + @Override + public String getQuoteCurrency() { + return quote; + } + + @Override + public double getRate() { + return rate; + } + }); + } + + @Override + void execExchange(String currencyA, String currencyB) { + if (!currencyA.equals(baseCurrency) && !currencyB.equals(baseCurrency)) { + throw new IllegalStateException("I can only exchange " + baseCurrency); + } + + showProgress(); + + Timber.d("execExchange(%s, %s)", currencyA, currencyB); + + // first deal with XMR/baseCurrency & baseCurrency/XMR + + if (currencyA.equals(Helper.BASE_CRYPTO) && (currencyB.equals(baseCurrency))) { + localExchange(currencyA, currencyB, 1.0d / exchangeRate); + return; + } + if (currencyA.equals(baseCurrency) && (currencyB.equals(Helper.BASE_CRYPTO))) { + localExchange(currencyA, currencyB, exchangeRate); + return; + } + + // next, deal with XMR/baseCurrency + + if (currencyA.equals(baseCurrency)) { + queryExchangeRate(Helper.BASE_CRYPTO, currencyB, exchangeRate, true); + } else { + queryExchangeRate(currencyA, Helper.BASE_CRYPTO, 1.0d / exchangeRate, false); + } + } + + private void queryExchangeRate(final String base, final String quote, final double factor, + final boolean baseIsBaseCrypto) { + queryExchangeRate(base, quote, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAttachedToWindow()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + ExchangeRate xchange = new ExchangeRate() { + @Override + public String getServiceName() { + return exchangeRate.getServiceName() + "+" + baseCurrency; + } + + @Override + public String getBaseCurrency() { + return baseIsBaseCrypto ? baseCurrency : base; + } + + @Override + public String getQuoteCurrency() { + return baseIsBaseCrypto ? quote : baseCurrency; + } + + @Override + public double getRate() { + return exchangeRate.getRate() * factor; + } + }; + exchange(xchange); + } + }); + } + + @Override + public void onError(final Exception e) { + Timber.e(e.getLocalizedMessage()); + new Handler(Looper.getMainLooper()).post(() -> exchangeFailed()); + } + }); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeView.java b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeView.java new file mode 100644 index 0000000..3208f72 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeView.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. + */ + +// based on https://code.tutsplus.com/tutorials/creating-compound-views-on-android--cms-22889 + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.textfield.TextInputLayout; +import com.m2049r.xmrwallet.R; +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.ThemeHelper; +import com.m2049r.xmrwallet.util.Helper; +import com.m2049r.xmrwallet.util.ServiceHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import timber.log.Timber; + +public class ExchangeView extends LinearLayout { + String xmrAmount = null; + String notXmrAmount = null; + + public void enable(boolean enable) { + etAmount.setEnabled(enable); + sCurrencyA.setEnabled(enable); + sCurrencyB.setEnabled(enable); + } + + void setXmr(String xmr) { + xmrAmount = xmr; + if (onNewAmountListener != null) { + onNewAmountListener.onNewAmount(xmr); + } + } + + public void setAmount(String xmrAmount) { + if (xmrAmount != null) { + setCurrencyA(0); + etAmount.getEditText().setText(xmrAmount); + setXmr(xmrAmount); + this.notXmrAmount = null; + doExchange(); + } else { + setXmr(null); + this.notXmrAmount = null; + tvAmountB.setText("--"); + } + } + + public String getAmount() { + return xmrAmount; + } + + public void setError(String msg) { + etAmount.setError(msg); + } + + TextInputLayout etAmount; + TextView tvAmountB; + Spinner sCurrencyA; + Spinner sCurrencyB; + ImageView evExchange; + ProgressBar pbExchange; + + + public void setCurrencyA(int currency) { + if ((currency != 0) && (getCurrencyB() != 0)) { + setCurrencyB(0); + } + sCurrencyA.setSelection(currency, true); + doExchange(); + } + + public void setCurrencyB(int currency) { + if ((currency != 0) && (getCurrencyA() != 0)) { + setCurrencyA(0); + } + sCurrencyB.setSelection(currency, true); + doExchange(); + } + + public int getCurrencyA() { + return sCurrencyA.getSelectedItemPosition(); + } + + public int getCurrencyB() { + return sCurrencyB.getSelectedItemPosition(); + } + + public ExchangeView(Context context) { + super(context); + initializeViews(context); + } + + public ExchangeView(Context context, AttributeSet attrs) { + super(context, attrs); + initializeViews(context); + } + + public ExchangeView(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + initializeViews(context); + } + + /** + * Inflates the views in the layout. + * + * @param context the current context for the view. + */ + private void initializeViews(Context context) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + inflater.inflate(R.layout.view_exchange, this); + } + + void setCurrencyAdapter(Spinner spinner) { + 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<>(getContext(), android.R.layout.simple_spinner_item, currencies); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(spinnerAdapter); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + etAmount = findViewById(R.id.etAmount); + tvAmountB = findViewById(R.id.tvAmountB); + sCurrencyA = findViewById(R.id.sCurrencyA); + sCurrencyB = findViewById(R.id.sCurrencyB); + evExchange = findViewById(R.id.evExchange); + pbExchange = findViewById(R.id.pbExchange); + + setCurrencyAdapter(sCurrencyA); + setCurrencyAdapter(sCurrencyB); + + // make progress circle gray + pbExchange.getIndeterminateDrawable(). + setColorFilter(ThemeHelper.getThemedColor(getContext(), R.attr.colorPrimaryVariant), + android.graphics.PorterDuff.Mode.MULTIPLY); + + sCurrencyA.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parentView, View selectedItemView, int position, long id) { + if (position != 0) { // if not XMR, select XMR on other + sCurrencyB.setSelection(0, true); + } + doExchange(); + } + + @Override + public void onNothingSelected(AdapterView parentView) { + // nothing (yet?) + } + }); + + sCurrencyB.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parentView, View selectedItemView, int position, long id) { + if (position != 0) { // if not XMR, select XMR on other + sCurrencyA.setSelection(0, true); + } + doExchange(); + } + + @Override + public void onNothingSelected(AdapterView parentView) { + // nothing + } + }); + + etAmount.getEditText().setOnFocusChangeListener(new OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (!hasFocus) { + doExchange(); + } + } + }); + + etAmount.getEditText().setOnEditorActionListener(new TextView.OnEditorActionListener() { + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) && (event.getAction() == KeyEvent.ACTION_DOWN)) + || (actionId == EditorInfo.IME_ACTION_DONE)) { + doExchange(); + return true; + } + return false; + } + }); + + + etAmount.getEditText().addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable editable) { + etAmount.setError(null); + clearAmounts(); + } + + @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 static double MAX_AMOUNT_XMR = 1000; + final static double MAX_AMOUNT_NOTXMR = 100000; + + public boolean checkEnteredAmount() { + boolean ok = true; + Timber.d("checkEnteredAmount"); + String amountEntry = etAmount.getEditText().getText().toString(); + if (!amountEntry.isEmpty()) { + try { + double a = Double.parseDouble(amountEntry); + double maxAmount = (getCurrencyA() == 0) ? MAX_AMOUNT_XMR : MAX_AMOUNT_NOTXMR; + if (a > (maxAmount)) { + etAmount.setError(getResources(). + getString(R.string.receive_amount_too_big, + String.format(Locale.US, "%,.0f", maxAmount))); + ok = false; + } else if (a < 0) { + etAmount.setError(getResources().getString(R.string.receive_amount_negative)); + ok = false; + } + } catch (NumberFormatException ex) { + etAmount.setError(getResources().getString(R.string.receive_amount_nan)); + ok = false; + } + } + if (ok) { + etAmount.setError(null); + } + return ok; + } + + public void doExchange() { + tvAmountB.setText("--"); + // use cached exchange rate if we have it + if (!isExchangeInProgress()) { + String enteredCurrencyA = (String) sCurrencyA.getSelectedItem(); + String enteredCurrencyB = (String) sCurrencyB.getSelectedItem(); + if ((enteredCurrencyA + enteredCurrencyB).equals(assetPair)) { + if (prepareExchange()) { + exchange(assetRate); + } else { + clearAmounts(); + } + } else { + clearAmounts(); + startExchange(); + } + } else { + clearAmounts(); + } + } + + private void clearAmounts() { + if ((xmrAmount != null) || (notXmrAmount != null)) { + tvAmountB.setText("--"); + setXmr(null); + notXmrAmount = null; + } + } + + private final ExchangeApi exchangeApi = ServiceHelper.getExchangeApi(); + + void startExchange() { + showProgress(); + String currencyA = (String) sCurrencyA.getSelectedItem(); + String currencyB = (String) sCurrencyB.getSelectedItem(); + + exchangeApi.queryExchangeRate(currencyA, currencyB, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAttachedToWindow()) + new Handler(Looper.getMainLooper()).post(() -> exchange(exchangeRate)); + } + + @Override + public void onError(final Exception e) { + Timber.e(e.getLocalizedMessage()); + new Handler(Looper.getMainLooper()).post(() -> exchangeFailed()); + } + }); + } + + public void exchange(double rate) { + if (getCurrencyA() == 0) { + if (xmrAmount == null) return; + if (!xmrAmount.isEmpty() && (rate > 0)) { + double amountB = rate * Double.parseDouble(xmrAmount); + notXmrAmount = Helper.getFormattedAmount(amountB, getCurrencyB() == 0); + } else { + notXmrAmount = ""; + } + tvAmountB.setText(notXmrAmount); + } else if (getCurrencyB() == 0) { + if (notXmrAmount == null) return; + if (!notXmrAmount.isEmpty() && (rate > 0)) { + double amountB = rate * Double.parseDouble(notXmrAmount); + setXmr(Helper.getFormattedAmount(amountB, true)); + } else { + setXmr(""); + } + tvAmountB.setText(xmrAmount); + } else { // no XMR currency - cannot happen! + throw new IllegalStateException("No XMR currency!"); + } + if (rate == 0) + tvAmountB.setText("--"); + } + + boolean prepareExchange() { + Timber.d("prepareExchange()"); + if (checkEnteredAmount()) { + String enteredAmount = etAmount.getEditText().getText().toString(); + if (!enteredAmount.isEmpty()) { + String cleanAmount = ""; + if (getCurrencyA() == 0) { + // sanitize the input + cleanAmount = Helper.getDisplayAmount(Wallet.getAmountFromString(enteredAmount)); + setXmr(cleanAmount); + notXmrAmount = null; + Timber.d("cleanAmount = %s", cleanAmount); + } else if (getCurrencyB() == 0) { // we use B & 0 here for the else below ... + // sanitize the input + double amountA = Double.parseDouble(enteredAmount); + cleanAmount = String.format(Locale.US, "%.2f", amountA); + setXmr(null); + notXmrAmount = cleanAmount; + } else { // no XMR currency - cannot happen! + Timber.e("No XMR currency!"); + setXmr(null); + notXmrAmount = null; + return false; + } + Timber.d("prepareExchange() %s", cleanAmount); + } else { + setXmr(""); + notXmrAmount = ""; + } + return true; + } else { + setXmr(null); + notXmrAmount = null; + return false; + } + } + + public void exchangeFailed() { + hideProgress(); + exchange(0); + if (onFailedExchangeListener != null) { + onFailedExchangeListener.onFailedExchange(); + } + } + + String assetPair = null; + double assetRate = 0; + + public void exchange(ExchangeRate exchangeRate) { + hideProgress(); + // first, make sure this is what we want + String enteredCurrencyA = (String) sCurrencyA.getSelectedItem(); + String enteredCurrencyB = (String) sCurrencyB.getSelectedItem(); + if (!exchangeRate.getBaseCurrency().equals(enteredCurrencyA) + || !exchangeRate.getQuoteCurrency().equals(enteredCurrencyB)) { + // something's wrong + Timber.e("Currencies don't match!"); + return; + } + assetPair = enteredCurrencyA + enteredCurrencyB; + assetRate = exchangeRate.getRate(); + if (prepareExchange()) { + exchange(exchangeRate.getRate()); + } + } + + private void showProgress() { + pbExchange.setVisibility(View.VISIBLE); + } + + private boolean isExchangeInProgress() { + return pbExchange.getVisibility() == View.VISIBLE; + } + + private void hideProgress() { + pbExchange.setVisibility(View.INVISIBLE); + } + + // Hooks + public interface OnNewAmountListener { + void onNewAmount(String xmr); + } + + OnNewAmountListener onNewAmountListener; + + public void setOnNewAmountListener(OnNewAmountListener listener) { + onNewAmountListener = listener; + } + + public interface OnAmountInvalidatedListener { + void onAmountInvalidated(); + } + + OnAmountInvalidatedListener onAmountInvalidatedListener; + + public void setOnAmountInvalidatedListener(OnAmountInvalidatedListener listener) { + onAmountInvalidatedListener = listener; + } + + public interface OnFailedExchangeListener { + void onFailedExchange(); + } + + OnFailedExchangeListener onFailedExchangeListener; + + public void setOnFailedExchangeListener(OnFailedExchangeListener listener) { + onFailedExchangeListener = listener; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/PasswordEntryView.java b/app/src/main/java/com/m2049r/xmrwallet/widget/PasswordEntryView.java new file mode 100644 index 0000000..6d5fee1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/PasswordEntryView.java @@ -0,0 +1,73 @@ +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.textfield.TextInputLayout; +import com.m2049r.xmrwallet.R; +import com.nulabinc.zxcvbn.Zxcvbn; + +public class PasswordEntryView extends TextInputLayout implements TextWatcher { + final private Zxcvbn zxcvbn = new Zxcvbn(); + + public PasswordEntryView(@NonNull Context context) { + super(context, null); + } + + public PasswordEntryView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs, R.attr.textInputStyle); + } + + public PasswordEntryView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void addView(@NonNull View child, int index, @NonNull final ViewGroup.LayoutParams params) { + super.addView(child, index, params); + final EditText et = getEditText(); + if (et != null) + et.addTextChangedListener(this); + } + + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + String password = s.toString(); + int icon = 0; + if (!password.isEmpty()) { + final double strength = Math.min(zxcvbn.measure(password).getGuessesLog10(), 15) / 3 * 20; // 0-100% + if (strength < 21) + icon = R.drawable.ic_smiley_sad_filled; + else if (strength < 40) + icon = R.drawable.ic_smiley_meh_filled; + else if (strength < 60) + icon = R.drawable.ic_smiley_neutral_filled; + else if (strength < 80) + icon = R.drawable.ic_smiley_happy_filled; + else if (strength < 99) + icon = R.drawable.ic_smiley_ecstatic_filled; + else + icon = R.drawable.ic_smiley_gunther_filled; + } + setErrorIconDrawable(icon); + if (icon != 0) + setError(" "); + else setError(null); + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/SendProgressView.java b/app/src/main/java/com/m2049r/xmrwallet/widget/SendProgressView.java new file mode 100644 index 0000000..11d5355 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/SendProgressView.java @@ -0,0 +1,87 @@ +/* + * 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.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.m2049r.xmrwallet.R; + +public class SendProgressView extends LinearLayout { + + public SendProgressView(Context context, AttributeSet attrs) { + super(context, attrs); + initializeViews(context); + } + + public SendProgressView(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + initializeViews(context); + } + + private void initializeViews(Context context) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + inflater.inflate(R.layout.view_send_progress, this); + } + + + View pbProgress; + View llMessage; + TextView tvCode; + TextView tvMessage; + TextView tvSolution; + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + pbProgress = findViewById(R.id.pbProgress); + llMessage = findViewById(R.id.llMessage); + tvCode = findViewById(R.id.tvCode); + tvMessage = findViewById(R.id.tvMessage); + tvSolution = findViewById(R.id.tvSolution); + } + + public void showProgress(String progressText) { + pbProgress.setVisibility(VISIBLE); + tvCode.setVisibility(INVISIBLE); + tvMessage.setText(progressText); + llMessage.setVisibility(VISIBLE); + tvSolution.setVisibility(INVISIBLE); + } + + public void hideProgress() { + pbProgress.setVisibility(INVISIBLE); + llMessage.setVisibility(INVISIBLE); + } + + public void showMessage(String code, String message, String solution) { + tvCode.setText(code); + tvMessage.setText(message); + tvSolution.setText(solution); + tvCode.setVisibility(VISIBLE); + llMessage.setVisibility(VISIBLE); + tvSolution.setVisibility(VISIBLE); + pbProgress.setVisibility(INVISIBLE); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/Toolbar.java b/app/src/main/java/com/m2049r/xmrwallet/widget/Toolbar.java new file mode 100644 index 0000000..768f8a8 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/Toolbar.java @@ -0,0 +1,161 @@ +/* + * 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. + */ + +// based on https://code.tutsplus.com/tutorials/creating-compound-views-on-android--cms-22889 + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import com.google.android.material.appbar.MaterialToolbar; +import com.m2049r.xmrwallet.R; + +import timber.log.Timber; + +public class Toolbar extends MaterialToolbar { + public interface OnButtonListener { + void onButton(int type); + } + + OnButtonListener onButtonListener; + + public void setOnButtonListener(OnButtonListener listener) { + onButtonListener = listener; + } + + ImageView toolbarImage; + TextView toolbarTitle; + TextView toolbarSubtitle; + ImageButton bSettings; + + public Toolbar(Context context) { + super(context); + initializeViews(context); + } + + public Toolbar(Context context, AttributeSet attrs) { + super(context, attrs); + initializeViews(context); + } + + public Toolbar(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + initializeViews(context); + } + + /** + * Inflates the views in the layout. + * + * @param context the current context for the view. + */ + private void initializeViews(Context context) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + inflater.inflate(R.layout.view_toolbar, this); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + toolbarImage = findViewById(R.id.toolbarImage); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + // the vector image does not work well for androis < Nougat + toolbarImage.getLayoutParams().width = (int) getResources().getDimension(R.dimen.logo_width); + toolbarImage.setImageResource(R.drawable.logo_horizontol_xmrujo); + } + + toolbarTitle = findViewById(R.id.toolbarTitle); + toolbarSubtitle = findViewById(R.id.toolbarSubtitle); + bSettings = findViewById(R.id.bSettings); + bSettings.setOnClickListener(v -> { + if (onButtonListener != null) { + onButtonListener.onButton(buttonType); + } + }); + } + + public void setTitle(String title, String subtitle) { + setTitle(title); + setSubtitle(subtitle); + } + + public void setTitle(String title) { + toolbarTitle.setText(title); + if (title != null) { + toolbarImage.setVisibility(View.INVISIBLE); + toolbarTitle.setVisibility(View.VISIBLE); + } else { + toolbarImage.setVisibility(View.VISIBLE); + toolbarTitle.setVisibility(View.INVISIBLE); + } + } + + public final static int BUTTON_NONE = 0; + public final static int BUTTON_BACK = 1; + public final static int BUTTON_CLOSE = 2; + public final static int BUTTON_SETTINGS = 3; + public final static int BUTTON_CANCEL = 4; + + int buttonType = BUTTON_SETTINGS; + + public void setButton(int type) { + switch (type) { + case BUTTON_BACK: + Timber.d("BUTTON_BACK"); + bSettings.setImageResource(R.drawable.ic_arrow_back); + bSettings.setVisibility(View.VISIBLE); + break; + case BUTTON_CLOSE: + Timber.d("BUTTON_CLOSE"); + bSettings.setImageResource(R.drawable.ic_close_white_24dp); + bSettings.setVisibility(View.VISIBLE); + break; + case BUTTON_SETTINGS: + Timber.d("BUTTON_SETTINGS"); + bSettings.setImageResource(R.drawable.ic_settings); + bSettings.setVisibility(View.VISIBLE); + break; + case BUTTON_CANCEL: + Timber.d("BUTTON_CANCEL"); + bSettings.setImageResource(R.drawable.ic_close_white_24dp); + bSettings.setVisibility(View.VISIBLE); + break; + case BUTTON_NONE: + default: + Timber.d("BUTTON_NONE"); + bSettings.setVisibility(View.INVISIBLE); + } + buttonType = type; + } + + public void setSubtitle(String subtitle) { + toolbarSubtitle.setText(subtitle); + if (subtitle != null) { + toolbarSubtitle.setVisibility(View.VISIBLE); + } else { + toolbarSubtitle.setVisibility(View.INVISIBLE); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/theromus/sha/Keccak.java b/app/src/main/java/com/theromus/sha/Keccak.java new file mode 100644 index 0000000..0163a31 --- /dev/null +++ b/app/src/main/java/com/theromus/sha/Keccak.java @@ -0,0 +1,170 @@ +package com.theromus.sha; + +import static com.theromus.utils.HexUtils.leftRotate64; +import static com.theromus.utils.HexUtils.convertToUint; +import static com.theromus.utils.HexUtils.convertFromLittleEndianTo64; +import static com.theromus.utils.HexUtils.convertFrom64ToLittleEndian; +import static java.lang.Math.min; +import static java.lang.System.arraycopy; +import static java.util.Arrays.fill; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; + + +/** + * Keccak implementation. + * + * @author romus + */ +public class Keccak { + + private static BigInteger BIT_64 = new BigInteger("18446744073709551615"); + + /** + * Do hash. + * + * @param message input data + * @param parameter keccak param + * @return byte-array result + */ + public byte[] getHash(final byte[] message, final Parameters parameter) { + int[] uState = new int[200]; + int[] uMessage = convertToUint(message); + + + int rateInBytes = parameter.getRate() / 8; + int blockSize = 0; + int inputOffset = 0; + + // Absorbing phase + while (inputOffset < uMessage.length) { + blockSize = min(uMessage.length - inputOffset, rateInBytes); + for (int i = 0; i < blockSize; i++) { + uState[i] = uState[i] ^ uMessage[i + inputOffset]; + } + + inputOffset = inputOffset + blockSize; + if (blockSize == rateInBytes) { + doKeccakf(uState); + blockSize = 0; + } + } + + // Padding phase + uState[blockSize] = uState[blockSize] ^ parameter.getD(); + if ((parameter.getD() & 0x80) != 0 && blockSize == (rateInBytes - 1)) { + doKeccakf(uState); + } + + uState[rateInBytes - 1] = uState[rateInBytes - 1] ^ 0x80; + doKeccakf(uState); + + // Squeezing phase + ByteArrayOutputStream byteResults = new ByteArrayOutputStream(); + int tOutputLen = parameter.getOutputLen() / 8; + while (tOutputLen > 0) { + blockSize = min(tOutputLen, rateInBytes); + for (int i = 0; i < blockSize; i++) { + byteResults.write((byte) uState[i]); + } + + tOutputLen -= blockSize; + if (tOutputLen > 0) { + doKeccakf(uState); + } + } + + return byteResults.toByteArray(); + } + + private void doKeccakf(final int[] uState) { + BigInteger[][] lState = new BigInteger[5][5]; + + for (int i = 0; i < 5; i++) { + for (int j = 0; j < 5; j++) { + int[] data = new int[8]; + arraycopy(uState, 8 * (i + 5 * j), data, 0, data.length); + lState[i][j] = convertFromLittleEndianTo64(data); + } + } + roundB(lState); + + fill(uState, 0); + for (int i = 0; i < 5; i++) { + for (int j = 0; j < 5; j++) { + int[] data = convertFrom64ToLittleEndian(lState[i][j]); + arraycopy(data, 0, uState, 8 * (i + 5 * j), data.length); + } + } + + } + + /** + * Permutation on the given state. + * + * @param state state + */ + private void roundB(final BigInteger[][] state) { + int LFSRstate = 1; + for (int round = 0; round < 24; round++) { + BigInteger[] C = new BigInteger[5]; + BigInteger[] D = new BigInteger[5]; + + // θ step + for (int i = 0; i < 5; i++) { + C[i] = state[i][0].xor(state[i][1]).xor(state[i][2]).xor(state[i][3]).xor(state[i][4]); + } + + for (int i = 0; i < 5; i++) { + D[i] = C[(i + 4) % 5].xor(leftRotate64(C[(i + 1) % 5], 1)); + } + + for (int i = 0; i < 5; i++) { + for (int j = 0; j < 5; j++) { + state[i][j] = state[i][j].xor(D[i]); + } + } + + //ρ and π steps + int x = 1, y = 0; + BigInteger current = state[x][y]; + for (int i = 0; i < 24; i++) { + int tX = x; + x = y; + y = (2 * tX + 3 * y) % 5; + + BigInteger shiftValue = current; + current = state[x][y]; + + state[x][y] = leftRotate64(shiftValue, (i + 1) * (i + 2) / 2); + } + + //χ step + for (int j = 0; j < 5; j++) { + BigInteger[] t = new BigInteger[5]; + for (int i = 0; i < 5; i++) { + t[i] = state[i][j]; + } + + for (int i = 0; i < 5; i++) { + // ~t[(i + 1) % 5] + BigInteger invertVal = t[(i + 1) % 5].xor(BIT_64); + // t[i] ^ ((~t[(i + 1) % 5]) & t[(i + 2) % 5]) + state[i][j] = t[i].xor(invertVal.and(t[(i + 2) % 5])); + } + } + + //ι step + for (int i = 0; i < 7; i++) { + LFSRstate = ((LFSRstate << 1) ^ ((LFSRstate >> 7) * 0x71)) % 256; + // pow(2, i) - 1 + int bitPosition = (1 << i) - 1; + if ((LFSRstate & 2) != 0) { + state[0][0] = state[0][0].xor(new BigInteger("1").shiftLeft(bitPosition)); + } + } + } + } + +} diff --git a/app/src/main/java/com/theromus/sha/Parameters.java b/app/src/main/java/com/theromus/sha/Parameters.java new file mode 100644 index 0000000..6835b5a --- /dev/null +++ b/app/src/main/java/com/theromus/sha/Parameters.java @@ -0,0 +1,51 @@ +package com.theromus.sha; + +/** + * The parameters defining the standard FIPS 202. + * + * @author romus + */ +public enum Parameters { + KECCAK_224 (1152, 0x01, 224), + KECCAK_256 (1088, 0x01, 256), + KECCAK_384 (832, 0x01, 384), + KECCAK_512 (576, 0x01, 512), + + SHA3_224 (1152, 0x06, 224), + SHA3_256 (1088, 0x06, 256), + SHA3_384 (832, 0x06, 384), + SHA3_512 (576, 0x06, 512), + + SHAKE128 (1344, 0x1F, 256), + SHAKE256 (1088, 0x1F, 512); + + private final int rate; + + /** + * Delimited suffix. + */ + public final int d; + + /** + * Output length (bits). + */ + public final int outputLen; + + Parameters(int rate, int d, int outputLen) { + this.rate = rate; + this.d = d; + this.outputLen = outputLen; + } + + public int getRate() { + return rate; + } + + public int getD() { + return d; + } + + public int getOutputLen() { + return outputLen; + } +} diff --git a/app/src/main/java/com/theromus/utils/HexUtils.java b/app/src/main/java/com/theromus/utils/HexUtils.java new file mode 100644 index 0000000..d1fc903 --- /dev/null +++ b/app/src/main/java/com/theromus/utils/HexUtils.java @@ -0,0 +1,97 @@ +package com.theromus.utils; + + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; + +/** + * Hex-utils. + * + * @author romus + */ +public class HexUtils { + + private static final byte[] ENCODE_BYTE_TABLE = { + (byte) '0', (byte) '1', (byte) '2', (byte) '3', (byte) '4', (byte) '5', (byte) '6', (byte) '7', + (byte) '8', (byte) '9', (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e', (byte) 'f' + }; + + /** + * Convert byte array to unsigned array. + * + * @param data byte array + * @return unsigned array + */ + public static int[] convertToUint(final byte[] data) { + int[] converted = new int[data.length]; + for (int i = 0; i < data.length; i++) { + converted[i] = data[i] & 0xFF; + } + + return converted; + } + + /** + * Convert LE to 64-bit value (unsigned long). + * + * @param data data + * @return 64-bit value (unsigned long) + */ + public static BigInteger convertFromLittleEndianTo64(final int[] data) { + BigInteger uLong = new BigInteger("0"); + for (int i = 0; i < 8; i++) { + uLong = uLong.add(new BigInteger(Integer.toString(data[i])).shiftLeft(8 * i)); + } + + return uLong; + } + + /** + * Convert 64-bit (unsigned long) value to LE. + * + * @param uLong 64-bit value (unsigned long) + * @return LE + */ + public static int[] convertFrom64ToLittleEndian(final BigInteger uLong) { + int[] data = new int[8]; + BigInteger mod256 = new BigInteger("256"); + for (int i = 0; i < 8; i++) { + data[i] = uLong.shiftRight((8 * i)).mod(mod256).intValue(); + } + + return data; + } + + /** + * Bitwise rotate left. + * + * @param value unsigned long value + * @param rotate rotate left + * @return result + */ + public static BigInteger leftRotate64(final BigInteger value, final int rotate) { + BigInteger lp = value.shiftRight(64 - (rotate % 64)); + BigInteger rp = value.shiftLeft(rotate % 64); + + return lp.add(rp).mod(new BigInteger("18446744073709551616")); + } + + /** + * Convert bytes to string. + * + * @param data bytes array + * @return string + */ + public static String convertBytesToString(final byte[] data) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < data.length; i++) { + int uVal = data[i] & 0xFF; + + buffer.write(ENCODE_BYTE_TABLE[(uVal >>> 4)]); + buffer.write(ENCODE_BYTE_TABLE[uVal & 0xF]); + } + + return new String(buffer.toByteArray()); + } + +} diff --git a/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java b/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java new file mode 100644 index 0000000..60ee5a4 --- /dev/null +++ b/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-2016 Nathan Freitas + * Copyright 2015 str4d + * Portions Copyright (c) 2016 CommonsWare, LLC + * + * 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 info.guardianproject.netcipher.client; + +import android.content.Context; +import android.content.Intent; +import javax.net.ssl.SSLSocketFactory; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +/** + * Creates an OkHttpClient using NetCipher configuration. Use + * build() if you have no other OkHttpClient configuration + * that you need to perform. Or, use applyTo() to augment an + * existing OkHttpClient.Builder with NetCipher. + */ +public class StrongOkHttpClientBuilder extends + StrongBuilderBase { + /** + * Creates a StrongOkHttpClientBuilder using the strongest set + * of options for security. Use this if the strongest set of + * options is what you want; otherwise, create a + * builder via the constructor and configure it as you see fit. + * + * @param context any Context will do + * @return a configured StrongOkHttpClientBuilder + * @throws Exception + */ + static public StrongOkHttpClientBuilder forMaxSecurity(Context context) + throws Exception { + return(new StrongOkHttpClientBuilder(context) + .withBestProxy()); + } + + /** + * Creates a builder instance. + * + * @param context any Context will do; builder will hold onto + * Application context + */ + public StrongOkHttpClientBuilder(Context context) { + super(context); + } + + /** + * Copy constructor. + * + * @param original builder to clone + */ + public StrongOkHttpClientBuilder(StrongOkHttpClientBuilder original) { + super(original); + } + + /** + * OkHttp3 does not support SOCKS proxies: + * https://github.com/square/okhttp/issues/2315 + * + * @return false + */ + @Override + public boolean supportsSocksProxy() { + return(true); + } + + /** + * {@inheritDoc} + */ + @Override + public OkHttpClient build(Intent status) { + return(applyTo(new OkHttpClient.Builder(), status).build()); + } + + /** + * Adds NetCipher configuration to an existing OkHttpClient.Builder, + * in case you have additional configuration that you wish to + * perform. + * + * @param builder a new or partially-configured OkHttpClient.Builder + * @return the same builder + */ + public OkHttpClient.Builder applyTo(OkHttpClient.Builder builder, Intent status) { + SSLSocketFactory factory=buildSocketFactory(); + + if (factory!=null) { + builder.sslSocketFactory(factory); + } + + return(builder + .proxy(buildProxy(status))); + } + + @Override + protected String get(Intent status, OkHttpClient connection, + String url) throws Exception { + Request request=new Request.Builder().url(url).build(); + + return(connection.newCall(request).execute().body().string()); + } +} diff --git a/app/src/main/res/anim/cycle_7.xml b/app/src/main/res/anim/cycle_7.xml new file mode 100644 index 0000000..4bfb143 --- /dev/null +++ b/app/src/main/res/anim/cycle_7.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/fab_close.xml b/app/src/main/res/anim/fab_close.xml new file mode 100644 index 0000000..7a5c735 --- /dev/null +++ b/app/src/main/res/anim/fab_close.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fab_close_screen.xml b/app/src/main/res/anim/fab_close_screen.xml new file mode 100644 index 0000000..ec4ee1a --- /dev/null +++ b/app/src/main/res/anim/fab_close_screen.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fab_open.xml b/app/src/main/res/anim/fab_open.xml new file mode 100644 index 0000000..03c4c30 --- /dev/null +++ b/app/src/main/res/anim/fab_open.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fab_open_screen.xml b/app/src/main/res/anim/fab_open_screen.xml new file mode 100644 index 0000000..ceb2831 --- /dev/null +++ b/app/src/main/res/anim/fab_open_screen.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fab_pulse.xml b/app/src/main/res/anim/fab_pulse.xml new file mode 100644 index 0000000..fb4e3ac --- /dev/null +++ b/app/src/main/res/anim/fab_pulse.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/rotate_backward.xml b/app/src/main/res/anim/rotate_backward.xml new file mode 100644 index 0000000..fed9d93 --- /dev/null +++ b/app/src/main/res/anim/rotate_backward.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/rotate_forward.xml b/app/src/main/res/anim/rotate_forward.xml new file mode 100644 index 0000000..47877dd --- /dev/null +++ b/app/src/main/res/anim/rotate_forward.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/shake.xml b/app/src/main/res/anim/shake.xml new file mode 100644 index 0000000..816ef0a --- /dev/null +++ b/app/src/main/res/anim/shake.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/color/btn_color_selector.xml b/app/src/main/res/color/btn_color_selector.xml new file mode 100644 index 0000000..a863d40 --- /dev/null +++ b/app/src/main/res/color/btn_color_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-night/ic_emptygunther.xml b/app/src/main/res/drawable-night/ic_emptygunther.xml new file mode 100644 index 0000000..f6534b6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emptygunther.xml @@ -0,0 +1,1083 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_gunther_streetmode.xml b/app/src/main/res/drawable-night/ic_gunther_streetmode.xml new file mode 100644 index 0000000..93ff431 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_gunther_streetmode.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_onboarding_fingerprint.xml b/app/src/main/res/drawable-night/ic_onboarding_fingerprint.xml new file mode 100644 index 0000000..2b07a1c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding_fingerprint.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_onboarding_nodes.xml b/app/src/main/res/drawable-night/ic_onboarding_nodes.xml new file mode 100644 index 0000000..7c8c5bb --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding_nodes.xml @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_onboarding_seed.xml b/app/src/main/res/drawable-night/ic_onboarding_seed.xml new file mode 100644 index 0000000..c7b7934 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding_seed.xml @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_onboarding_welcome.xml b/app/src/main/res/drawable-night/ic_onboarding_welcome.xml new file mode 100644 index 0000000..65c3886 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding_welcome.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_onboarding_xmrto.xml b/app/src/main/res/drawable-night/ic_onboarding_xmrto.xml new file mode 100644 index 0000000..28054d3 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding_xmrto.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v24/ic_check_circle.xml b/app/src/main/res/drawable-v24/ic_check_circle.xml new file mode 100644 index 0000000..7c0c48e --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_check_circle.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v24/ic_check_circle_xmr.xml b/app/src/main/res/drawable-v24/ic_check_circle_xmr.xml new file mode 100644 index 0000000..342a70b --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_check_circle_xmr.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v24/ic_xmrto_btc_off.xml b/app/src/main/res/drawable-v24/ic_xmrto_btc_off.xml new file mode 100644 index 0000000..bacf2e4 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_xmrto_btc_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable-v24/ic_xmrto_dash_off.xml b/app/src/main/res/drawable-v24/ic_xmrto_dash_off.xml new file mode 100644 index 0000000..8d52acf --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_xmrto_dash_off.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-v24/ic_xmrto_doge_off.xml b/app/src/main/res/drawable-v24/ic_xmrto_doge_off.xml new file mode 100644 index 0000000..dc12f6b --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_xmrto_doge_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable-v24/ic_xmrto_eth_off.xml b/app/src/main/res/drawable-v24/ic_xmrto_eth_off.xml new file mode 100644 index 0000000..8e58eca --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_xmrto_eth_off.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-v24/ic_xmrto_ltc_off.xml b/app/src/main/res/drawable-v24/ic_xmrto_ltc_off.xml new file mode 100644 index 0000000..ffdc3bb --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_xmrto_ltc_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/backgound_all.xml b/app/src/main/res/drawable/backgound_all.xml new file mode 100644 index 0000000..7e94c2e --- /dev/null +++ b/app/src/main/res/drawable/backgound_all.xml @@ -0,0 +1,352 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/backgound_seed.xml b/app/src/main/res/drawable/backgound_seed.xml new file mode 100644 index 0000000..b15241e --- /dev/null +++ b/app/src/main/res/drawable/backgound_seed.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/backgound_toolbar_mainnet.xml b/app/src/main/res/drawable/backgound_toolbar_mainnet.xml new file mode 100644 index 0000000..87168c3 --- /dev/null +++ b/app/src/main/res/drawable/backgound_toolbar_mainnet.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/backgound_toolbar_streetmode.xml b/app/src/main/res/drawable/backgound_toolbar_streetmode.xml new file mode 100644 index 0000000..d354b60 --- /dev/null +++ b/app/src/main/res/drawable/backgound_toolbar_streetmode.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_green.xml b/app/src/main/res/drawable/button_green.xml new file mode 100644 index 0000000..7bcc594 --- /dev/null +++ b/app/src/main/res/drawable/button_green.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_selector_green.xml b/app/src/main/res/drawable/button_selector_green.xml new file mode 100644 index 0000000..3eb712d --- /dev/null +++ b/app/src/main/res/drawable/button_selector_green.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_dark.xml b/app/src/main/res/drawable/dot_dark.xml new file mode 100644 index 0000000..fdd253f --- /dev/null +++ b/app/src/main/res/drawable/dot_dark.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/dot_light.xml b/app/src/main/res/drawable/dot_light.xml new file mode 100644 index 0000000..8699969 --- /dev/null +++ b/app/src/main/res/drawable/dot_light.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/gradient_all.xml b/app/src/main/res/drawable/gradient_all.xml new file mode 100644 index 0000000..0c1e537 --- /dev/null +++ b/app/src/main/res/drawable/gradient_all.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/drawable/gradient_oval.xml b/app/src/main/res/drawable/gradient_oval.xml new file mode 100644 index 0000000..a2ec4b7 --- /dev/null +++ b/app/src/main/res/drawable/gradient_oval.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gradient_street.xml b/app/src/main/res/drawable/gradient_street.xml new file mode 100644 index 0000000..13d38c7 --- /dev/null +++ b/app/src/main/res/drawable/gradient_street.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/drawable/gradient_street_efab.xml b/app/src/main/res/drawable/gradient_street_efab.xml new file mode 100644 index 0000000..fecef8c --- /dev/null +++ b/app/src/main/res/drawable/gradient_street_efab.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gunther_24dp.png b/app/src/main/res/drawable/gunther_24dp.png new file mode 100644 index 0000000..33e0c0f Binary files /dev/null and b/app/src/main/res/drawable/gunther_24dp.png differ diff --git a/app/src/main/res/drawable/gunther_coder.png b/app/src/main/res/drawable/gunther_coder.png new file mode 100644 index 0000000..bc6508c Binary files /dev/null and b/app/src/main/res/drawable/gunther_coder.png differ diff --git a/app/src/main/res/drawable/gunther_csi_24dp.png b/app/src/main/res/drawable/gunther_csi_24dp.png new file mode 100644 index 0000000..a527514 Binary files /dev/null and b/app/src/main/res/drawable/gunther_csi_24dp.png differ diff --git a/app/src/main/res/drawable/gunther_desaturated.png b/app/src/main/res/drawable/gunther_desaturated.png new file mode 100644 index 0000000..2f14e52 Binary files /dev/null and b/app/src/main/res/drawable/gunther_desaturated.png differ diff --git a/app/src/main/res/drawable/ic_account_balance_wallet_black_24dp.xml b/app/src/main/res/drawable/ic_account_balance_wallet_black_24dp.xml new file mode 100644 index 0000000..23c3e5d --- /dev/null +++ b/app/src/main/res/drawable/ic_account_balance_wallet_black_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..e249c3e --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle.xml b/app/src/main/res/drawable/ic_add_circle.xml new file mode 100644 index 0000000..2c0d372 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_all_inclusive.xml b/app/src/main/res/drawable/ic_all_inclusive.xml new file mode 100644 index 0000000..92f9867 --- /dev/null +++ b/app/src/main/res/drawable/ic_all_inclusive.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..858c605 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..d820d9b --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle_xmr.xml b/app/src/main/res/drawable/ic_check_circle_xmr.xml new file mode 100644 index 0000000..645bdf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_xmr.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_close_white_24dp.xml b/app/src/main/res/drawable/ic_close_white_24dp.xml new file mode 100644 index 0000000..927a942 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_content_copy_24dp.xml b/app/src/main/res/drawable/ic_content_copy_24dp.xml new file mode 100644 index 0000000..1ba50d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_content_paste_24dp.xml b/app/src/main/res/drawable/ic_content_paste_24dp.xml new file mode 100644 index 0000000..01dae40 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_paste_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_all.xml b/app/src/main/res/drawable/ic_done_all.xml new file mode 100644 index 0000000..80c1ad8 --- /dev/null +++ b/app/src/main/res/drawable/ic_done_all.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_emptygunther.xml b/app/src/main/res/drawable/ic_emptygunther.xml new file mode 100644 index 0000000..3f45d8e --- /dev/null +++ b/app/src/main/res/drawable/ic_emptygunther.xml @@ -0,0 +1,1113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_error_red_24dp.xml b/app/src/main/res/drawable/ic_error_red_24dp.xml new file mode 100644 index 0000000..70e38a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_red_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_red_36dp.xml b/app/src/main/res/drawable/ic_error_red_36dp.xml new file mode 100644 index 0000000..6d30b10 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_red_36dp.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000..c7f3ef7 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,19 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eye_black_24dp.xml b/app/src/main/res/drawable/ic_eye_black_24dp.xml new file mode 100644 index 0000000..e02f1d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_24dp.xml b/app/src/main/res/drawable/ic_favorite_24dp.xml new file mode 100644 index 0000000..933147e --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_border_24dp.xml b/app/src/main/res/drawable/ic_favorite_border_24dp.xml new file mode 100644 index 0000000..a26a6ba --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 0000000..30d9186 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_gunther_streetmode.xml b/app/src/main/res/drawable/ic_gunther_streetmode.xml new file mode 100644 index 0000000..4f8a59a --- /dev/null +++ b/app/src/main/res/drawable/ic_gunther_streetmode.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_hand.xml b/app/src/main/res/drawable/ic_hand.xml new file mode 100644 index 0000000..c8f20cf --- /dev/null +++ b/app/src/main/res/drawable/ic_hand.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable/ic_help_white_24dp.xml new file mode 100644 index 0000000..1f73200 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_import.xml b/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 0000000..62c1d5e --- /dev/null +++ b/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_black_24dp.xml b/app/src/main/res/drawable/ic_info_outline_black_24dp.xml new file mode 100644 index 0000000..f942316 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml b/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml new file mode 100644 index 0000000..c7841dd --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_white_24dp.xml b/app/src/main/res/drawable/ic_info_white_24dp.xml new file mode 100644 index 0000000..9f60ef9 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_key.xml b/app/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..6252c72 --- /dev/null +++ b/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,31 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down.xml new file mode 100644 index 0000000..d6ed308 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_up.xml b/app/src/main/res/drawable/ic_keyboard_arrow_up.xml new file mode 100644 index 0000000..5df167a --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launch_external.xml b/app/src/main/res/drawable/ic_launch_external.xml new file mode 100644 index 0000000..761c15c --- /dev/null +++ b/app/src/main/res/drawable/ic_launch_external.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..85c122d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2c2fd4b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_ledger_restore.xml b/app/src/main/res/drawable/ic_ledger_restore.xml new file mode 100644 index 0000000..be8b691 --- /dev/null +++ b/app/src/main/res/drawable/ic_ledger_restore.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_logo_horizontol_xmrujo.xml b/app/src/main/res/drawable/ic_logo_horizontol_xmrujo.xml new file mode 100644 index 0000000..ec2cdd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_horizontol_xmrujo.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_monero.xml b/app/src/main/res/drawable/ic_monero.xml new file mode 100644 index 0000000..b50ba67 --- /dev/null +++ b/app/src/main/res/drawable/ic_monero.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_monero_bw.xml b/app/src/main/res/drawable/ic_monero_bw.xml new file mode 100644 index 0000000..2910012 --- /dev/null +++ b/app/src/main/res/drawable/ic_monero_bw.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_monero_logo_b.png b/app/src/main/res/drawable/ic_monero_logo_b.png new file mode 100644 index 0000000..2b3dd2b Binary files /dev/null and b/app/src/main/res/drawable/ic_monero_logo_b.png differ diff --git a/app/src/main/res/drawable/ic_monerujo.xml b/app/src/main/res/drawable/ic_monerujo.xml new file mode 100644 index 0000000..88d4306 --- /dev/null +++ b/app/src/main/res/drawable/ic_monerujo.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_navigate_next.xml b/app/src/main/res/drawable/ic_navigate_next.xml new file mode 100644 index 0000000..435fc04 --- /dev/null +++ b/app/src/main/res/drawable/ic_navigate_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_navigate_prev.xml b/app/src/main/res/drawable/ic_navigate_prev.xml new file mode 100644 index 0000000..8460325 --- /dev/null +++ b/app/src/main/res/drawable/ic_navigate_prev.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_network_clearnet.xml b/app/src/main/res/drawable/ic_network_clearnet.xml new file mode 100644 index 0000000..bc817b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_clearnet.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_tor_on.xml b/app/src/main/res/drawable/ic_network_tor_on.xml new file mode 100644 index 0000000..23640f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_tor_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_new.xml b/app/src/main/res/drawable/ic_new.xml new file mode 100644 index 0000000..0c861ae --- /dev/null +++ b/app/src/main/res/drawable/ic_new.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nfc.xml b/app/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000..2821ae8 --- /dev/null +++ b/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_fingerprint.xml b/app/src/main/res/drawable/ic_onboarding_fingerprint.xml new file mode 100644 index 0000000..9ec70a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_fingerprint.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_nodes.xml b/app/src/main/res/drawable/ic_onboarding_nodes.xml new file mode 100644 index 0000000..391d346 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_nodes.xml @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_seed.xml b/app/src/main/res/drawable/ic_onboarding_seed.xml new file mode 100644 index 0000000..3481157 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_seed.xml @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_welcome.xml b/app/src/main/res/drawable/ic_onboarding_welcome.xml new file mode 100644 index 0000000..0158b09 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_welcome.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_xmrto.xml b/app/src/main/res/drawable/ic_onboarding_xmrto.xml new file mode 100644 index 0000000..6476a20 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_xmrto.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pending.xml b/app/src/main/res/drawable/ic_pending.xml new file mode 100644 index 0000000..bca0e0a --- /dev/null +++ b/app/src/main/res/drawable/ic_pending.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_renew.xml b/app/src/main/res/drawable/ic_renew.xml new file mode 100644 index 0000000..adae305 --- /dev/null +++ b/app/src/main/res/drawable/ic_renew.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_scan.xml b/app/src/main/res/drawable/ic_scan.xml new file mode 100644 index 0000000..a06451e --- /dev/null +++ b/app/src/main/res/drawable/ic_scan.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_seed.xml b/app/src/main/res/drawable/ic_seed.xml new file mode 100644 index 0000000..6d714f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_seed.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..d42e154 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..a7c7678 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..c2a4926 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sideshift_circle.xml b/app/src/main/res/drawable/ic_sideshift_circle.xml new file mode 100644 index 0000000..f894205 --- /dev/null +++ b/app/src/main/res/drawable/ic_sideshift_circle.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_sideshift_white.xml b/app/src/main/res/drawable/ic_sideshift_white.xml new file mode 100644 index 0000000..549c445 --- /dev/null +++ b/app/src/main/res/drawable/ic_sideshift_white.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_smiley_ecstatic_filled.xml b/app/src/main/res/drawable/ic_smiley_ecstatic_filled.xml new file mode 100644 index 0000000..d636152 --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_ecstatic_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_smiley_gunther_filled.xml b/app/src/main/res/drawable/ic_smiley_gunther_filled.xml new file mode 100644 index 0000000..73a1202 --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_gunther_filled.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_smiley_happy_filled.xml b/app/src/main/res/drawable/ic_smiley_happy_filled.xml new file mode 100644 index 0000000..ebd638d --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_happy_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_smiley_meh_filled.xml b/app/src/main/res/drawable/ic_smiley_meh_filled.xml new file mode 100644 index 0000000..6936876 --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_meh_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_smiley_neutral_filled.xml b/app/src/main/res/drawable/ic_smiley_neutral_filled.xml new file mode 100644 index 0000000..ca1b805 --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_neutral_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_smiley_sad_filled.xml b/app/src/main/res/drawable/ic_smiley_sad_filled.xml new file mode 100644 index 0000000..1f7a498 --- /dev/null +++ b/app/src/main/res/drawable/ic_smiley_sad_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_statsup.xml b/app/src/main/res/drawable/ic_statsup.xml new file mode 100644 index 0000000..1a553ba --- /dev/null +++ b/app/src/main/res/drawable/ic_statsup.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_success.xml b/app/src/main/res/drawable/ic_success.xml new file mode 100644 index 0000000..0bc71ee --- /dev/null +++ b/app/src/main/res/drawable/ic_success.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi_1_bar.xml b/app/src/main/res/drawable/ic_wifi_1_bar.xml new file mode 100644 index 0000000..fa525ec --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_1_bar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_wifi_2_bar.xml b/app/src/main/res/drawable/ic_wifi_2_bar.xml new file mode 100644 index 0000000..9a96709 --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_2_bar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_wifi_3_bar.xml b/app/src/main/res/drawable/ic_wifi_3_bar.xml new file mode 100644 index 0000000..4087257 --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_3_bar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_wifi_4_bar.xml b/app/src/main/res/drawable/ic_wifi_4_bar.xml new file mode 100644 index 0000000..0277e81 --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_4_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi_lock.xml b/app/src/main/res/drawable/ic_wifi_lock.xml new file mode 100644 index 0000000..63f47c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi_off.xml b/app/src/main/res/drawable/ic_wifi_off.xml new file mode 100644 index 0000000..a4905df --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_xmrto_btc.xml b/app/src/main/res/drawable/ic_xmrto_btc.xml new file mode 100644 index 0000000..60efbe2 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_btc.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_btc_off.xml b/app/src/main/res/drawable/ic_xmrto_btc_off.xml new file mode 100644 index 0000000..a0d6714 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_btc_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_dash.xml b/app/src/main/res/drawable/ic_xmrto_dash.xml new file mode 100644 index 0000000..a3ace15 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_dash.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_dash_off.xml b/app/src/main/res/drawable/ic_xmrto_dash_off.xml new file mode 100644 index 0000000..e183626 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_dash_off.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_doge.xml b/app/src/main/res/drawable/ic_xmrto_doge.xml new file mode 100644 index 0000000..c8bfcd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_doge.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_doge_off.xml b/app/src/main/res/drawable/ic_xmrto_doge_off.xml new file mode 100644 index 0000000..c6c6a3b --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_doge_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_eth.xml b/app/src/main/res/drawable/ic_xmrto_eth.xml new file mode 100644 index 0000000..9569125 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_eth.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_eth_off.xml b/app/src/main/res/drawable/ic_xmrto_eth_off.xml new file mode 100644 index 0000000..55c7161 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_eth_off.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_logo.xml b/app/src/main/res/drawable/ic_xmrto_logo.xml new file mode 100644 index 0000000..17058e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_logo.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_ltc.xml b/app/src/main/res/drawable/ic_xmrto_ltc.xml new file mode 100644 index 0000000..5bb3470 --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_ltc.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_xmrto_ltc_off.xml b/app/src/main/res/drawable/ic_xmrto_ltc_off.xml new file mode 100644 index 0000000..a78f33f --- /dev/null +++ b/app/src/main/res/drawable/ic_xmrto_ltc_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/logo_horizontol_xmrujo.png b/app/src/main/res/drawable/logo_horizontol_xmrujo.png new file mode 100644 index 0000000..fd45253 Binary files /dev/null and b/app/src/main/res/drawable/logo_horizontol_xmrujo.png differ diff --git a/app/src/main/res/drawable/onboarding_dots.xml b/app/src/main/res/drawable/onboarding_dots.xml new file mode 100644 index 0000000..d35efb8 --- /dev/null +++ b/app/src/main/res/drawable/onboarding_dots.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/selector_login.xml b/app/src/main/res/drawable/selector_login.xml new file mode 100644 index 0000000..b62ad95 --- /dev/null +++ b/app/src/main/res/drawable/selector_login.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..c6cdff3 --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_on_boarding.xml b/app/src/main/res/layout/activity_on_boarding.xml new file mode 100644 index 0000000..cfaf918 --- /dev/null +++ b/app/src/main/res/layout/activity_on_boarding.xml @@ -0,0 +1,57 @@ + + + +