Compare commits
109 Commits
v1.0.0-alp
...
master
Author | SHA1 | Date |
---|---|---|
Kevin Gorham | baf3522e89 | |
Kevin Gorham | adda661311 | |
Kevin Gorham | a68981d788 | |
Kevin Gorham | 5d30a274fd | |
Kevin Gorham | 258b622d58 | |
Kevin Gorham | e4d060dbdb | |
Kevin Gorham | 9b199ceb36 | |
Kevin Gorham | 1ceaa2a996 | |
Kevin Gorham | 762eff533b | |
Kevin Gorham | 24064e7fd5 | |
Kevin Gorham | 484f0d2368 | |
Kevin Gorham | 1bda85c1c4 | |
Kevin Gorham | 0afe8e05b0 | |
Linda Lee | b568495089 | |
Linda Lee | 768c8819a0 | |
Kevin Gorham | 1ff92a8269 | |
Linda Lee | 331498a97c | |
Kevin Gorham | e70c55e7a6 | |
Kevin Gorham | e9d7bea423 | |
Kevin Gorham | 9ff1e96ebd | |
Kevin Gorham | f38ba85e6a | |
Kevin Gorham | 41422992ce | |
Kevin Gorham | de69567812 | |
Kevin Gorham | 340fb8c993 | |
Kevin Gorham | ebbe69125c | |
Kevin Gorham | 4c4ef46efe | |
Kevin Gorham | c5a17ff876 | |
Kevin Gorham | 901db38ee0 | |
Taylor Hornby | 5cd2091394 | |
Kevin Gorham | e77423e3dd | |
Taylor Hornby | c3622f1d63 | |
Kevin Gorham | 7d2a62854a | |
Kevin Gorham | 7717612524 | |
Kevin Gorham | 337a361ef1 | |
Kevin Gorham | 5632de7493 | |
Kevin Gorham | 5a956a55d3 | |
Kevin Gorham | 8371f9c53a | |
Kevin Gorham | 6e44614207 | |
Kevin Gorham | 27efadc218 | |
Kevin Gorham | ae41bd50cf | |
Kevin Gorham | 28d19bce1f | |
Kevin Gorham | dcd1e63491 | |
Kevin Gorham | 436fa5fa74 | |
Kevin Gorham | f724a74993 | |
Kevin Gorham | 3d4ae2ae63 | |
Kevin Gorham | 9550cdbbc7 | |
Kevin Gorham | 1367ef6eff | |
Kevin Gorham | 6d08591452 | |
Kevin Gorham | 2fc572e434 | |
Kevin Gorham | d5129e44fa | |
Kevin Gorham | 5803a9dd71 | |
Kevin Gorham | 8331e8ff06 | |
Kevin Gorham | 4392f02dbe | |
Kevin Gorham | 9b756d60da | |
Kevin Gorham | a2a53f3cb8 | |
Kevin Gorham | 3bce43c32e | |
Kevin Gorham | 8434e23014 | |
Kevin Gorham | f02021709a | |
Kevin Gorham | b630b9fa78 | |
Kevin Gorham | 6da700d683 | |
Kevin Gorham | a357afe09a | |
Kevin Gorham | 899e48b9f3 | |
Kevin Gorham | 61ec3bed66 | |
Linda Lee | 2c0fcaacd5 | |
Linda Lee | a1424e2d3d | |
Kevin Gorham | cccfbe2271 | |
Kevin Gorham | 27a78a90b4 | |
Kevin Gorham | b72b1434ac | |
Kevin Gorham | 64461197b6 | |
Kevin Gorham | 3028f99ced | |
Kevin Gorham | 4283a771f6 | |
Kevin Gorham | f8603d424a | |
Kevin Gorham | f7e438431d | |
Kevin Gorham | 771d10358e | |
Kevin Gorham | 62bbd30c40 | |
Kevin Gorham | fd5a0ff831 | |
Kevin Gorham | 83969f0eb4 | |
Kevin Gorham | cb9e6cc4b4 | |
Kevin Gorham | 655d959282 | |
Kevin Gorham | d5c8d17c3d | |
Kevin Gorham | 4c8adf5180 | |
Kevin Gorham | 6ab46f75bb | |
Kevin Gorham | 931bf5c280 | |
Kevin Gorham | 5fbee70b58 | |
Kevin Gorham | 7f026f033e | |
Kevin Gorham | a93cf27eea | |
Kevin Gorham | 074b4fe1ee | |
Kevin Gorham | f72f33477d | |
Kevin Gorham | df651dddad | |
Kevin Gorham | c42a0063c2 | |
Kevin Gorham | 4922d690e9 | |
Kevin Gorham | 8ef4edd88b | |
Kevin Gorham | 4ecac12f03 | |
Kevin Gorham | c99deb7447 | |
Kevin Gorham | 69b32f14b9 | |
Kevin Gorham | ed7577f4a8 | |
Kevin Gorham | 1937c19a14 | |
Kevin Gorham | ac626917f4 | |
Kevin Gorham | 1d25feadf9 | |
Kevin Gorham | b2908989aa | |
Kevin Gorham | ce09ed7bd2 | |
Kevin Gorham | b53992534a | |
Kevin Gorham | 5cdfc97945 | |
Kevin Gorham | e1bbf1b6e8 | |
Kevin Gorham | cdcc39121b | |
Kevin Gorham | f81c6b2dff | |
Kevin Gorham | fa4415ae99 | |
Kevin Gorham | 29c024c563 | |
Kevin Gorham | 65edb2f69a |
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Bug vs expected behavior**
|
||||
What is the bug? What did you expected to happen instead?
|
||||
|
||||
**Device (please complete the following information):**
|
||||
- Android Device: [e.g. Samsung S8]
|
||||
- Android Version [e.g. 22]
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
|
@ -49,7 +49,9 @@ captures/
|
|||
.externalNativeBuild
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# allow our dummy file to live in the repo to make building easier
|
||||
#google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
Change Log
|
||||
==========
|
||||
|
||||
Version 1.0.0-alpha23 *(2020-02-21)*
|
||||
------------------------------------
|
||||
- Fix: reorg improvements, squashing critical bugs that disabled wallets
|
||||
- New: extend analytics to include taps, screen views, and send flow.
|
||||
- New: add crash reporting via Crashlytics.
|
||||
- New: expose user logs and developer logs as files.
|
||||
- New: improve feature for creating checkpoints.
|
||||
- New: added DB schemas to the repository for tracking.
|
||||
- Fix: numerous bug fixes, test fixes and cleanup.
|
||||
- New: improved error handling and user experience
|
||||
|
||||
Version 1.0.0-alpha17 *(2020-02-07)*
|
||||
------------------------------------
|
||||
- New: implemented wallet import
|
||||
- New: display the memo when tapping outbound transactions
|
||||
- Fix: removed the sad zebra and softened wording for sending z->t
|
||||
- Fix: removed restriction on smallest sendable ZEC amount
|
||||
- Fix: removed "fund now"
|
||||
- New: turned on developer logging to help with troubleshooting
|
||||
- New: improved wallet details ability to handle small amounts of ZEC
|
||||
- New: added ability to clear the memo
|
||||
- Fix: changed "SEND WITHOUT MEMO" to "OMIT MEMO"
|
||||
- Fix: corrected wording when the address is included in the memo
|
||||
- New: display the approximate wallet birthday with the backup words
|
||||
- New: improved crash reporting
|
||||
- Fix: fixed bug when returning from the background
|
||||
- New: added logging for failed transactions
|
||||
- New: added logic to verify setup and offer explanation when the wallet is corrupted
|
||||
- New: refactored and improved wallet initialization
|
||||
- New: added ability to contribute 'plugins' to the SDK
|
||||
- New: added tons more checkpoints to reduce startup/import time
|
||||
- New: exposed logic to derive addresses directly from seeds
|
||||
- Fix: fixed several crashes
|
||||
|
||||
Version 1.0.0-alpha11 *(2020-01-15)*
|
||||
------------------------------------
|
||||
- Initial ECC release
|
||||
|
||||
Version 1.0.0-alpha03 *(2019-12-18)*
|
||||
------------------------------------
|
||||
- Initial internal wallet team release
|
|
@ -0,0 +1,98 @@
|
|||
# Contributing Guidelines
|
||||
|
||||
This document contains information and guidelines about contributing to this project.
|
||||
Please read it before you start participating.
|
||||
|
||||
**Topics**
|
||||
|
||||
* [Asking Questions](#asking-questions)
|
||||
* [Reporting Security Issues](#reporting-security-issues)
|
||||
* [Reporting Non Security Issues](#reporting-other-issues)
|
||||
* [Developers Certificate of Origin](#developers-certificate-of-origin)
|
||||
|
||||
## Asking Questions
|
||||
|
||||
Questions are welcome! We encourage you to ask questions through GitHub issues.
|
||||
Before doing so, please check that the project issues database doesn't already
|
||||
include an answer to your question. Then open a new Issue and use the "Question"
|
||||
label.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you have discovered an issue with this code that could present a security hazard or wish to discuss a sensitive issue with our security team, please contact security@z.cash [security.asc](https://z.cash/gpg-pubkeys/security.asc). Key fingerprint = AF85 0445 546C 18B7 86F9 2C62 88FB 8B86 D8B5 A68C
|
||||
|
||||
## Reporting Non Security Issues
|
||||
|
||||
A great way to contribute to the project
|
||||
is to send a detailed issue when you encounter a problem.
|
||||
We always appreciate a well-written, thorough bug report.
|
||||
|
||||
Check that the project issues database
|
||||
doesn't already include that problem or suggestion before submitting an issue.
|
||||
If you find a match, add a quick "+1" or "I have this problem too."
|
||||
Doing this helps prioritize the most common problems and requests.
|
||||
|
||||
When reporting issues, please include the following:
|
||||
|
||||
* The Android API you're using
|
||||
* The device you're targeting
|
||||
* The full output of any stack trace or compiler error
|
||||
* A code snippet that reproduces the described behavior, if applicable
|
||||
* Any other details that would be useful in understanding the problem
|
||||
|
||||
This information will help us review and fix your issue faster.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
We **love** pull requests!
|
||||
|
||||
All contributions _will_ be licensed under the MIT license.
|
||||
|
||||
Code/comments should adhere to the following rules:
|
||||
|
||||
* Every Pull request must have an Issue associated to it. PRs with not
|
||||
associated with an Issue will be closed
|
||||
* Code build and Code Lint must pass.
|
||||
* Names should be descriptive and concise.
|
||||
* Although they are not mandatory, PRs that include significant testing will be
|
||||
prioritized.
|
||||
* All enhancements and bug fixes need to be documented in the CHANGELOG.
|
||||
* When writing comments, use properly constructed sentences, including
|
||||
punctuation.
|
||||
* When documenting APIs and/or source code, don't make assumptions or make
|
||||
implications about race, gender, religion, political orientation or anything
|
||||
else that isn't relevant to the project.
|
||||
* Remember that source code usually gets written once and read often: ensure
|
||||
the reader doesn't have to make guesses. Make sure that the purpose and inner
|
||||
logic are either obvious to a reasonably skilled professional, or add a
|
||||
comment that explains it.
|
||||
|
||||
## Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
- (a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
- (b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
- (c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
- (d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
|
||||
|
||||
|
||||
This contribution guide is inspired on great projects like [AlamoFire](https://github.com/Alamofire/Foundation/blob/master/CONTRIBUTING.md) and [CocoaPods](https://github.com/CocoaPods/CocoaPods/blob/master/CONTRIBUTING.md)
|
47
README.md
47
README.md
|
@ -1,2 +1,47 @@
|
|||
# zcash-android-wallet
|
||||
Android wallet using the Zcash Android SDK that is maintained by core developers.
|
||||
An Android wallet using the Zcash Android SDK that is maintained by ECC developers.
|
||||
|
||||
### Motivation
|
||||
[Dogfooding](https://en.wikipedia.org/wiki/Eating_your_own_dog_food) - _transitive verb_ - is the practice of an organization using its own product. This app was created to help us learn.
|
||||
|
||||
Please take note: the wallet is not an official product by ECC, but rather a tool for learning about our libraries that it is built on. This means that we do not have robust infrasturcture or user support for this application. We open sourced it as a resource to make wallet development easier for the Zcash ecosystem.
|
||||
|
||||
### Setup
|
||||
|
||||
To run, clone the repo, open it in Android Studio and press play. It should just work.™
|
||||
|
||||
#### Requirements
|
||||
- [The code](https://github.com/zcash/zcash-android-wallet)
|
||||
- [Android Studio](https://developer.android.com/studio/index.html) or [adb](https://www.xda-developers.com/what-is-adb/)
|
||||
- A device or emulator
|
||||
|
||||
### Install from Android Studio
|
||||
1. [Install Android studio](https://developer.android.com/studio/install) and setup an emulator
|
||||
1a. If using a device, be sure to [put it in developer mode](https://developer.android.com/studio/debug/dev-options) to enable side-loading apps
|
||||
2. `Import` the zcash-android-wallet folder.
|
||||
It will be recognized as an Android project.
|
||||
3. Press play (once it is done opening and indexing)
|
||||
|
||||
### OR Install from the command line
|
||||
To build from the command line, [setup ADB](https://www.xda-developers.com/install-adb-windows-macos-linux/) and connect your device. Then simply run this and it will both build and install the app:
|
||||
```bash
|
||||
cd /path/to/zcash-android-wallet
|
||||
./gradlew
|
||||
```
|
||||
|
||||
## Disclaimers
|
||||
There are some known areas for improvement:
|
||||
|
||||
- This app is mainly intended for learning and improving the related libraries that it uses. There may be bugs.
|
||||
- This wallet currently only supports receiving at shielded addresses, which makes it incompatible with wallets that do not support sending to shielded addresses.
|
||||
- Traffic analysis, like in other cryptocurrency wallets, can leak some privacy of the user.
|
||||
- The wallet requires a trust in the server to display accurate transaction information.
|
||||
- This app has been developed and run exclusively on `mainnet` it might not work on `testnet`.
|
||||
|
||||
See the [Wallet App Threat Model](https://zcash.readthedocs.io/en/latest/rtd_pages/wallet_threat_model.html)
|
||||
for more information about the security and privacy limitations of the wallet.
|
||||
|
||||
If you'd like to sign up to help us test, reach out on discord and let us know! We're always happy to get feedback!
|
||||
|
||||
### License
|
||||
MIT
|
||||
|
|
|
@ -4,13 +4,15 @@ apply plugin: 'com.android.application'
|
|||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
//apply plugin: 'com.github.ben-manes.versions'
|
||||
|
||||
archivesBaseName = 'zcash-android-wallet'
|
||||
group = 'cash.z.ecc.android'
|
||||
version = '1.0.0-alpha03'
|
||||
version = '1.0.0-alpha31'
|
||||
|
||||
android {
|
||||
ndkVersion "21.1.6352462"
|
||||
compileSdkVersion Deps.compileSdkVersion
|
||||
buildToolsVersion Deps.buildToolsVersion
|
||||
viewBinding.enabled = true
|
||||
|
@ -18,10 +20,12 @@ android {
|
|||
applicationId 'cash.z.ecc.android'
|
||||
minSdkVersion Deps.minSdkVersion
|
||||
targetSdkVersion Deps.targetSdkVersion
|
||||
versionCode = 1_00_00_003
|
||||
// last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
|
||||
versionCode = 1_00_00_031
|
||||
// last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX) dev(9XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
|
||||
versionName = "$version"
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
multiDexEnabled true
|
||||
}
|
||||
flavorDimensions 'network'
|
||||
productFlavors {
|
||||
|
@ -37,14 +41,25 @@ android {
|
|||
matchingFallbacks = ['zcashmainnet', 'release']
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
placeholder {
|
||||
storeFile file("${rootProject.projectDir}/placeholder.keystore")
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
storePassword "android"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
useProguard false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.placeholder
|
||||
}
|
||||
debug {
|
||||
minifyEnabled true
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
useProguard false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
@ -53,14 +68,7 @@ android {
|
|||
// matchingFallbacks = ['debug', 'release', 'zcashtestnet']
|
||||
// }
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
// storeFile file("debug.keystore")
|
||||
// keyAlias "androiddebugkey"
|
||||
// keyPassword "android"
|
||||
// storePassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
|
@ -68,7 +76,18 @@ android {
|
|||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg 'dagger.fastInit', 'enabled'
|
||||
arg 'dagger.fullBindingGraphValidation', 'ERROR'
|
||||
arg 'dagger.gradle.incremental'
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all {
|
||||
outputFileName = "$archivesBaseName-v${defaultConfig.versionName}-${variant.buildType.name}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -78,19 +97,38 @@ dependencies {
|
|||
implementation project(':mnemonic')
|
||||
implementation project(':lockbox')
|
||||
|
||||
// Zcash
|
||||
implementation Deps.Zcash.ANDROID_WALLET_PLUGINS
|
||||
zcashtestnetImplementation Deps.Zcash.Sdk.TESTNET
|
||||
zcashmainnetImplementation Deps.Zcash.Sdk.MAINNET
|
||||
|
||||
// Kotlin
|
||||
implementation Deps.Kotlin.STDLIB
|
||||
|
||||
// Android
|
||||
implementation Deps.AndroidX.ANNOTATION
|
||||
implementation Deps.AndroidX.APPCOMPAT
|
||||
implementation Deps.AndroidX.CORE_KTX
|
||||
implementation Deps.AndroidX.CONSTRAINT_LAYOUT
|
||||
implementation Deps.AndroidX.CORE_KTX
|
||||
implementation Deps.AndroidX.FRAGMENT_KTX
|
||||
implementation Deps.AndroidX.LEGACY
|
||||
implementation Deps.AndroidX.PAGING
|
||||
implementation Deps.AndroidX.CameraX.CAMERA2
|
||||
implementation Deps.AndroidX.CameraX.CORE
|
||||
implementation Deps.AndroidX.CameraX.LIFECYCLE
|
||||
implementation Deps.AndroidX.CameraX.View.EXT
|
||||
implementation Deps.AndroidX.CameraX.View.VIEW
|
||||
implementation Deps.AndroidX.Lifecycle.LIFECYCLE_EXTENSIONS
|
||||
implementation Deps.AndroidX.Lifecycle.LIFECYCLE_RUNTIME_KTX
|
||||
implementation Deps.AndroidX.Navigation.FRAGMENT_KTX
|
||||
implementation Deps.AndroidX.Navigation.UI_KTX
|
||||
implementation Deps.AndroidX.Room.ROOM_KTX
|
||||
kapt Deps.AndroidX.Room.ROOM_COMPILER
|
||||
|
||||
// Google
|
||||
implementation Deps.Google.GUAVA
|
||||
implementation Deps.Google.MATERIAL
|
||||
implementation Deps.Google.ML_VISION // QR Scanner
|
||||
|
||||
// Dagger
|
||||
implementation Deps.Dagger.ANDROID_SUPPORT
|
||||
|
@ -98,21 +136,22 @@ dependencies {
|
|||
kapt Deps.Dagger.COMPILER
|
||||
|
||||
// grpc-java
|
||||
implementation "io.grpc:grpc-okhttp:1.21.0"
|
||||
implementation "io.grpc:grpc-android:1.21.0"
|
||||
implementation "io.grpc:grpc-protobuf-lite:1.21.0"
|
||||
implementation "io.grpc:grpc-stub:1.21.0"
|
||||
implementation 'javax.annotation:javax.annotation-api:1.3.2'
|
||||
// solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
|
||||
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ
|
||||
implementation 'com.google.guava:guava:27.0.1-android'
|
||||
implementation Deps.Grpc.ANDROID
|
||||
implementation Deps.Grpc.OKHTTP
|
||||
implementation Deps.Grpc.PROTOBUG
|
||||
implementation Deps.Grpc.STUB
|
||||
implementation Deps.JavaX.JAVA_ANNOTATION
|
||||
|
||||
implementation 'com.mixpanel.android:mixpanel-android:5.6.3'
|
||||
// Misc.
|
||||
implementation Deps.Misc.LOTTIE
|
||||
implementation Deps.Misc.CHIPS
|
||||
|
||||
// Tests
|
||||
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
testImplementation Deps.Test.COROUTINES_TEST
|
||||
testImplementation Deps.Test.JUNIT
|
||||
testImplementation Deps.Test.MOKITO
|
||||
androidTestImplementation Deps.Test.Android.JUNIT
|
||||
androidTestImplementation Deps.Test.Android.ESPRESSO
|
||||
}
|
||||
|
||||
defaultTasks 'clean', 'installZcashmainnetRelease'
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "0",
|
||||
"firebase_url": "https://a.b.com",
|
||||
"project_id": "dummy",
|
||||
"storage_bucket": "dummy"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:8888888888888888888888",
|
||||
"android_client_info": {
|
||||
"package_name": "cash.z.ecc.android"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "dummy.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "dummy"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "dummy.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:8888888888888888888888",
|
||||
"android_client_info": {
|
||||
"package_name": "cash.z.ecc.android.testnet"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "dummy.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "dummy"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "dummy.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
|
@ -1,6 +1,12 @@
|
|||
-dontobfuscate
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Reports
|
||||
-printusage build/outputs/logs/R8-removed-code-report.txt
|
||||
-printseeds build/outputs/logs/R8-entry-points-report.txt
|
||||
|
||||
## Okio
|
||||
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
||||
|
||||
#-keep class cash.z.** { *; }
|
|
@ -3,16 +3,116 @@ package cash.z.ecc.android.integration
|
|||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import okio.Buffer
|
||||
import okio.GzipSink
|
||||
import okio.Okio
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IntegrationTest {
|
||||
|
||||
private lateinit var appContext: Context
|
||||
private val mnemonics = Mnemonics()
|
||||
private val phrase =
|
||||
"human pulse approve subway climb stairs mind gentle raccoon warfare fog roast sponsor" +
|
||||
" under absorb spirit hurdle animal original honey owner upper empower describe"
|
||||
|
||||
@Before
|
||||
fun start() {
|
||||
appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSeed_generation() {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
assertEquals(
|
||||
"Generated incorrect BIP-39 seed!",
|
||||
"f4e3d38d9c244da7d0407e19a93c80429614ee82dcf62c141235751c9f1228905d12a1f275f" +
|
||||
"5c22f6fb7fcd9e0a97f1676e0eec53fdeeeafe8ce8aa39639b9fe",
|
||||
seed.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSeed_storage() {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
val lb = LockBox(appContext)
|
||||
lb.setBytes("seed", seed)
|
||||
assertTrue(seed.contentEquals(lb.getBytes("seed")!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPhrase_storage() {
|
||||
val lb = LockBox(appContext)
|
||||
val phraseChars = phrase.toCharArray()
|
||||
lb.setCharsUtf8("phrase", phraseChars)
|
||||
assertTrue(phraseChars.contentEquals(lb.getCharsUtf8("phrase")!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPhrase_maxLengthStorage() {
|
||||
val lb = LockBox(appContext)
|
||||
// find and expose the max length
|
||||
var acceptedSize = 256
|
||||
while (acceptedSize > 0) {
|
||||
try {
|
||||
lb.setCharsUtf8("temp", nextString(acceptedSize).toCharArray())
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
acceptedSize--
|
||||
}
|
||||
|
||||
val maxSeedPhraseLength = 8 * 24 + 23 //215 (max length of each word is 8)
|
||||
assertTrue(
|
||||
"LockBox does not support the maximum length seed phrase." +
|
||||
" Expected: $maxSeedPhraseLength but was: $acceptedSize",
|
||||
acceptedSize > maxSeedPhraseLength
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAddress() {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
val initializer = Initializer(appContext).apply {
|
||||
new(seed, Initializer.DefaultBirthdayStore(appContext).newWalletBirthday, overwrite = true)
|
||||
}
|
||||
assertEquals(
|
||||
"Generated incorrect z-address!",
|
||||
"zs1gn2ah0zqhsxnrqwuvwmgxpl5h3ha033qexhsz8tems53fw877f4gug353eefd6z8z3n4zxty65c",
|
||||
initializer.rustBackend.getAddress()
|
||||
)
|
||||
initializer.clear()
|
||||
}
|
||||
|
||||
|
||||
private fun ByteArray.toHex(): String {
|
||||
val sb = StringBuilder(size * 2)
|
||||
for (b in this)
|
||||
sb.append(String.format("%02x", b))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun String.gzip(): ByteArray {
|
||||
val result = Buffer()
|
||||
val sink = Okio.buffer(GzipSink(result))
|
||||
sink.use {
|
||||
sink.write(toByteArray())
|
||||
}
|
||||
return result.readByteArray()
|
||||
}
|
||||
|
||||
fun nextString(length: Int): String {
|
||||
val allowedChars = "ACGT"
|
||||
return (1..length)
|
||||
.map { allowedChars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
package="cash.z.ecc.android">
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name="cash.z.ecc.android.ZcashWalletApp"
|
||||
|
@ -12,21 +13,26 @@
|
|||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/ZcashTheme">
|
||||
<activity android:name=".ui.MainActivity">
|
||||
<activity android:name=".ui.MainActivity" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Mixpanel options -->
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.AutoShowMixpanelUpdates" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.EnableDebugLogging" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableDecideChecker" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableEmulatorBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableGestureBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableViewCrawler" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.IgnoreInvisibleViewsVisualEditor" android:value="true" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="cash.z.ecc.android.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
android:writePermission="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Firebase options -->
|
||||
<meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="barcode" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -1,36 +1,52 @@
|
|||
package cash.z.ecc.android
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import cash.z.ecc.android.di.DaggerAppComponent
|
||||
import androidx.camera.camera2.Camera2Config
|
||||
import androidx.camera.core.CameraXConfig
|
||||
import cash.z.ecc.android.di.component.AppComponent
|
||||
import cash.z.ecc.android.di.component.DaggerAppComponent
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DaggerApplication
|
||||
import cash.z.ecc.android.sdk.ext.SilentTwig
|
||||
import cash.z.ecc.android.sdk.ext.TroubleshootingTwig
|
||||
import cash.z.ecc.android.sdk.ext.Twig
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.*
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class ZcashWalletApp : DaggerApplication() {
|
||||
class ZcashWalletApp : Application(), CameraXConfig.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var coordinator: FeedbackCoordinator
|
||||
|
||||
var creationTime: Long = 0
|
||||
private set
|
||||
|
||||
var creationMeasured: Boolean = false
|
||||
|
||||
/**
|
||||
* Intentionally private Scope for use with launching Feedback jobs. The feedback object has the
|
||||
* longest scope in the app because it needs to be around early in order to measure launch times
|
||||
* and stick around late in order to catch crashes. We intentionally don't expose this because
|
||||
* application objects can have odd lifecycles, given that there is no clear onDestroy moment in
|
||||
* many cases.
|
||||
*/
|
||||
private var feedbackScope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate() {
|
||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler()))
|
||||
creationTime = System.currentTimeMillis()
|
||||
instance = this
|
||||
// Setup handler for uncaught exceptions.
|
||||
super.onCreate()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler()))
|
||||
// Twig.plant(TroubleshootingTwig())
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement the HasActivityInjector behavior so that dagger knows which [AndroidInjector] to use.
|
||||
*/
|
||||
override fun applicationInjector(): AndroidInjector<out DaggerApplication> {
|
||||
return DaggerAppComponent.factory().create(this)
|
||||
component = DaggerAppComponent.factory().create(this)
|
||||
component.inject(this)
|
||||
feedbackScope.launch {
|
||||
coordinator.feedback.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
|
@ -38,15 +54,33 @@ class ZcashWalletApp : DaggerApplication() {
|
|||
// MultiDex.install(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: ZcashWalletApp
|
||||
override fun getCameraXConfig(): CameraXConfig {
|
||||
return Camera2Config.defaultConfig()
|
||||
}
|
||||
|
||||
class ExceptionReporter(val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler {
|
||||
companion object {
|
||||
lateinit var instance: ZcashWalletApp
|
||||
lateinit var component: AppComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* @param feedbackCoordinator inject a provider so that if a crash happens before configuration
|
||||
* is complete, we can lazily initialize all the feedback objects at this moment so that we
|
||||
* don't have to add any time to startup.
|
||||
*/
|
||||
inner class ExceptionReporter(private val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(t: Thread?, e: Throwable?) {
|
||||
// trackCrash(e, "Top-level exception wasn't caught by anything else!")
|
||||
// Analytics.clear()
|
||||
twig("Uncaught Exception: $e caused by: ${e?.cause}")
|
||||
// these are the only reported crashes that are considered fatal
|
||||
coordinator.feedback.report(e, true)
|
||||
coordinator.flush()
|
||||
// can do this if necessary but first verify that we need it
|
||||
runBlocking {
|
||||
coordinator.await()
|
||||
coordinator.feedback.stop()
|
||||
}
|
||||
ogHandler.uncaughtException(t, e)
|
||||
Thread.sleep(2000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
package cash.z.ecc.android.di
|
||||
|
||||
import dagger.Module
|
||||
|
||||
@Module
|
||||
abstract class AppBindingModule {
|
||||
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
package cash.z.ecc.android.di
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.ui.MainActivityModule
|
||||
import cash.z.ecc.android.ui.detail.WalletDetailFragmentModule
|
||||
import cash.z.ecc.android.ui.home.HomeFragmentModule
|
||||
import cash.z.ecc.android.ui.receive.ReceiveFragmentModule
|
||||
import cash.z.ecc.android.ui.send.SendFragmentModule
|
||||
import cash.z.ecc.android.ui.setup.BackupFragmentModule
|
||||
import cash.z.ecc.android.ui.setup.LandingFragmentModule
|
||||
import dagger.BindsInstance
|
||||
import dagger.Component
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.support.AndroidSupportInjectionModule
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Component(
|
||||
modules = [
|
||||
AndroidSupportInjectionModule::class,
|
||||
|
||||
AppModule::class,
|
||||
|
||||
// Activities
|
||||
MainActivityModule::class,
|
||||
|
||||
// Fragments
|
||||
HomeFragmentModule::class,
|
||||
ReceiveFragmentModule::class,
|
||||
SendFragmentModule::class,
|
||||
WalletDetailFragmentModule::class,
|
||||
LandingFragmentModule::class,
|
||||
BackupFragmentModule::class
|
||||
]
|
||||
)
|
||||
interface AppComponent : AndroidInjector<ZcashWalletApp> {
|
||||
|
||||
@Component.Factory
|
||||
interface Factory {
|
||||
fun create(@BindsInstance application: ZcashWalletApp): AppComponent
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
package cash.z.ecc.android.di
|
||||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module(includes = [AppBindingModule::class, ViewModelModule::class])
|
||||
class AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppContext(): Context = ZcashWalletApp.instance
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package cash.z.ecc.android.di.annotation
|
||||
|
||||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class SynchronizerScope
|
|
@ -0,0 +1,23 @@
|
|||
package cash.z.ecc.android.di.component
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.module.AppModule
|
||||
import dagger.BindsInstance
|
||||
import dagger.Component
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Component(modules = [AppModule::class])
|
||||
interface AppComponent {
|
||||
fun inject(zcashWalletApp: ZcashWalletApp)
|
||||
|
||||
// Subcomponents
|
||||
fun mainActivitySubcomponent(): MainActivitySubcomponent.Factory
|
||||
fun synchronizerSubcomponent(): SynchronizerSubcomponent.Factory
|
||||
fun initializerSubcomponent(): InitializerSubcomponent.Factory
|
||||
|
||||
@Component.Factory
|
||||
interface Factory {
|
||||
fun create(@BindsInstance application: ZcashWalletApp): AppComponent
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package cash.z.ecc.android.di.component
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.annotation.ActivityScope
|
||||
import cash.z.ecc.android.di.annotation.SynchronizerScope
|
||||
import cash.z.ecc.android.di.module.InitializerModule
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import dagger.BindsInstance
|
||||
import dagger.Subcomponent
|
||||
|
||||
@SynchronizerScope
|
||||
@Subcomponent(modules = [InitializerModule::class])
|
||||
interface InitializerSubcomponent {
|
||||
|
||||
fun initializer(): Initializer
|
||||
fun birthdayStore(): Initializer.WalletBirthdayStore
|
||||
|
||||
@Subcomponent.Factory
|
||||
interface Factory {
|
||||
fun create(@BindsInstance birthdayStore: Initializer.WalletBirthdayStore = Initializer.DefaultBirthdayStore(ZcashWalletApp.instance)): InitializerSubcomponent
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package cash.z.ecc.android.di.component
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.di.annotation.ActivityScope
|
||||
import cash.z.ecc.android.di.module.MainActivityModule
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import dagger.BindsInstance
|
||||
import dagger.Subcomponent
|
||||
import javax.inject.Named
|
||||
|
||||
@ActivityScope
|
||||
@Subcomponent(modules = [MainActivityModule::class])
|
||||
interface MainActivitySubcomponent {
|
||||
|
||||
fun inject(activity: MainActivity)
|
||||
|
||||
@Named("BeforeSynchronizer") fun viewModelFactory(): ViewModelProvider.Factory
|
||||
|
||||
@Subcomponent.Factory
|
||||
interface Factory {
|
||||
fun create(@BindsInstance activity: FragmentActivity): MainActivitySubcomponent
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package cash.z.ecc.android.di.component
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.di.annotation.SynchronizerScope
|
||||
import cash.z.ecc.android.di.module.SynchronizerModule
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import dagger.BindsInstance
|
||||
import dagger.Subcomponent
|
||||
import javax.inject.Named
|
||||
|
||||
@SynchronizerScope
|
||||
@Subcomponent(modules = [SynchronizerModule::class])
|
||||
interface SynchronizerSubcomponent {
|
||||
|
||||
fun synchronizer(): Synchronizer
|
||||
|
||||
@Named("Synchronizer") fun viewModelFactory(): ViewModelProvider.Factory
|
||||
|
||||
@Subcomponent.Factory
|
||||
interface Factory {
|
||||
fun create(@BindsInstance initializer: Initializer): SynchronizerSubcomponent
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.component.MainActivitySubcomponent
|
||||
import cash.z.ecc.android.feedback.*
|
||||
import cash.z.ecc.android.sdk.ext.SilentTwig
|
||||
import cash.z.ecc.android.sdk.ext.TroubleshootingTwig
|
||||
import cash.z.ecc.android.sdk.ext.Twig
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.multibindings.IntoSet
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module(subcomponents = [MainActivitySubcomponent::class])
|
||||
class AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppContext(): Context = ZcashWalletApp.instance
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideClipboard(context: Context) =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
||||
|
||||
//
|
||||
// Feedback
|
||||
//
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferences(context: Context): SharedPreferences
|
||||
= context.getSharedPreferences("Application", Context.MODE_PRIVATE)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeedback(): Feedback = Feedback()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeedbackCoordinator(
|
||||
feedback: Feedback,
|
||||
preferences: SharedPreferences,
|
||||
defaultObservers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
|
||||
): FeedbackCoordinator {
|
||||
return preferences.getBoolean(FeedbackCoordinator.ENABLED, true).let { isEnabled ->
|
||||
// observe nothing unless feedback is enabled
|
||||
Twig.plant(if (isEnabled) TroubleshootingTwig() else SilentTwig())
|
||||
FeedbackCoordinator(feedback, if (isEnabled) defaultObservers else setOf())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Default Feedback Observer Set
|
||||
//
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IntoSet
|
||||
fun provideFeedbackFile(): FeedbackCoordinator.FeedbackObserver = FeedbackFile()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IntoSet
|
||||
fun provideFeedbackConsole(): FeedbackCoordinator.FeedbackObserver = FeedbackConsole()
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.Reusable
|
||||
|
||||
@Module
|
||||
class InitializerModule {
|
||||
private val host = "lightwalletd.z.cash"
|
||||
private val port = 9067
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun provideInitializer(appContext: Context) = Initializer(appContext, host, port)
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
import cash.z.ecc.android.di.component.InitializerSubcomponent
|
||||
import cash.z.ecc.android.di.component.SynchronizerSubcomponent
|
||||
import dagger.Module
|
||||
|
||||
@Module(includes = [ViewModelsActivityModule::class], subcomponents = [SynchronizerSubcomponent::class, InitializerSubcomponent::class])
|
||||
class MainActivityModule {
|
||||
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.android.di.annotation.SynchronizerScope
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
/**
|
||||
* Module that creates the synchronizer from an initializer and also everything that depends on the
|
||||
* synchronizer (because it doesn't exist prior to this module being installed).
|
||||
*/
|
||||
@Module(includes = [ViewModelsSynchronizerModule::class])
|
||||
class SynchronizerModule {
|
||||
|
||||
@Provides
|
||||
@SynchronizerScope
|
||||
fun provideSynchronizer(appContext: Context, initializer: Initializer): Synchronizer {
|
||||
return Synchronizer(initializer)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.di.annotation.ActivityScope
|
||||
import cash.z.ecc.android.di.annotation.ViewModelKey
|
||||
import cash.z.ecc.android.di.viewmodel.ViewModelFactory
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Named
|
||||
|
||||
/**
|
||||
* View model related objects, scoped to the activity that do not depend on the Synchronizer. These
|
||||
* are any VMs that must be created before the Synchronizer.
|
||||
*/
|
||||
@Module
|
||||
abstract class ViewModelsActivityModule {
|
||||
|
||||
@ActivityScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(WalletSetupViewModel::class)
|
||||
abstract fun bindWalletSetupViewModel(implementation: WalletSetupViewModel): ViewModel
|
||||
|
||||
|
||||
/**
|
||||
* Factory for view models that are created until before the Synchronizer exists. This is a
|
||||
* little tricky because we cannot make them all in one place or else they won't be available
|
||||
* to both the parent and the child components. If they all live in the child component, which
|
||||
* isn't created until the synchronizer exists, then the parent component will not have the
|
||||
* view models yet.
|
||||
*/
|
||||
@ActivityScope
|
||||
@Named("BeforeSynchronizer")
|
||||
@Binds
|
||||
abstract fun bindViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory
|
||||
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package cash.z.ecc.android.di.module
|
||||
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.di.annotation.SynchronizerScope
|
||||
import cash.z.ecc.android.di.annotation.ViewModelKey
|
||||
import cash.z.ecc.android.di.viewmodel.ViewModelFactory
|
||||
import cash.z.ecc.android.ui.detail.WalletDetailViewModel
|
||||
import cash.z.ecc.android.ui.home.HomeViewModel
|
||||
import cash.z.ecc.android.ui.profile.ProfileViewModel
|
||||
import cash.z.ecc.android.ui.receive.ReceiveViewModel
|
||||
import cash.z.ecc.android.ui.scan.ScanViewModel
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Named
|
||||
|
||||
/**
|
||||
* View model related objects, scoped to the synchronizer.
|
||||
*/
|
||||
@Module
|
||||
abstract class ViewModelsSynchronizerModule {
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(HomeViewModel::class)
|
||||
abstract fun bindHomeViewModel(implementation: HomeViewModel): ViewModel
|
||||
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(SendViewModel::class)
|
||||
abstract fun bindSendViewModel(implementation: SendViewModel): ViewModel
|
||||
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(WalletDetailViewModel::class)
|
||||
abstract fun bindWalletDetailViewModel(implementation: WalletDetailViewModel): ViewModel
|
||||
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ReceiveViewModel::class)
|
||||
abstract fun bindReceiveViewModel(implementation: ReceiveViewModel): ViewModel
|
||||
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ScanViewModel::class)
|
||||
abstract fun bindScanViewModel(implementation: ScanViewModel): ViewModel
|
||||
|
||||
@SynchronizerScope
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ProfileViewModel::class)
|
||||
abstract fun bindProfileViewModel(implementation: ProfileViewModel): ViewModel
|
||||
|
||||
/**
|
||||
* Factory for view models that are not created until the Synchronizer exists. Only VMs that
|
||||
* require the Synchronizer should wait until it is created. In other words, these are the VMs
|
||||
* that live within the scope of the Synchronizer.
|
||||
*/
|
||||
@SynchronizerScope
|
||||
@Named("Synchronizer")
|
||||
@Binds
|
||||
abstract fun bindViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package cash.z.ecc.android.di.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
|
||||
inline fun <reified VM : ViewModel> BaseFragment<*>.viewModel() = object : Lazy<VM> {
|
||||
val cached: VM? = null
|
||||
override fun isInitialized(): Boolean = cached != null
|
||||
override val value: VM
|
||||
get() = cached
|
||||
?: ViewModelProvider(this@viewModel, scopedFactory<VM>())[VM::class.java]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a view model that is scoped to the lifecycle of the activity.
|
||||
*
|
||||
* @param isSynchronizerScope true when this view model depends on the Synchronizer. False when this
|
||||
* viewModel needs to be created before the synchronizer or otherwise has no dependency on it being
|
||||
* available for use.
|
||||
*/
|
||||
inline fun <reified VM : ViewModel> BaseFragment<*>.activityViewModel(isSynchronizerScope: Boolean = true) = object : Lazy<VM> {
|
||||
val cached: VM? = null
|
||||
override fun isInitialized(): Boolean = cached != null
|
||||
override val value: VM
|
||||
get() {
|
||||
return cached
|
||||
?: scopedFactory<VM>(isSynchronizerScope)?.let { factory ->
|
||||
ViewModelProvider(this@activityViewModel.mainActivity!!, factory)[VM::class.java]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified VM : ViewModel> BaseFragment<*>.scopedFactory(isSynchronizerScope: Boolean = true): ViewModelProvider.Factory {
|
||||
val factory = if (isSynchronizerScope) mainActivity?.synchronizerComponent?.viewModelFactory() else mainActivity?.component?.viewModelFactory()
|
||||
return factory ?: throw IllegalStateException("Error: mainActivity should not be null by the time the ${VM::class.java.simpleName} viewmodel is lazily accessed!")
|
||||
}
|
|
@ -1,29 +1,10 @@
|
|||
package cash.z.ecc.android.di
|
||||
package cash.z.ecc.android.di.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import cash.z.ecc.android.di.annotation.ViewModelKey
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
abstract class ViewModelModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindViewModelFactory(implementation: ViewModelFactory): ViewModelProvider.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(WalletSetupViewModel::class)
|
||||
abstract fun bindWalletSetupViewModel(implementation: WalletSetupViewModel): ViewModel
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ViewModelFactory @Inject constructor(
|
||||
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
|
||||
) : ViewModelProvider.Factory {
|
||||
|
@ -31,8 +12,8 @@ class ViewModelFactory @Inject constructor(
|
|||
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
|
||||
modelClass.isAssignableFrom(it.key)
|
||||
}?.value ?: throw IllegalArgumentException(
|
||||
"No map entry found for ${modelClass.canonicalName}." +
|
||||
" Verify that this ViewModel has been added to the ViewModelModule."
|
||||
"No map entry found for ${modelClass.canonicalName}. Verify that this ViewModel has" +
|
||||
" been added to the ViewModelModule. ${creators.keys}"
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return creator.get() as T
|
|
@ -0,0 +1,97 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.getSystemService
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
|
||||
fun Context.showClearDataConfirmation(onDismiss: () -> Unit = {}, onCancel: () -> Unit = {}): Dialog {
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Nuke Wallet?")
|
||||
.setMessage("WARNING: Potential Loss of Funds\n\nClearing all wallet data and can result in a loss of funds, if you cannot locate your correct seed phrase.\n\nPlease confirm that you have your 24-word seed phrase available before proceeding.")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Cancel") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onDismiss()
|
||||
onCancel()
|
||||
}
|
||||
.setNegativeButton("Erase Wallet") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onDismiss()
|
||||
getSystemService<ActivityManager>()?.clearApplicationUserData()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun Context.showUninitializedError(error: Throwable? = null, onDismiss: () -> Unit = {}): Dialog {
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Wallet Improperly Initialized")
|
||||
.setMessage("This wallet has not been initialized correctly! Perhaps an error occurred during install.\n\nThis can be fixed with a reset. First, locate your backup seed phrase, then CLEAR DATA and reimport it.")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onDismiss()
|
||||
if (error != null) throw error
|
||||
}
|
||||
.setNegativeButton("Clear Data") { dialog, _ ->
|
||||
showClearDataConfirmation(onDismiss, onCancel = {
|
||||
// do not let the user back into the app because we cannot recover from this case
|
||||
showUninitializedError(error, onDismiss)
|
||||
})
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun Context.showInvalidSeedPhraseError(error: Throwable? = null, onDismiss: () -> Unit = {}): Dialog {
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Oops! Invalid Seed Phrase")
|
||||
.setMessage("That seed phrase appears to be invalid! Please double-check it and try again.\n\n${error?.message ?: ""}")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Retry") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun Context.showScanFailure(error: Throwable?, onCancel: () -> Unit = {}, onDismiss: () -> Unit = {}): Dialog {
|
||||
val message = if (error == null) {
|
||||
"Unknown error"
|
||||
} else {
|
||||
"${error.message}${if (error.cause != null) "\n\nCaused by: ${error.cause}" else ""}"
|
||||
}
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Scan Failure")
|
||||
.setMessage(message)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Retry") { d, _ ->
|
||||
d.dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
.setNegativeButton("Ignore") { d, _ ->
|
||||
d.dismiss()
|
||||
onCancel()
|
||||
onDismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun Context.showCriticalProcessorError(error: Throwable?, onRetry: () -> Unit = {}): Dialog {
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Processor Error")
|
||||
.setMessage(error?.message ?: "Critical error while processing blocks!")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Retry") { d, _ ->
|
||||
d.dismiss()
|
||||
onRetry()
|
||||
}
|
||||
.setNegativeButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
throw error ?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
|
||||
}
|
||||
.show()
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
import android.view.inputmethod.EditorInfo.IME_ACTION_DONE
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.safelyConvertToBigDecimal
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
|
||||
fun EditText.onEditorActionDone(block: (EditText) -> Unit) {
|
||||
this.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == IME_ACTION_DONE) {
|
||||
block(this)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun TextView.convertZecToZatoshi(): Long? {
|
||||
return try {
|
||||
text.toString().safelyConvertToBigDecimal()?.convertZecToZatoshi() ?: null
|
||||
} catch (t: Throwable) {
|
||||
twig("Failed to convert text to Zatoshi: $text")
|
||||
null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
fun Boolean.asString(ifTrue: String = "", ifFalse: String = "") = if(this) ifTrue else ifFalse
|
|
@ -1,5 +1,6 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.IntegerRes
|
||||
|
@ -28,3 +29,9 @@ internal inline fun @receiver:StringRes Int.toAppString(): String {
|
|||
internal inline fun @receiver:IntegerRes Int.toAppInt(): Int {
|
||||
return ZcashWalletApp.instance.resources.getInteger(this)}
|
||||
|
||||
|
||||
fun Float.toPx() = this * Resources.getSystem().displayMetrics.density
|
||||
|
||||
fun Int.toPx() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
fun Int.toDp() = (this / Resources.getSystem().displayMetrics.density + 0.5f).toInt()
|
|
@ -0,0 +1,13 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.text.toSpannable
|
||||
|
||||
fun CharSequence.toColoredSpan(colorResId: Int, coloredPortion: String): Spannable {
|
||||
return toSpannable().apply {
|
||||
val start = this@toColoredSpan.indexOf(coloredPortion)
|
||||
setSpan(ForegroundColorSpan(colorResId.toAppColor()), start, start + coloredPortion.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
|
@ -6,6 +6,10 @@ import cash.z.ecc.android.ui.MainActivity
|
|||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
|
||||
fun View.gone() = goneIf(true)
|
||||
|
||||
fun View.invisible() = invisibleIf(true)
|
||||
|
||||
fun View.goneIf(isGone: Boolean) {
|
||||
visibility = if (isGone) GONE else VISIBLE
|
||||
}
|
||||
|
@ -14,16 +18,26 @@ fun View.invisibleIf(isInvisible: Boolean) {
|
|||
visibility = if (isInvisible) INVISIBLE else VISIBLE
|
||||
}
|
||||
|
||||
fun View.onClickNavTo(navResId: Int) {
|
||||
fun View.disabledIf(isDisabled: Boolean) {
|
||||
isEnabled = !isDisabled
|
||||
}
|
||||
|
||||
fun View.transparentIf(isTransparent: Boolean) {
|
||||
alpha = if (isTransparent) 0.0f else 1.0f
|
||||
}
|
||||
|
||||
fun View.onClickNavTo(navResId: Int, block: (() -> Any) = {}) {
|
||||
setOnClickListener {
|
||||
(context as? MainActivity)?.navController?.navigate(navResId)
|
||||
block()
|
||||
(context as? MainActivity)?.safeNavigate(navResId)
|
||||
?: throw IllegalStateException("Cannot navigate from this activity. " +
|
||||
"Expected MainActivity but found ${context.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
fun View.onClickNavUp() {
|
||||
fun View.onClickNavUp(block: (() -> Any) = {}) {
|
||||
setOnClickListener {
|
||||
block()
|
||||
(context as? MainActivity)?.navController?.navigateUp()
|
||||
?: throw IllegalStateException(
|
||||
"Cannot navigate from this activity. " +
|
||||
|
@ -32,6 +46,17 @@ fun View.onClickNavUp() {
|
|||
}
|
||||
}
|
||||
|
||||
fun View.onClickNavBack(block: (() -> Any) = {}) {
|
||||
setOnClickListener {
|
||||
block()
|
||||
(context as? MainActivity)?.navController?.popBackStack()
|
||||
?: throw IllegalStateException(
|
||||
"Cannot navigate from this activity. " +
|
||||
"Expected MainActivity but found ${context.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.clicks() = channelFlow<View> {
|
||||
setOnClickListener {
|
||||
offer(this@clicks)
|
||||
|
|
|
@ -5,12 +5,15 @@ import okio.Okio
|
|||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
class FeedbackFile(fileName: String = "feedback.log") :
|
||||
class FeedbackFile(fileName: String = "user_log.txt") :
|
||||
FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
val file = File(ZcashWalletApp.instance.noBackupFilesDir, fileName)
|
||||
val file = File("${ZcashWalletApp.instance.filesDir}/logs", fileName)
|
||||
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
||||
|
||||
init {
|
||||
if (!file.parentFile.exists()) file.parentFile.mkdirs()
|
||||
}
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
appendToFile(metric.toString())
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.ext.toAppString
|
||||
import com.mixpanel.android.mpmetrics.MixpanelAPI
|
||||
|
||||
class FeedbackMixpanel : FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
private val mixpanel =
|
||||
MixpanelAPI.getInstance(ZcashWalletApp.instance, R.string.mixpanel_project.toAppString())
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
track(metric.key, metric.toMap())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
track(action.key, action.toMap())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
mixpanel.flush()
|
||||
}
|
||||
|
||||
private fun track(eventName: String, properties: Map<String, Any>) {
|
||||
mixpanel.trackMap(eventName, properties)
|
||||
}
|
||||
|
||||
}
|
|
@ -2,17 +2,197 @@ package cash.z.ecc.android.feedback
|
|||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
|
||||
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
|
||||
FEEDBACK_STARTED("action.feedback.start", "feedback started"),
|
||||
FEEDBACK_STOPPED("action.feedback.stop", "feedback stopped");
|
||||
object Report {
|
||||
|
||||
override fun toString(): String = description
|
||||
}
|
||||
|
||||
enum class MetricType(override val key: String, val description: String) : Feedback.Action {
|
||||
SEED_CREATION("metric.seed.creation", "seed created")
|
||||
object Funnel {
|
||||
sealed class Send(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("send", stepName, step, *properties) {
|
||||
object AddressPageComplete : Send("addresspagecomplete", 10)
|
||||
object MemoPageComplete : Send("memopagecomplete", 20)
|
||||
object ConfirmPageComplete : Send("confirmpagecomplete", 30)
|
||||
|
||||
// Beginning of send
|
||||
object SendSelected : Send("sendselected", 50)
|
||||
object SpendingKeyFound : Send("keyfound", 60)
|
||||
object Creating : Send("creating", 70)
|
||||
class Created(id: Long) : Send("created", 80, "id" to id)
|
||||
object Submitted : Send("submitted", 90)
|
||||
class Mined(minedHeight: Int) : Send("mined", 100, "minedHeight" to minedHeight)
|
||||
|
||||
// Errors
|
||||
abstract class Error(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Send("error.$stepName", step, "isError" to true, *properties)
|
||||
object ErrorNotFound : Error("notfound", 51)
|
||||
class ErrorEncoding(errorCode: Int? = null, errorMessage: String? = null) : Error("encode", 71,
|
||||
"errorCode" to (errorCode ?: -1),
|
||||
"errorMessage" to (errorMessage ?: "None")
|
||||
)
|
||||
class ErrorSubmitting(errorCode: Int? = null, errorMessage: String? = null) : Error("submit", 81,
|
||||
"errorCode" to (errorCode ?: -1),
|
||||
"errorMessage" to (errorMessage ?: "None")
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Restore(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("restore", stepName, step, *properties) {
|
||||
object Initiated : Restore("initiated", 0)
|
||||
object SeedWordsStarted : Restore("wordsstarted", 10)
|
||||
class SeedWordCount(wordCount: Int) : Restore("wordsmodified", 15, "seedWordCount" to wordCount)
|
||||
object SeedWordsCompleted : Restore("wordscompleted", 20)
|
||||
object Stay : Restore("stay", 21)
|
||||
object Exit : Restore("stay", 22)
|
||||
object Done : Restore("doneselected", 30)
|
||||
object ImportStarted : Restore("importstarted", 40)
|
||||
object ImportCompleted : Restore("importcompleted", 50)
|
||||
object Success : Restore("success", 100)
|
||||
}
|
||||
|
||||
sealed class UserFeedback(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("feedback", stepName, step, *properties) {
|
||||
object Started : UserFeedback("started", 0)
|
||||
object Cancelled : UserFeedback("cancelled", 1)
|
||||
class Submitted(rating: Int, question1: String, question2: String, question3: String) : UserFeedback("submitted", 100, "rating" to rating, "question1" to question1, "question2" to question2, "question3" to question3)
|
||||
}
|
||||
}
|
||||
|
||||
object Error {
|
||||
object NonFatal {
|
||||
class Reorg(errorBlockHeight: Int, rewindBlockHeight: Int) : Feedback.AppError(
|
||||
"reorg",
|
||||
"Chain error detected at height $errorBlockHeight, rewinding to $rewindBlockHeight",
|
||||
false,
|
||||
"errorHeight" to errorBlockHeight,
|
||||
"rewindHeight" to rewindBlockHeight
|
||||
) {
|
||||
val errorHeight: Int by propertyMap
|
||||
val rewindHeight: Int by propertyMap
|
||||
}
|
||||
class TxUpdateFailed(t: Throwable) : Feedback.AppError("txupdate", t, false)
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder for things that we want to monitor
|
||||
sealed class Issue(name: String, vararg properties: Pair<String, Any>) : Feedback.MappedAction(
|
||||
"issueName" to name,
|
||||
"isIssue" to true,
|
||||
*properties
|
||||
) {
|
||||
override val key = "issue.$name"
|
||||
override fun toString() = "occurrence of ${key.replace('.', ' ')}"
|
||||
|
||||
// Issues with sending worth monitoring
|
||||
object SelfSend : Issue("self.send")
|
||||
object TinyAmount : Issue("tiny.amount")
|
||||
object MicroAmount : Issue("micro.amount")
|
||||
object MinimumAmount : Issue("minimum.amount")
|
||||
class TruncatedMemo(memoSize: Int) : Issue("truncated.memo", "memoSize" to memoSize)
|
||||
class LargeMemo(memoSize: Int) : Issue("large.memo", "memoSize" to memoSize)
|
||||
}
|
||||
|
||||
enum class Screen(val id: String? = null) : Feedback.Action {
|
||||
BACKUP,
|
||||
HOME,
|
||||
DETAIL("wallet.detail"),
|
||||
LANDING,
|
||||
PROFILE,
|
||||
FEEDBACK,
|
||||
RECEIVE,
|
||||
RESTORE,
|
||||
SCAN,
|
||||
SEND_ADDRESS("send.address"),
|
||||
SEND_CONFIRM("send.confirm"),
|
||||
SEND_FINAL("send.final"),
|
||||
SEND_MEMO("send.memo");
|
||||
|
||||
override val key = "screen.${id ?: name.toLowerCase()}"
|
||||
override fun toString() = "viewed the ${key.substring(7).replace('.', ' ')} screen"
|
||||
}
|
||||
|
||||
enum class Tap(val id: String) : Feedback.Action {
|
||||
BACKUP_DONE("backup.done"),
|
||||
BACKUP_VERIFY("backup.verify"),
|
||||
DEVELOPER_WALLET_PROMPT("landing.devwallet.prompt"),
|
||||
DEVELOPER_WALLET_IMPORT("landing.devwallet.import"),
|
||||
DEVELOPER_WALLET_CANCEL("landing.devwallet.cancel"),
|
||||
LANDING_RESTORE("landing.restore"),
|
||||
LANDING_NEW("landing.new"),
|
||||
LANDING_BACKUP("landing.backup"),
|
||||
LANDING_BACKUP_SKIPPED_1("landing.backup.skip.1"),
|
||||
LANDING_BACKUP_SKIPPED_2("landing.backup.skip.2"),
|
||||
LANDING_BACKUP_SKIPPED_3("landing.backup.skip.3"),
|
||||
HOME_PROFILE("home.profile"),
|
||||
HOME_DETAIL("home.detail"),
|
||||
HOME_SCAN("home.scan"),
|
||||
HOME_SEND("home.send"),
|
||||
HOME_FUND_NOW("home.fund.now"),
|
||||
HOME_CLEAR_AMOUNT("home.clear.amount"),
|
||||
DETAIL_BACK("detail.back"),
|
||||
PROFILE_CLOSE("profile.close"),
|
||||
PROFILE_BACKUP("profile.backup"),
|
||||
PROFILE_VIEW_USER_LOGS("profile.view.user.logs"),
|
||||
PROFILE_VIEW_DEV_LOGS("profile.view.dev.logs"),
|
||||
PROFILE_SEND_FEEDBACK("profile.send.feedback"),
|
||||
FEEDBACK_CANCEL("feedback.cancel"),
|
||||
FEEDBACK_SUBMIT("feedback.submit"),
|
||||
RECEIVE_SCAN("receive.scan"),
|
||||
RECEIVE_BACK("receive.back"),
|
||||
RESTORE_DONE("restore.done"),
|
||||
RESTORE_SUCCESS("restore.success"),
|
||||
RESTORE_BACK("restore.back"),
|
||||
SCAN_RECEIVE("scan.receive"),
|
||||
SCAN_BACK("scan.back"),
|
||||
SEND_ADDRESS_MAX("send.address.max"),
|
||||
SEND_ADDRESS_NEXT("send.address.next"),
|
||||
SEND_ADDRESS_PASTE("send.address.paste"),
|
||||
SEND_ADDRESS_BACK("send.address.back"),
|
||||
SEND_ADDRESS_DONE_ADDRESS("send.address.done.address"),
|
||||
SEND_ADDRESS_DONE_AMOUNT("send.address.done.amount"),
|
||||
SEND_ADDRESS_SCAN("send.address.scan"),
|
||||
SEND_CONFIRM_BACK("send.confirm.back"),
|
||||
SEND_CONFIRM_NEXT("send.confirm.next"),
|
||||
SEND_FINAL_EXIT("send.final.exit"),
|
||||
SEND_FINAL_RETRY("send.final.retry"),
|
||||
SEND_FINAL_CLOSE("send.final.close"),
|
||||
SEND_MEMO_INCLUDE("send.memo.include"),
|
||||
SEND_MEMO_EXCLUDE("send.memo.exclude"),
|
||||
SEND_MEMO_NEXT("send.memo.next"),
|
||||
SEND_MEMO_SKIP("send.memo.skip"),
|
||||
SEND_MEMO_CLEAR("send.memo.clear"),
|
||||
SEND_MEMO_BACK("send.memo.back"),
|
||||
|
||||
// General events
|
||||
COPY_ADDRESS("copy.address");
|
||||
|
||||
override val key = "tap.$id"
|
||||
override fun toString() = "${key.replace('.', ' ')} button".replace("tap ", "tapped the ")
|
||||
}
|
||||
|
||||
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
|
||||
FEEDBACK_STARTED("action.feedback.start", "feedback started"),
|
||||
FEEDBACK_STOPPED("action.feedback.stop", "feedback stopped"),
|
||||
SYNC_START("action.feedback.synchronizer.start", "sync started");
|
||||
|
||||
override fun toString(): String = description
|
||||
}
|
||||
|
||||
enum class MetricType(override val key: String, val description: String) : Feedback.Action {
|
||||
ENTROPY_CREATED("metric.entropy.created", "entropy created"),
|
||||
SEED_CREATED("metric.seed.created", "seed created"),
|
||||
SEED_IMPORTED("metric.seed.imported", "seed imported"),
|
||||
SEED_PHRASE_CREATED("metric.seedphrase.created", "seed phrase created"),
|
||||
SEED_PHRASE_LOADED("metric.seedphrase.loaded", "seed phrase loaded"),
|
||||
WALLET_CREATED("metric.wallet.created", "wallet created"),
|
||||
WALLET_IMPORTED("metric.wallet.imported", "wallet imported"),
|
||||
ACCOUNT_CREATED("metric.account.created", "account created"),
|
||||
|
||||
// Transactions
|
||||
TRANSACTION_INITIALIZED("metric.tx.initialized", "transaction initialized"),
|
||||
TRANSACTION_CREATED("metric.tx.created", "transaction created successfully"),
|
||||
TRANSACTION_SUBMITTED("metric.tx.submitted", "transaction submitted successfully"),
|
||||
TRANSACTION_MINED("metric.tx.mined", "transaction mined")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a metric with a start time of ZcashWalletApp.creationTime and an end time of when this
|
||||
* instance was created. This can then be passed to [Feedback.report].
|
||||
*/
|
||||
class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) :
|
||||
Feedback.Metric by metric {
|
||||
constructor() : this(
|
||||
|
@ -27,5 +207,6 @@ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric)
|
|||
override fun toString(): String = metric.toString()
|
||||
}
|
||||
|
||||
fun <T> Feedback.measure(type: MetricType, block: () -> T) =
|
||||
|
||||
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
|
||||
this.measure(type.key, type.description, block)
|
|
@ -1,10 +1,14 @@
|
|||
package cash.z.ecc.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Dialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
|
@ -13,25 +17,38 @@ import android.view.ViewGroup
|
|||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.annotation.ActivityScope
|
||||
import cash.z.ecc.android.feedback.*
|
||||
import cash.z.ecc.android.di.component.MainActivitySubcomponent
|
||||
import cash.z.ecc.android.di.component.SynchronizerSubcomponent
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import cash.z.ecc.android.feedback.LaunchMetric
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Error.NonFatal.Reorg
|
||||
import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED
|
||||
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
|
||||
import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import dagger.multibindings.IntoSet
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class MainActivity : DaggerAppCompatActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var feedback: Feedback
|
||||
|
@ -39,13 +56,30 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var feedbackCoordinator: FeedbackCoordinator
|
||||
|
||||
lateinit var navController: NavController
|
||||
@Inject
|
||||
lateinit var clipboard: ClipboardManager
|
||||
|
||||
private val mediaPlayer: MediaPlayer = MediaPlayer()
|
||||
|
||||
private var snackbar: Snackbar? = null
|
||||
private var dialog: Dialog? = null
|
||||
private var ignoreScanFailure: Boolean = false
|
||||
|
||||
lateinit var component: MainActivitySubcomponent
|
||||
lateinit var synchronizerComponent: SynchronizerSubcomponent
|
||||
|
||||
var navController: NavController? = null
|
||||
private val navInitListeners: MutableList<() -> Unit> = mutableListOf()
|
||||
|
||||
private val hasCameraPermission
|
||||
get() = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
component = ZcashWalletApp.component.mainActivitySubcomponent().create(this).also {
|
||||
it.inject(this)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.main_activity)
|
||||
initNavigation()
|
||||
|
@ -78,7 +112,7 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
|
||||
override fun onDestroy() {
|
||||
lifecycleScope.launch {
|
||||
feedback.report(NonUserAction.FEEDBACK_STOPPED)
|
||||
feedback.report(FEEDBACK_STOPPED)
|
||||
feedback.stop()
|
||||
}
|
||||
super.onDestroy()
|
||||
|
@ -97,13 +131,60 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
|
||||
private fun initNavigation() {
|
||||
navController = findNavController(R.id.nav_host_fragment)
|
||||
navController.addOnDestinationChangedListener { _, _, _ ->
|
||||
navController!!.addOnDestinationChangedListener { _, _, _ ->
|
||||
// hide the keyboard anytime we change destinations
|
||||
getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(
|
||||
this@MainActivity.window.decorView.rootView.windowToken,
|
||||
InputMethodManager.HIDE_NOT_ALWAYS
|
||||
)
|
||||
}
|
||||
|
||||
for (listener in navInitListeners) {
|
||||
listener()
|
||||
}
|
||||
navInitListeners.clear()
|
||||
}
|
||||
|
||||
fun safeNavigate(@IdRes destination: Int) {
|
||||
if (navController == null) {
|
||||
navInitListeners.add {
|
||||
try {
|
||||
navController?.navigate(destination)
|
||||
} catch (t: Throwable) {
|
||||
twig("WARNING: during callback, did not navigate to destination: R.id.${resources.getResourceEntryName(destination)} due to: $t")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
navController?.navigate(destination)
|
||||
} catch (t: Throwable) {
|
||||
twig("WARNING: did not immediately navigate to destination: R.id.${resources.getResourceEntryName(destination)} due to: $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startSync(initializer: Initializer) {
|
||||
if (!::synchronizerComponent.isInitialized) {
|
||||
synchronizerComponent = ZcashWalletApp.component.synchronizerSubcomponent().create(initializer)
|
||||
feedback.report(SYNC_START)
|
||||
synchronizerComponent.synchronizer().let { synchronizer ->
|
||||
synchronizer.onProcessorErrorHandler = ::onProcessorError
|
||||
synchronizer.onChainErrorHandler = ::onChainError
|
||||
synchronizer.start(lifecycleScope)
|
||||
}
|
||||
} else {
|
||||
twig("Ignoring request to start sync because sync has already been started!")
|
||||
}
|
||||
}
|
||||
|
||||
fun reportScreen(screen: Report.Screen?) = reportAction(screen)
|
||||
|
||||
fun reportTap(tap: Report.Tap?) = reportAction(tap)
|
||||
|
||||
fun reportFunnel(step: Feedback.Funnel?) = reportAction(step)
|
||||
|
||||
private fun reportAction(action: Feedback.Action?) {
|
||||
action?.let { feedback.report(it) }
|
||||
}
|
||||
|
||||
fun playSound(fileName: String) {
|
||||
|
@ -130,19 +211,43 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
fun copyAddress(view: View) {
|
||||
// TODO: get address from synchronizer
|
||||
val address =
|
||||
"zs1qduvdyuv83pyygjvc4cfcuc2wj5flnqn730iigf0tjct8k5ccs9y30p96j2gvn9gzyxm6q0vj12c4"
|
||||
val clipboard: ClipboardManager =
|
||||
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText(
|
||||
"Z-Address",
|
||||
address
|
||||
fun copyAddress(view: View? = null) {
|
||||
reportTap(COPY_ADDRESS)
|
||||
lifecycleScope.launch {
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText(
|
||||
"Z-Address",
|
||||
synchronizerComponent.synchronizer().getAddress()
|
||||
)
|
||||
)
|
||||
showMessage("Address copied!", "Sweet")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isValidAddress(address: String): Boolean {
|
||||
try {
|
||||
return !synchronizerComponent.synchronizer().validateAddress(address).isNotValid
|
||||
} catch (t: Throwable) { }
|
||||
return false
|
||||
}
|
||||
|
||||
fun copyText(textToCopy: String, label: String = "ECC Wallet Text") {
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText(label, textToCopy)
|
||||
)
|
||||
showMessage("Address copied!", "Sweet")
|
||||
showMessage("$label copied!", "Sweet")
|
||||
}
|
||||
|
||||
fun preventBackPress(fragment: Fragment) {
|
||||
onFragmentBackPressed(fragment){}
|
||||
}
|
||||
|
||||
fun onFragmentBackPressed(fragment: Fragment, block: () -> Unit) {
|
||||
onBackPressedDispatcher.addCallback(fragment, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
block()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun showMessage(message: String, action: String) {
|
||||
|
@ -174,47 +279,153 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
if (!it.isShownOrQueued) it.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun showKeyboard(focusedView: View) {
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
|
||||
}
|
||||
|
||||
fun hideKeyboard() {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param popUpToInclusive the destination to remove from the stack before opening the camera.
|
||||
* This only takes effect in the common case where the permission is granted.
|
||||
*/
|
||||
fun maybeOpenScan(popUpToInclusive: Int? = null) {
|
||||
if (hasCameraPermission) {
|
||||
openCamera(popUpToInclusive)
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
requestPermissions(arrayOf(Manifest.permission.CAMERA), 101)
|
||||
} else {
|
||||
onNoCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 101) {
|
||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
openCamera()
|
||||
} else {
|
||||
onNoCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCamera(popUpToInclusive: Int? = null) {
|
||||
navController?.navigate(popUpToInclusive ?: R.id.action_global_nav_scan)
|
||||
}
|
||||
|
||||
private fun onNoCamera() {
|
||||
showSnackbar("Well, this is awkward. You denied permission for the camera.")
|
||||
}
|
||||
|
||||
// TODO: clean up this error handling
|
||||
private var ignoredErrors = 0
|
||||
private fun onProcessorError(error: Throwable?): Boolean {
|
||||
var notified = false
|
||||
when (error) {
|
||||
is CompactBlockProcessorException.Uninitialized -> {
|
||||
if (dialog == null) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Wallet Improperly Initialized")
|
||||
.setMessage("This wallet has not been initialized correctly! Perhaps an error occurred during install.\n\nThis can be fixed with a reset. Please reimport using your backup seed phrase.")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
throw error
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
is CompactBlockProcessorException.FailedScan -> {
|
||||
if (dialog == null && !ignoreScanFailure) throttle("scanFailure", 20_000L) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Scan Failure")
|
||||
.setMessage("${error.message}${if (error.cause != null) "\n\nCaused by: ${error.cause}" else ""}")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Retry") { d, _ ->
|
||||
d.dismiss()
|
||||
dialog = null
|
||||
}
|
||||
.setNegativeButton("Ignore") { d, _ ->
|
||||
d.dismiss()
|
||||
ignoreScanFailure = true
|
||||
dialog = null
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!notified) {
|
||||
ignoredErrors++
|
||||
if (ignoredErrors >= ZcashSdk.RETRIES) {
|
||||
if (dialog == null) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Processor Error")
|
||||
.setMessage(error?.message ?: "Critical error while processing blocks!")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Retry") { d, _ ->
|
||||
d.dismiss()
|
||||
dialog = null
|
||||
}
|
||||
.setNegativeButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
throw error
|
||||
?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("MainActivity has received an error${if (notified) " and notified the user" else ""} and logged it.")
|
||||
feedback.report(error)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onChainError(errorHeight: Int, rewindHeight: Int) {
|
||||
feedback.report(Reorg(errorHeight, rewindHeight))
|
||||
}
|
||||
|
||||
|
||||
// TODO: maybe move this quick helper code somewhere general or throttle the dialogs differently (like with a flow and stream operators, instead)
|
||||
|
||||
private val throttles = mutableMapOf<String, () -> Any>()
|
||||
private val noWork = {}
|
||||
private fun throttle(key: String, delay: Long, block: () -> Any) {
|
||||
// if the key exists, just add the block to run later and exit
|
||||
if (throttles.containsKey(key)) {
|
||||
throttles[key] = block
|
||||
return
|
||||
}
|
||||
block()
|
||||
|
||||
// after doing the work, check back in later and if another request came in, throttle it, otherwise exit
|
||||
throttles[key] = noWork
|
||||
findViewById<View>(android.R.id.content).postDelayed({
|
||||
throttles[key]?.let { pendingWork ->
|
||||
throttles.remove(key)
|
||||
if (pendingWork !== noWork) throttle(key, delay, pendingWork)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class MainActivityModule {
|
||||
@ActivityScope
|
||||
@ContributesAndroidInjector(modules = [MainActivityProviderModule::class])
|
||||
abstract fun contributeActivity(): MainActivity
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
class MainActivityProviderModule {
|
||||
|
||||
@Provides
|
||||
@ActivityScope
|
||||
fun provideFeedback(): Feedback = Feedback()
|
||||
|
||||
@Provides
|
||||
@ActivityScope
|
||||
fun provideFeedbackCoordinator(
|
||||
feedback: Feedback,
|
||||
defaultObservers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
|
||||
): FeedbackCoordinator = FeedbackCoordinator(feedback, defaultObservers)
|
||||
|
||||
|
||||
//
|
||||
// Default Feedback Observer Set
|
||||
//
|
||||
|
||||
@Provides
|
||||
@ActivityScope
|
||||
@IntoSet
|
||||
fun provideFeedbackFile(): FeedbackCoordinator.FeedbackObserver = FeedbackFile()
|
||||
|
||||
@Provides
|
||||
@ActivityScope
|
||||
@IntoSet
|
||||
fun provideFeedbackConsole(): FeedbackCoordinator.FeedbackObserver = FeedbackConsole()
|
||||
|
||||
@Provides
|
||||
@ActivityScope
|
||||
@IntoSet
|
||||
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
|
||||
}
|
|
@ -5,15 +5,22 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import dagger.android.support.DaggerFragment
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
abstract class BaseFragment<T : ViewBinding> : DaggerFragment() {
|
||||
abstract class BaseFragment<T : ViewBinding> : Fragment() {
|
||||
val mainActivity: MainActivity? get() = activity as MainActivity?
|
||||
|
||||
lateinit var binding: T
|
||||
|
||||
lateinit var resumedScope: CoroutineScope
|
||||
|
||||
open val screen: Report.Screen? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -23,7 +30,32 @@ abstract class BaseFragment<T : ViewBinding> : DaggerFragment() {
|
|||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.reportScreen(screen)
|
||||
resumedScope = lifecycleScope.coroutineContext.let {
|
||||
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
resumedScope.cancel()
|
||||
}
|
||||
|
||||
// inflate is static in the ViewBinding class so we can't handle this ourselves
|
||||
// each fragment must call FragmentMyLayoutBinding.inflate(inflater)
|
||||
abstract fun inflate(@NonNull inflater: LayoutInflater): T
|
||||
|
||||
fun onBackPressNavTo(navResId: Int, block: (() -> Unit) = {}) {
|
||||
mainActivity?.onFragmentBackPressed(this) {
|
||||
block()
|
||||
mainActivity?.safeNavigate(navResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun tapped(tap: Report.Tap) {
|
||||
mainActivity?.reportTap(tap)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
|
||||
class TransactionAdapter<T : ConfirmedTransaction> :
|
||||
PagedListAdapter<T, TransactionViewHolder<T>>(
|
||||
object : DiffUtil.ItemCallback<T>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem.minedHeight == newItem.minedHeight && oldItem.noteId == newItem.noteId
|
||||
// bugfix: distinguish between self-transactions so they don't overwrite each other in the UI // TODO confirm that this is working, as intended
|
||||
&& ((oldItem.raw == null && newItem.raw == null) || (oldItem.raw != null && newItem.raw != null && oldItem.raw!!.contentEquals(newItem.raw!!)))
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem == newItem
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = TransactionViewHolder<T>(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.item_transaction, parent, false)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: TransactionViewHolder<T>,
|
||||
position: Int
|
||||
) = holder.bindTo(getItem(position))
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.toAppColor
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX
|
||||
import cash.z.ecc.android.ui.util.toUtf8Memo
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.ext.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.charset.Charset
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val indicator = itemView.findViewById<View>(R.id.indicator)
|
||||
private val amountText = itemView.findViewById<TextView>(R.id.text_transaction_amount)
|
||||
private val topText = itemView.findViewById<TextView>(R.id.text_transaction_top)
|
||||
private val bottomText = itemView.findViewById<TextView>(R.id.text_transaction_bottom)
|
||||
private val shieldIcon = itemView.findViewById<View>(R.id.image_shield)
|
||||
private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault())
|
||||
private val addressRegex = """zs\d\w{65,}""".toRegex()
|
||||
|
||||
fun bindTo(transaction: T?) {
|
||||
(itemView.context as MainActivity).lifecycleScope.launch {
|
||||
// update view
|
||||
var lineOne: String = ""
|
||||
var lineTwo: String = ""
|
||||
var amountZec: String = ""
|
||||
var amountDisplay: String = ""
|
||||
var amountColor: Int = R.color.text_light_dimmed
|
||||
var lineOneColor: Int = R.color.text_light
|
||||
var lineTwoColor: Int = R.color.text_light_dimmed
|
||||
var indicatorBackground: Int = R.drawable.background_indicator_unknown
|
||||
|
||||
transaction?.apply {
|
||||
itemView.setOnClickListener {
|
||||
onTransactionClicked(this)
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
onTransactionLongPressed(this)
|
||||
true
|
||||
}
|
||||
amountZec = value.convertZatoshiToZecString()
|
||||
// TODO: these might be good extension functions
|
||||
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
|
||||
val isMined = blockTimeInSeconds != 0L
|
||||
when {
|
||||
!toAddress.isNullOrEmpty() -> {
|
||||
lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}"
|
||||
lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation"
|
||||
amountDisplay = "- $amountZec"
|
||||
if (isMined) {
|
||||
amountColor = R.color.zcashRed
|
||||
indicatorBackground = R.drawable.background_indicator_outbound
|
||||
} else {
|
||||
lineOneColor = R.color.text_light_dimmed
|
||||
lineTwoColor = R.color.text_light
|
||||
}
|
||||
}
|
||||
toAddress.isNullOrEmpty() && value > 0L && minedHeight > 0 -> {
|
||||
lineOne = getSender(transaction)
|
||||
lineTwo = "Received $timestamp"
|
||||
amountDisplay = "+ $amountZec"
|
||||
amountColor = R.color.zcashGreen
|
||||
indicatorBackground = R.drawable.background_indicator_inbound
|
||||
}
|
||||
else -> {
|
||||
lineOne = "Unknown"
|
||||
lineTwo = "Unknown"
|
||||
amountDisplay = "$amountZec"
|
||||
amountColor = R.color.text_light
|
||||
}
|
||||
}
|
||||
// sanitize amount
|
||||
if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amountDisplay = "< 0.001"
|
||||
else if (amountZec.length > 10) { // 10 allows 3 digits to the left and 6 to the right of the decimal
|
||||
amountDisplay = "tap to view"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
topText.text = lineOne
|
||||
bottomText.text = lineTwo
|
||||
amountText.text = amountDisplay
|
||||
amountText.setTextColor(amountColor.toAppColor())
|
||||
topText.setTextColor(lineOneColor.toAppColor())
|
||||
bottomText.setTextColor(lineTwoColor.toAppColor())
|
||||
val context = itemView.context
|
||||
indicator.background = context.resources.getDrawable(indicatorBackground)
|
||||
shieldIcon.goneIf((transaction?.raw != null || transaction?.expiryHeight != null) && !transaction?.toAddress.isShielded())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSender(transaction: ConfirmedTransaction): String {
|
||||
val memo = transaction.memo.toUtf8Memo()
|
||||
return when {
|
||||
memo.contains(INCLUDE_MEMO_PREFIX) -> {
|
||||
val address = memo.split(INCLUDE_MEMO_PREFIX)[1].trim().validateAddress() ?: "Unknown"
|
||||
"${address.toAbbreviatedAddress()} paid you"
|
||||
}
|
||||
memo.contains("eply to:") -> {
|
||||
val address = memo.split("eply to:")[1].trim().validateAddress() ?: "Unknown"
|
||||
"${address.toAbbreviatedAddress()} paid you"
|
||||
}
|
||||
memo.contains("zs") -> {
|
||||
val who = extractAddress(memo).validateAddress()?.toAbbreviatedAddress() ?: "Unknown"
|
||||
"$who paid you"
|
||||
}
|
||||
else -> "Unknown paid you"
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractAddress(memo: String?) =
|
||||
addressRegex.findAll(memo ?: "").lastOrNull()?.value
|
||||
|
||||
private fun onTransactionClicked(transaction: ConfirmedTransaction) {
|
||||
val txId = transaction.rawTransactionId.toTxId()
|
||||
val detailsMessage: String = "Zatoshi amount: ${transaction.value}\n\n" +
|
||||
"Transaction: $txId" +
|
||||
"${if (transaction.toAddress != null) "\n\nTo: ${transaction.toAddress}" else ""}" +
|
||||
"${if (transaction.memo != null) "\n\nMemo: \n${String(transaction.memo!!, Charset.forName("UTF-8"))}" else ""}"
|
||||
|
||||
MaterialAlertDialogBuilder(itemView.context)
|
||||
.setMessage(detailsMessage)
|
||||
.setTitle("Transaction Details")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Ok") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("Copy TX") { dialog, _ ->
|
||||
(itemView.context as MainActivity).copyText(txId, "Transaction Id")
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onTransactionLongPressed(transaction: ConfirmedTransaction) {
|
||||
(transaction.toAddress ?: extractAddress(transaction.memo.toUtf8Memo()))?.let {
|
||||
(itemView.context as MainActivity).copyText(it, "Transaction Address")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun String?.validateAddress(): String? {
|
||||
if (this == null) return null
|
||||
return if ((itemView.context as MainActivity).isValidAddress(this)) this else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toTxId(): String {
|
||||
val sb = StringBuilder(size * 2)
|
||||
for(i in (size - 1) downTo 0) {
|
||||
sb.append(String.format("%02x", this[i]))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
//
|
||||
//import android.content.Context
|
||||
//import android.graphics.Canvas
|
||||
//import android.graphics.Rect
|
||||
//import android.view.LayoutInflater
|
||||
//import android.view.View
|
||||
//import androidx.recyclerview.widget.RecyclerView
|
||||
//import cash.z.ecc.android.R
|
||||
//
|
||||
//
|
||||
//class TransactionsDrawableFooter(context: Context) : RecyclerView.ItemDecoration() {
|
||||
//
|
||||
// private var footer: View =
|
||||
// LayoutInflater.from(context).inflate(R.layout.footer_transactions, null, false)
|
||||
//
|
||||
// override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
// super.onDraw(c, parent, state!!)
|
||||
// footer.measure(
|
||||
// View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.AT_MOST),
|
||||
// View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
// )
|
||||
// // layout basically just gets drawn on the reserved space on top of the first view
|
||||
// footer.layout(parent.left, 0, parent.right, footer.measuredHeight)
|
||||
// for (i in 0 until parent.childCount) {
|
||||
// val view: View = parent.getChildAt(i)
|
||||
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
// c.save()
|
||||
// val height: Int = footer.measuredHeight
|
||||
// val top: Int = view.top - height
|
||||
// c.translate(0.0f, top.toFloat())
|
||||
// footer.draw(c)
|
||||
// c.restore()
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun getItemOffsets(
|
||||
// outRect: Rect,
|
||||
// view: View,
|
||||
// parent: RecyclerView,
|
||||
// state: RecyclerView.State
|
||||
// ) {
|
||||
// super.getItemOffsets(outRect, view, parent, state)
|
||||
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
// outRect.set(0, 0, 0, 150)
|
||||
// } else {
|
||||
// outRect.setEmpty()
|
||||
// }
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,49 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
|
||||
|
||||
class TransactionsFooter(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private var footer: Drawable = context.resources.getDrawable(R.drawable.background_footer)
|
||||
val bounds = Rect()
|
||||
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
c.save()
|
||||
val left: Int = 0
|
||||
val right: Int = parent.width
|
||||
val childCount = parent.childCount
|
||||
val adapterItemCount = parent.adapter!!.itemCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = parent.getChildAt(i)
|
||||
if (parent.getChildAdapterPosition(child) == adapterItemCount - 1) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
val bottom: Int = bounds.bottom + Math.round(child.translationY)
|
||||
val top: Int = bottom - footer.intrinsicHeight
|
||||
footer.setBounds(left, top, right, bottom)
|
||||
footer.draw(c)
|
||||
}
|
||||
}
|
||||
c.restore()
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
outRect.set(0, 0, 0, footer.intrinsicHeight)
|
||||
} else {
|
||||
outRect.setEmpty()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,79 +1,82 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentDetailBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.ext.onClick
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onClickNavUp
|
||||
import cash.z.ecc.android.feedback.FeedbackFile
|
||||
import cash.z.ecc.android.ext.toColoredSpan
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.DETAIL_BACK
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import okio.Okio
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.WalletBalance
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
|
||||
override val screen = Report.Screen.DETAIL
|
||||
private val viewModel: WalletDetailViewModel by viewModel()
|
||||
|
||||
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentDetailBinding =
|
||||
FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.onClickNavUp()
|
||||
|
||||
onClick(binding.buttonFeedback) {
|
||||
onSendFeedback()
|
||||
}
|
||||
onClick(binding.buttonLogs) {
|
||||
onViewLogs()
|
||||
}
|
||||
onClick(binding.buttonBackup, 1L) {
|
||||
onBackupWallet()
|
||||
binding.backButtonHitArea.onClickNavUp { tapped(DETAIL_BACK) }
|
||||
lifecycleScope.launch {
|
||||
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSendFeedback() {
|
||||
mainActivity?.showSnackbar("Feedback not yet implemented.")
|
||||
}
|
||||
|
||||
private fun onViewLogs() {
|
||||
loadLogFileAsText().let { logText ->
|
||||
if (logText == null) {
|
||||
mainActivity?.showSnackbar("Log file not found!")
|
||||
} else {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, logText)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, "Share Log File")
|
||||
startActivity(shareIntent)
|
||||
}
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
initTransactionUI()
|
||||
viewModel.balance.collectWith(resumedScope) {
|
||||
onBalanceUpdated(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBackupWallet() {
|
||||
mainActivity?.navController?.navigate(R.id.action_nav_detail_to_backup_wallet)
|
||||
}
|
||||
|
||||
private fun loadLogFileAsText(): String? {
|
||||
val feedbackFile: FeedbackFile =
|
||||
mainActivity?.feedbackCoordinator?.findObserver() ?: return null
|
||||
Okio.buffer(Okio.source(feedbackFile.file)).use {
|
||||
return it.readUtf8()
|
||||
private fun onBalanceUpdated(balance: WalletBalance) {
|
||||
binding.textBalanceAvailable.text = balance.availableZatoshi.convertZatoshiToZecString()
|
||||
val change = (balance.totalZatoshi - balance.availableZatoshi)
|
||||
binding.textBalanceDescription.apply {
|
||||
goneIf(change <= 0L)
|
||||
val changeString = change.convertZatoshiToZecString()
|
||||
text = "(expecting +$changeString ZEC)".toColoredSpan(R.color.text_light, "+${changeString}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initTransactionUI() {
|
||||
binding.recyclerTransactions.layoutManager =
|
||||
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
||||
binding.recyclerTransactions.addItemDecoration(TransactionsFooter(binding.recyclerTransactions.context))
|
||||
adapter = TransactionAdapter()
|
||||
viewModel.transactions.collectWith(resumedScope) { onTransactionsUpdated(it) }
|
||||
binding.recyclerTransactions.adapter = adapter
|
||||
binding.recyclerTransactions.smoothScrollToPosition(0)
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class WalletDetailFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): WalletDetailFragment
|
||||
private fun onTransactionsUpdated(transactions: PagedList<ConfirmedTransaction>) {
|
||||
twig("got a new paged list of transactions")
|
||||
binding.groupEmptyViews.goneIf(transactions.size > 0)
|
||||
adapter.submitList(transactions)
|
||||
}
|
||||
|
||||
// TODO: maybe implement this for better fade behavior. Or do an actual scroll behavior instead, yeah do that. Or an item decoration.
|
||||
fun onLastItemShown(item: ConfirmedTransaction, position: Int) {
|
||||
binding.footerFade.alpha = position.toFloat() / (binding.recyclerTransactions.adapter?.itemCount ?: 1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package cash.z.ecc.android.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import javax.inject.Inject
|
||||
|
||||
class WalletDetailViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
val transactions get() = synchronizer.clearedTransactions
|
||||
val balance get() = synchronizer.balances
|
||||
|
||||
suspend fun getAddress() = synchronizer.getAddress()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("WalletDetailViewModel cleared!")
|
||||
}
|
||||
}
|
|
@ -5,111 +5,254 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer.Status.*
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.safelyConvertToBigDecimal
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
override val screen = Report.Screen.HOME
|
||||
|
||||
private lateinit var numberPad: List<TextView>
|
||||
private lateinit var uiModel: HomeViewModel.UiModel
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||
private val sendViewModel: SendViewModel by activityViewModel()
|
||||
private val viewModel: HomeViewModel by viewModel()
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
|
||||
lateinit var snake: MagicSnakeLoader
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
|
||||
FragmentHomeBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
with(binding) {
|
||||
numberPad = arrayListOf(
|
||||
buttonNumberPad0,
|
||||
buttonNumberPad1,
|
||||
buttonNumberPad2,
|
||||
buttonNumberPad3,
|
||||
buttonNumberPad4,
|
||||
buttonNumberPad5,
|
||||
buttonNumberPad6,
|
||||
buttonNumberPad7,
|
||||
buttonNumberPad8,
|
||||
buttonNumberPad9,
|
||||
buttonNumberPadDecimal,
|
||||
buttonNumberPadBack
|
||||
)
|
||||
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_receive)
|
||||
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
|
||||
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
|
||||
hitAreaScan.onClickNavTo(R.id.action_nav_home_to_nav_send)
|
||||
|
||||
textBannerAction.setOnClickListener {
|
||||
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: trigger this from presenter
|
||||
onNoFunds()
|
||||
}
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
twig("HomeFragment.onAttach")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================")
|
||||
super.onAttach(context)
|
||||
|
||||
// this will call startSync either now or later (after initializing with newly created seed)
|
||||
walletSetup.checkSeed().onEach {
|
||||
when(it) {
|
||||
NO_SEED -> {
|
||||
mainActivity?.navController?.navigate(R.id.action_nav_home_to_create_wallet)
|
||||
}
|
||||
twig("Checking seed")
|
||||
if (it == NO_SEED) {
|
||||
// interact with user to create, backup and verify seed
|
||||
// leads to a call to startSync(), later (after accounts are created from seed)
|
||||
twig("Seed not found, therefore, launching seed creation flow")
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_create_wallet)
|
||||
} else {
|
||||
twig("Found seed. Re-opening existing wallet")
|
||||
mainActivity?.startSync(walletSetup.openWallet())
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun onBannerAction(action: BannerAction) {
|
||||
when (action) {
|
||||
LEARN_MORE -> {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
|
||||
.setTitle("No Balance")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Tap Faucet") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
setBanner("Tapping faucet...", CANCEL)
|
||||
}
|
||||
.setNegativeButton("View Address") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
mainActivity?.navController?.navigate(R.id.action_nav_home_to_nav_receive)
|
||||
}
|
||||
.show()
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
twig("HomeFragment.onViewCreated uiModel: ${::uiModel.isInitialized} saved: ${savedInstanceState != null}")
|
||||
with(binding) {
|
||||
numberPad = arrayListOf(
|
||||
buttonNumberPad0.asKey(),
|
||||
buttonNumberPad1.asKey(),
|
||||
buttonNumberPad2.asKey(),
|
||||
buttonNumberPad3.asKey(),
|
||||
buttonNumberPad4.asKey(),
|
||||
buttonNumberPad5.asKey(),
|
||||
buttonNumberPad6.asKey(),
|
||||
buttonNumberPad7.asKey(),
|
||||
buttonNumberPad8.asKey(),
|
||||
buttonNumberPad9.asKey(),
|
||||
buttonNumberPadDecimal.asKey(),
|
||||
buttonNumberPadBack.asKey()
|
||||
)
|
||||
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
|
||||
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
||||
hitAreaScan.setOnClickListener {
|
||||
mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
|
||||
}
|
||||
CANCEL -> {
|
||||
// TODO: trigger banner / balance update
|
||||
onNoFunds()
|
||||
|
||||
textBannerAction.setOnClickListener {
|
||||
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
||||
}
|
||||
buttonSendAmount.setOnClickListener {
|
||||
onSend().also { tapped(HOME_SEND) }
|
||||
}
|
||||
setSendAmount("0", false)
|
||||
|
||||
snake = MagicSnakeLoader(binding.lottieButtonLoading)
|
||||
}
|
||||
|
||||
binding.buttonNumberPadBack.setOnLongClickListener {
|
||||
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
|
||||
true
|
||||
}
|
||||
|
||||
if (::uiModel.isInitialized) {
|
||||
twig("uiModel exists!")
|
||||
onModelUpdated(null, uiModel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClearAmount() {
|
||||
if (::uiModel.isInitialized) {
|
||||
resumedScope.launch {
|
||||
binding.textSendAmount.text.apply {
|
||||
while (uiModel.pendingSend != "0") {
|
||||
viewModel.onChar('<')
|
||||
delay(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNoFunds() {
|
||||
setBanner("No Balance", LEARN_MORE)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
|
||||
viewModel.initializeMaybe()
|
||||
onClearAmount()
|
||||
viewModel.uiModels.scanReduce { old, new ->
|
||||
onModelUpdated(old, new)
|
||||
new
|
||||
}.onCompletion {
|
||||
twig("uiModel.scanReduce completed.")
|
||||
}.catch { e ->
|
||||
twig("exception while processing uiModels $e")
|
||||
throw e
|
||||
}.launchIn(resumedScope)
|
||||
|
||||
// TODO: see if there is a better way to trigger a refresh of the uiModel on resume
|
||||
// the latest one should just be in the viewmodel and we should just "resubscribe"
|
||||
// but for some reason, this doesn't always happen, which kind of defeats the purpose
|
||||
// of having a cold stream in the view model
|
||||
resumedScope.launch {
|
||||
viewModel.refreshBalance()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setBanner(message: String = "", action: BannerAction = CLEAR) {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
twig("HomeFragment.onSaveInstanceState")
|
||||
if (::uiModel.isInitialized) {
|
||||
// outState.putParcelable("uiModel", uiModel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
savedInstanceState?.let { inState ->
|
||||
twig("HomeFragment.onViewStateRestored")
|
||||
// onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Public UI API
|
||||
//
|
||||
|
||||
var isSendEnabled = false
|
||||
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
|
||||
isSendEnabled = enabled
|
||||
binding.buttonSendAmount.apply {
|
||||
if (enabled || !isSynced) {
|
||||
isEnabled = true
|
||||
isClickable = isSynced
|
||||
binding.lottieButtonLoading.alpha = 1.0f
|
||||
} else {
|
||||
isEnabled = false
|
||||
isClickable = false
|
||||
binding.lottieButtonLoading.alpha = 0.32f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(uiModel: HomeViewModel.UiModel) {
|
||||
if (!uiModel.processorInfo.hasData && !uiModel.isDisconnected) {
|
||||
twig("Warning: ignoring progress update because the processor is still starting.")
|
||||
return
|
||||
}
|
||||
|
||||
snake.isSynced = uiModel.isSynced
|
||||
if (!uiModel.isSynced) {
|
||||
snake.downloadProgress = uiModel.downloadProgress
|
||||
snake.scanProgress = uiModel.scanProgress
|
||||
}
|
||||
|
||||
val sendText = when {
|
||||
uiModel.status == DISCONNECTED -> "Reconnecting . . ."
|
||||
uiModel.isSynced -> if (uiModel.hasFunds) "SEND AMOUNT" else "NO FUNDS AVAILABLE"
|
||||
uiModel.status == STOPPED -> "IDLE"
|
||||
uiModel.isDownloading -> "Downloading . . . ${snake.downloadProgress}%"
|
||||
uiModel.isValidating -> "Validating . . ."
|
||||
uiModel.isScanning -> "Scanning . . . ${snake.scanProgress}%"
|
||||
else -> "Updating"
|
||||
}
|
||||
|
||||
binding.buttonSendAmount.text = sendText
|
||||
twig("Send button set to: $sendText")
|
||||
|
||||
val resId = if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light
|
||||
binding.buttonSendAmount.setTextColor(resources.getColorStateList(resId))
|
||||
binding.lottieButtonLoading.invisibleIf(uiModel.isDisconnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param amount the amount to send represented as ZEC, without the dollar sign.
|
||||
*/
|
||||
fun setSendAmount(amount: String, updateModel: Boolean = true) {
|
||||
binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$")
|
||||
if (updateModel) {
|
||||
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
|
||||
}
|
||||
binding.buttonSendAmount.disabledIf(amount == "0")
|
||||
}
|
||||
|
||||
fun setAvailable(availableBalance: Long = -1L, totalBalance: Long = -1L) {
|
||||
val missingBalance = availableBalance < 0
|
||||
val availableString = if (missingBalance) "Updating" else availableBalance.convertZatoshiToZecString()
|
||||
binding.textBalanceAvailable.text = availableString
|
||||
binding.textBalanceAvailable.transparentIf(missingBalance)
|
||||
binding.labelBalance.transparentIf(missingBalance)
|
||||
binding.textBalanceDescription.apply {
|
||||
goneIf(missingBalance)
|
||||
text = if (availableBalance != -1L && (availableBalance < totalBalance)) {
|
||||
val change = (totalBalance - availableBalance).convertZatoshiToZecString()
|
||||
"(expecting +$change ZEC)".toColoredSpan(R.color.text_light, "+$change")
|
||||
} else {
|
||||
"(enter an amount to send)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBanner(message: String = "", action: BannerAction = CLEAR) {
|
||||
with(binding) {
|
||||
val hasMessage = !message.isEmpty() || action != CLEAR
|
||||
groupBalance.goneIf(hasMessage)
|
||||
|
@ -121,10 +264,120 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Private UI Events
|
||||
//
|
||||
|
||||
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
logUpdate(old, new)
|
||||
uiModel = new
|
||||
if (old?.pendingSend != new.pendingSend) {
|
||||
setSendAmount(new.pendingSend)
|
||||
}
|
||||
setProgress(uiModel) // TODO: we may not need to separate anymore
|
||||
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
||||
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
||||
setSendEnabled(new.isSendEnabled, new.status == SYNCED)
|
||||
}
|
||||
|
||||
private fun logUpdate(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
var message = ""
|
||||
fun maybeComma() = if (message.length > "UiModel(".length) ", " else ""
|
||||
message = when {
|
||||
old == null -> "$new"
|
||||
new == null -> "null"
|
||||
else -> {
|
||||
buildString {
|
||||
append("UiModel(")
|
||||
if (old.status != new.status) append ("status=${new.status}")
|
||||
if (old.processorInfo != new.processorInfo) {
|
||||
append ("${maybeComma()}processorInfo=ProcessorInfo(")
|
||||
val startLength = length
|
||||
fun innerComma() = if (length > startLength) ", " else ""
|
||||
if (old.processorInfo.networkBlockHeight != new.processorInfo.networkBlockHeight) append("networkBlockHeight=${new.processorInfo.networkBlockHeight}")
|
||||
if (old.processorInfo.lastScannedHeight != new.processorInfo.lastScannedHeight) append("${innerComma()}lastScannedHeight=${new.processorInfo.lastScannedHeight}")
|
||||
if (old.processorInfo.lastDownloadedHeight != new.processorInfo.lastDownloadedHeight) append("${innerComma()}lastDownloadedHeight=${new.processorInfo.lastDownloadedHeight}")
|
||||
if (old.processorInfo.lastDownloadRange != new.processorInfo.lastDownloadRange) append("${innerComma()}lastDownloadRange=${new.processorInfo.lastDownloadRange}")
|
||||
if (old.processorInfo.lastScanRange != new.processorInfo.lastScanRange) append("${innerComma()}lastScanRange=${new.processorInfo.lastScanRange}")
|
||||
append(")")
|
||||
}
|
||||
if (old.availableBalance != new.availableBalance) append ("${maybeComma()}availableBalance=${new.availableBalance}")
|
||||
if (old.totalBalance != new.totalBalance) append ("${maybeComma()}totalBalance=${new.totalBalance}")
|
||||
if (old.pendingSend != new.pendingSend) append ("${maybeComma()}pendingSend=${new.pendingSend}")
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("onModelUpdated: $message")
|
||||
}
|
||||
|
||||
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
||||
setAvailable()
|
||||
}
|
||||
|
||||
private fun onSynced(uiModel: HomeViewModel.UiModel) {
|
||||
snake.isSynced = true
|
||||
if (!uiModel.hasBalance) {
|
||||
onNoFunds()
|
||||
} else {
|
||||
setBanner("")
|
||||
setAvailable(uiModel.availableBalance, uiModel.totalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSend() {
|
||||
if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
||||
}
|
||||
|
||||
private fun onBannerAction(action: BannerAction) {
|
||||
when (action) {
|
||||
FUND_NOW -> {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setMessage("To make full use of this wallet, deposit funds to your address.")
|
||||
.setTitle("No Balance")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("View Address") { dialog, _ ->
|
||||
tapped(HOME_FUND_NOW)
|
||||
dialog.dismiss()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
}
|
||||
.show()
|
||||
// MaterialAlertDialogBuilder(activity)
|
||||
// .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
|
||||
// .setTitle("No Balance")
|
||||
// .setCancelable(true)
|
||||
// .setPositiveButton("Tap Faucet") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// setBanner("Tapping faucet...", CANCEL)
|
||||
// }
|
||||
// .setNegativeButton("View Address") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
// }
|
||||
// .show()
|
||||
}
|
||||
CANCEL -> {
|
||||
// TODO: trigger banner / balance update
|
||||
onNoFunds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNoFunds() {
|
||||
setBanner("No Balance", FUND_NOW)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Inner classes and extensions
|
||||
//
|
||||
|
||||
enum class BannerAction(val action: String) {
|
||||
LEARN_MORE("Learn More"),
|
||||
FUND_NOW(""),
|
||||
CANCEL("Cancel"),
|
||||
CLEAR("");
|
||||
NONE(""),
|
||||
CLEAR("clear");
|
||||
|
||||
companion object {
|
||||
fun from(action: String?): BannerAction {
|
||||
|
@ -135,12 +388,52 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextView.asKey(): TextView {
|
||||
val c = text[0]
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
twig("CHAR TYPED: $c")
|
||||
viewModel.onChar(c)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class HomeFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): HomeFragment
|
||||
|
||||
|
||||
// TODO: remove these troubleshooting logs
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
twig("HomeFragment.onCreate")
|
||||
}
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
twig("HomeFragment.onActivityCreated")
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
twig("HomeFragment.onStart")
|
||||
}
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
twig("HomeFragment.onPause resumeScope.isActive: ${resumedScope.isActive}")
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
twig("HomeFragment.onStop")
|
||||
}
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
twig("HomeFragment.onDestroyView")
|
||||
}
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
twig("HomeFragment.onDestroy")
|
||||
}
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
twig("HomeFragment.onDetach")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer.Status.*
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||
import cash.z.ecc.android.sdk.exception.RustLayerException
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk.MINERS_FEE_ZATOSHI
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk.ZATOSHI_PER_ZEC
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class HomeViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
lateinit var uiModels: Flow<UiModel>
|
||||
|
||||
lateinit var _typedChars: ConflatedBroadcastChannel<Char>
|
||||
|
||||
var initialized = false
|
||||
|
||||
fun initializeMaybe() {
|
||||
twig("init called")
|
||||
if (initialized) {
|
||||
twig("Warning already initialized HomeViewModel. Ignoring call to initialize.")
|
||||
return
|
||||
}
|
||||
|
||||
if (::_typedChars.isInitialized) {
|
||||
_typedChars.close()
|
||||
}
|
||||
_typedChars = ConflatedBroadcastChannel()
|
||||
val typedChars = _typedChars.asFlow()
|
||||
|
||||
val zec = typedChars.scan("0") { acc, c ->
|
||||
when {
|
||||
// no-op cases
|
||||
acc == "0" && c == '0'
|
||||
|| (c == '<' && acc == "0")
|
||||
|| (c == '.' && acc.contains('.')) -> {twig("triggered: 1 acc: $acc c: $c")
|
||||
acc
|
||||
}
|
||||
c == '<' && acc.length <= 1 -> {twig("triggered: 2 $typedChars")
|
||||
"0"
|
||||
}
|
||||
c == '<' -> {twig("triggered: 3")
|
||||
acc.substring(0, acc.length - 1)
|
||||
}
|
||||
acc == "0" && c != '.' -> {twig("triggered: 4 $typedChars")
|
||||
c.toString()
|
||||
}
|
||||
else -> {twig("triggered: 5 $typedChars")
|
||||
"$acc$c"
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("initializing view models stream")
|
||||
uiModels = synchronizer.run {
|
||||
combine(status, processorInfo, balances, zec) { s, p, b, z->
|
||||
UiModel(s, p, b.availableZatoshi, b.totalZatoshi, z)
|
||||
}.onStart{ emit(UiModel()) }
|
||||
}.conflate()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("HomeViewModel cleared!")
|
||||
}
|
||||
|
||||
suspend fun onChar(c: Char) {
|
||||
_typedChars.send(c)
|
||||
}
|
||||
|
||||
suspend fun refreshBalance() {
|
||||
try {
|
||||
(synchronizer as SdkSynchronizer).refreshBalance()
|
||||
} catch (e: RustLayerException.BalanceException) {
|
||||
twig("Balance refresh failed. This is probably caused by a critical error but we'll give the app a chance to try to recover.")
|
||||
}
|
||||
}
|
||||
|
||||
data class UiModel( // <- THIS ERROR IS AN IDE BUG WITH PARCELIZE
|
||||
val status: Synchronizer.Status = DISCONNECTED,
|
||||
val processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(),
|
||||
val availableBalance: Long = -1L,
|
||||
val totalBalance: Long = -1L,
|
||||
val pendingSend: String = "0"
|
||||
) {
|
||||
// Note: the wallet is effectively empty if it cannot cover the miner's fee
|
||||
val hasFunds: Boolean get() = availableBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001
|
||||
val hasBalance: Boolean get() = totalBalance > 0
|
||||
val isSynced: Boolean get() = status == SYNCED
|
||||
val isSendEnabled: Boolean get() = isSynced && hasFunds
|
||||
|
||||
// Processor Info
|
||||
val isDownloading = status == DOWNLOADING
|
||||
val isScanning = status == SCANNING
|
||||
val isValidating = status == VALIDATING
|
||||
val isDisconnected = status == DISCONNECTED
|
||||
val downloadProgress: Int get() {
|
||||
return processorInfo.run {
|
||||
if (lastDownloadRange.isEmpty()) {
|
||||
100
|
||||
} else {
|
||||
val progress =
|
||||
(((lastDownloadedHeight - lastDownloadRange.first + 1).coerceAtLeast(0).toFloat() / (lastDownloadRange.last - lastDownloadRange.first + 1)) * 100.0f).coerceAtMost(
|
||||
100.0f
|
||||
).roundToInt()
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
val scanProgress: Int get() {
|
||||
return processorInfo.run {
|
||||
if (lastScanRange.isEmpty()) {
|
||||
100
|
||||
} else {
|
||||
val progress = (((lastScannedHeight - lastScanRange.first + 1).coerceAtLeast(0).toFloat() / (lastScanRange.last - lastScanRange.first + 1)) * 100.0f).coerceAtMost(100.0f).roundToInt()
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
val totalProgress: Float get() {
|
||||
val downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f)
|
||||
val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f)
|
||||
return downloadWeighted.coerceAtLeast(0.0f) + scanWeighted.coerceAtLeast(0.0f)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
|
||||
class MagicSnakeLoader(
|
||||
val lottie: LottieAnimationView,
|
||||
private val scanningStartFrame: Int = 100,
|
||||
private val scanningEndFrame: Int = 187,
|
||||
val totalFrames: Int = 200
|
||||
) : ValueAnimator.AnimatorUpdateListener {
|
||||
private var isPaused: Boolean = true
|
||||
private var isStarted: Boolean = false
|
||||
|
||||
var isSynced: Boolean = false
|
||||
set(value) {
|
||||
if (value && !isStarted) {
|
||||
lottie.progress = 1.0f
|
||||
field = value
|
||||
return
|
||||
}
|
||||
|
||||
// it is started but it hadn't reached the synced state yet
|
||||
if (value && !field) {
|
||||
field = value
|
||||
playToCompletion()
|
||||
} else {
|
||||
field = value
|
||||
}
|
||||
}
|
||||
|
||||
var scanProgress: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
if (value > 0) {
|
||||
startMaybe()
|
||||
onScanUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
var downloadProgress: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
if (value > 0) startMaybe()
|
||||
}
|
||||
|
||||
private fun startMaybe() {
|
||||
|
||||
if (!isSynced && !isStarted) lottie.postDelayed({
|
||||
// after some delay, if we're still not synced then we better start animating (unless we already are)!
|
||||
if (!isSynced && isPaused) {
|
||||
lottie.resumeAnimation()
|
||||
isPaused = false
|
||||
isStarted = true
|
||||
}
|
||||
}, 200L)
|
||||
}
|
||||
|
||||
private val isDownloading get() = downloadProgress in 1..99
|
||||
private val isScanning get() = scanProgress in 1..99
|
||||
|
||||
init {
|
||||
lottie.addAnimatorUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
if (isSynced || isPaused) {
|
||||
// playToCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
// if we are scanning, then set the animation progress, based on the scan progress
|
||||
// if we're not scanning, then we're looping
|
||||
animation.currentFrame().let { frame ->
|
||||
if (isDownloading) allowLoop(frame) else applyScanProgress(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private val acceptablePauseFrames = arrayOf(33,34,67,68,99)
|
||||
private fun applyScanProgress(frame: Int) {
|
||||
// don't hardcode the progress until the loop animation has completed, cleanly
|
||||
if (isPaused) {
|
||||
onScanUpdated()
|
||||
} else {
|
||||
// once we're ready to show scan progress, do it! Don't do extra loops.
|
||||
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScanUpdated() {
|
||||
if (isSynced) {
|
||||
// playToCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if (isPaused && isStarted) {
|
||||
// move forward within the scan range, proportionate to how much scanning is complete
|
||||
val scanRange = scanningEndFrame - scanningStartFrame
|
||||
val scanRangeProgress = scanProgress.toFloat() / 100.0f * scanRange.toFloat()
|
||||
lottie.progress = (scanningStartFrame.toFloat() + scanRangeProgress) / totalFrames
|
||||
}
|
||||
}
|
||||
|
||||
private fun playToCompletion() {
|
||||
removeLoops()
|
||||
unpause()
|
||||
}
|
||||
|
||||
private fun removeLoops() {
|
||||
lottie.frame.let {frame ->
|
||||
if (frame in 33..67) {
|
||||
lottie.frame = frame + 34
|
||||
} else if (frame in 0..33) {
|
||||
lottie.frame = frame + 67
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun allowLoop(frame: Int) {
|
||||
unpause()
|
||||
if (frame >= scanningStartFrame) {
|
||||
lottie.progress = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun unpause() {
|
||||
if (isPaused) {
|
||||
lottie.resumeAnimation()
|
||||
isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if (!isPaused) {
|
||||
lottie.pauseAnimation()
|
||||
isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ValueAnimator.currentFrame(): Int {
|
||||
return ((animatedValue as Float) * totalFrames).toInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.home.OgHomeFragment.BannerAction.*
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer.Status.*
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.safelyConvertToBigDecimal
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OgHomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
override val screen = Report.Screen.HOME
|
||||
|
||||
private lateinit var numberPad: List<TextView>
|
||||
private lateinit var uiModel: HomeViewModel.UiModel
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||
private val sendViewModel: SendViewModel by activityViewModel()
|
||||
private val viewModel: HomeViewModel by viewModel()
|
||||
|
||||
lateinit var snake: MagicSnakeLoader
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
|
||||
FragmentHomeBinding.inflate(inflater)
|
||||
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
twig("HomeFragment.onAttach")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================")
|
||||
super.onAttach(context)
|
||||
|
||||
// this will call startSync either now or later (after initializing with newly created seed)
|
||||
walletSetup.checkSeed().onEach {
|
||||
twig("Checking seed")
|
||||
if (it == NO_SEED) {
|
||||
// interact with user to create, backup and verify seed
|
||||
// leads to a call to startSync(), later (after accounts are created from seed)
|
||||
twig("Seed not found, therefore, launching seed creation flow")
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_landing)
|
||||
} else {
|
||||
twig("Found seed. Re-opening existing wallet")
|
||||
mainActivity?.startSync(walletSetup.openWallet())
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
twig("HomeFragment.onViewCreated uiModel: ${::uiModel.isInitialized} saved: ${savedInstanceState != null}")
|
||||
with(binding) {
|
||||
numberPad = arrayListOf(
|
||||
buttonNumberPad0.asKey(),
|
||||
buttonNumberPad1.asKey(),
|
||||
buttonNumberPad2.asKey(),
|
||||
buttonNumberPad3.asKey(),
|
||||
buttonNumberPad4.asKey(),
|
||||
buttonNumberPad5.asKey(),
|
||||
buttonNumberPad6.asKey(),
|
||||
buttonNumberPad7.asKey(),
|
||||
buttonNumberPad8.asKey(),
|
||||
buttonNumberPad9.asKey(),
|
||||
buttonNumberPadDecimal.asKey(),
|
||||
buttonNumberPadBack.asKey()
|
||||
)
|
||||
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
|
||||
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
||||
hitAreaScan.setOnClickListener {
|
||||
mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
|
||||
}
|
||||
|
||||
textBannerAction.setOnClickListener {
|
||||
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
||||
}
|
||||
buttonSendAmount.setOnClickListener {
|
||||
onSend().also { tapped(HOME_SEND) }
|
||||
}
|
||||
setSendAmount("0", false)
|
||||
|
||||
snake = MagicSnakeLoader(binding.lottieButtonLoading)
|
||||
}
|
||||
|
||||
binding.buttonNumberPadBack.setOnLongClickListener {
|
||||
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
|
||||
true
|
||||
}
|
||||
|
||||
if (::uiModel.isInitialized) {
|
||||
twig("uiModel exists!")
|
||||
onModelUpdated(null, uiModel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClearAmount() {
|
||||
if (::uiModel.isInitialized) {
|
||||
resumedScope.launch {
|
||||
binding.textSendAmount.text.apply {
|
||||
while (uiModel.pendingSend != "0") {
|
||||
viewModel.onChar('<')
|
||||
delay(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
|
||||
// viewModel.initializeMaybe()
|
||||
// onClearAmount()
|
||||
// viewModel.uiModels.scanReduce { old, new ->
|
||||
// onModelUpdated(old, new)
|
||||
// new
|
||||
// }.onCompletion {
|
||||
// twig("uiModel.scanReduce completed.")
|
||||
// }.catch { e ->
|
||||
// twig("exception while processing uiModels $e")
|
||||
// throw e
|
||||
// }.launchIn(resumedScope)
|
||||
//
|
||||
// // TODO: see if there is a better way to trigger a refresh of the uiModel on resume
|
||||
// // the latest one should just be in the viewmodel and we should just "resubscribe"
|
||||
// // but for some reason, this doesn't always happen, which kind of defeats the purpose
|
||||
// // of having a cold stream in the view model
|
||||
// resumedScope.launch {
|
||||
// viewModel.refreshBalance()
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
twig("HomeFragment.onSaveInstanceState")
|
||||
if (::uiModel.isInitialized) {
|
||||
// outState.putParcelable("uiModel", uiModel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
savedInstanceState?.let { inState ->
|
||||
twig("HomeFragment.onViewStateRestored")
|
||||
// onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Public UI API
|
||||
//
|
||||
|
||||
var isSendEnabled = false
|
||||
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
|
||||
isSendEnabled = enabled
|
||||
binding.buttonSendAmount.apply {
|
||||
if (enabled || !isSynced) {
|
||||
isEnabled = true
|
||||
isClickable = isSynced
|
||||
binding.lottieButtonLoading.alpha = 1.0f
|
||||
} else {
|
||||
isEnabled = false
|
||||
isClickable = false
|
||||
binding.lottieButtonLoading.alpha = 0.32f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(uiModel: HomeViewModel.UiModel) {
|
||||
if (!uiModel.processorInfo.hasData && !uiModel.isDisconnected) {
|
||||
twig("Warning: ignoring progress update because the processor is still starting.")
|
||||
return
|
||||
}
|
||||
|
||||
snake.isSynced = uiModel.isSynced
|
||||
if (!uiModel.isSynced) {
|
||||
snake.downloadProgress = uiModel.downloadProgress
|
||||
snake.scanProgress = uiModel.scanProgress
|
||||
}
|
||||
|
||||
val sendText = when {
|
||||
uiModel.status == DISCONNECTED -> "Reconnecting . . ."
|
||||
uiModel.isSynced -> if (uiModel.hasFunds) "SEND AMOUNT" else "NO FUNDS AVAILABLE"
|
||||
uiModel.status == STOPPED -> "IDLE"
|
||||
uiModel.isDownloading -> "Downloading . . . ${snake.downloadProgress}%"
|
||||
uiModel.isValidating -> "Validating . . ."
|
||||
uiModel.isScanning -> "Scanning . . . ${snake.scanProgress}%"
|
||||
else -> "Updating"
|
||||
}
|
||||
|
||||
binding.buttonSendAmount.text = sendText
|
||||
twig("Send button set to: $sendText")
|
||||
|
||||
val resId = if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light
|
||||
binding.buttonSendAmount.setTextColor(resources.getColorStateList(resId))
|
||||
binding.lottieButtonLoading.invisibleIf(uiModel.isDisconnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param amount the amount to send represented as ZEC, without the dollar sign.
|
||||
*/
|
||||
fun setSendAmount(amount: String, updateModel: Boolean = true) {
|
||||
binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$")
|
||||
if (updateModel) {
|
||||
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
|
||||
}
|
||||
binding.buttonSendAmount.disabledIf(amount == "0")
|
||||
}
|
||||
|
||||
fun setAvailable(availableBalance: Long = -1L, totalBalance: Long = -1L) {
|
||||
val missingBalance = availableBalance < 0
|
||||
val availableString = if (missingBalance) "Updating" else availableBalance.convertZatoshiToZecString()
|
||||
binding.textBalanceAvailable.text = availableString
|
||||
binding.textBalanceAvailable.transparentIf(missingBalance)
|
||||
binding.labelBalance.transparentIf(missingBalance)
|
||||
binding.textBalanceDescription.apply {
|
||||
goneIf(missingBalance)
|
||||
text = if (availableBalance != -1L && (availableBalance < totalBalance)) {
|
||||
val change = (totalBalance - availableBalance).convertZatoshiToZecString()
|
||||
"(expecting +$change ZEC)".toColoredSpan(R.color.text_light, "+$change")
|
||||
} else {
|
||||
"(enter an amount to send)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBanner(message: String = "", action: BannerAction = CLEAR) {
|
||||
with(binding) {
|
||||
val hasMessage = !message.isEmpty() || action != CLEAR
|
||||
groupBalance.goneIf(hasMessage)
|
||||
groupBanner.goneIf(!hasMessage)
|
||||
layerLock.goneIf(!hasMessage)
|
||||
|
||||
textBannerMessage.text = message
|
||||
textBannerAction.text = action.action
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Private UI Events
|
||||
//
|
||||
|
||||
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
logUpdate(old, new)
|
||||
uiModel = new
|
||||
if (old?.pendingSend != new.pendingSend) {
|
||||
setSendAmount(new.pendingSend)
|
||||
}
|
||||
setProgress(uiModel) // TODO: we may not need to separate anymore
|
||||
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
||||
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
||||
setSendEnabled(new.isSendEnabled, new.status == SYNCED)
|
||||
}
|
||||
|
||||
private fun logUpdate(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
var message = ""
|
||||
fun maybeComma() = if (message.length > "UiModel(".length) ", " else ""
|
||||
message = when {
|
||||
old == null -> "$new"
|
||||
new == null -> "null"
|
||||
else -> {
|
||||
buildString {
|
||||
append("UiModel(")
|
||||
if (old.status != new.status) append ("status=${new.status}")
|
||||
if (old.processorInfo != new.processorInfo) {
|
||||
append ("${maybeComma()}processorInfo=ProcessorInfo(")
|
||||
val startLength = length
|
||||
fun innerComma() = if (length > startLength) ", " else ""
|
||||
if (old.processorInfo.networkBlockHeight != new.processorInfo.networkBlockHeight) append("networkBlockHeight=${new.processorInfo.networkBlockHeight}")
|
||||
if (old.processorInfo.lastScannedHeight != new.processorInfo.lastScannedHeight) append("${innerComma()}lastScannedHeight=${new.processorInfo.lastScannedHeight}")
|
||||
if (old.processorInfo.lastDownloadedHeight != new.processorInfo.lastDownloadedHeight) append("${innerComma()}lastDownloadedHeight=${new.processorInfo.lastDownloadedHeight}")
|
||||
if (old.processorInfo.lastDownloadRange != new.processorInfo.lastDownloadRange) append("${innerComma()}lastDownloadRange=${new.processorInfo.lastDownloadRange}")
|
||||
if (old.processorInfo.lastScanRange != new.processorInfo.lastScanRange) append("${innerComma()}lastScanRange=${new.processorInfo.lastScanRange}")
|
||||
append(")")
|
||||
}
|
||||
if (old.availableBalance != new.availableBalance) append ("${maybeComma()}availableBalance=${new.availableBalance}")
|
||||
if (old.totalBalance != new.totalBalance) append ("${maybeComma()}totalBalance=${new.totalBalance}")
|
||||
if (old.pendingSend != new.pendingSend) append ("${maybeComma()}pendingSend=${new.pendingSend}")
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("onModelUpdated: $message")
|
||||
}
|
||||
|
||||
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
||||
setAvailable()
|
||||
}
|
||||
|
||||
private fun onSynced(uiModel: HomeViewModel.UiModel) {
|
||||
snake.isSynced = true
|
||||
if (!uiModel.hasBalance) {
|
||||
onNoFunds()
|
||||
} else {
|
||||
setBanner("")
|
||||
setAvailable(uiModel.availableBalance, uiModel.totalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSend() {
|
||||
if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
||||
}
|
||||
|
||||
private fun onBannerAction(action: BannerAction) {
|
||||
when (action) {
|
||||
FUND_NOW -> {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setMessage("To make full use of this wallet, deposit funds to your address.")
|
||||
.setTitle("No Balance")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("View Address") { dialog, _ ->
|
||||
tapped(HOME_FUND_NOW)
|
||||
dialog.dismiss()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
}
|
||||
.show()
|
||||
// MaterialAlertDialogBuilder(activity)
|
||||
// .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
|
||||
// .setTitle("No Balance")
|
||||
// .setCancelable(true)
|
||||
// .setPositiveButton("Tap Faucet") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// setBanner("Tapping faucet...", CANCEL)
|
||||
// }
|
||||
// .setNegativeButton("View Address") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
// }
|
||||
// .show()
|
||||
}
|
||||
CANCEL -> {
|
||||
// TODO: trigger banner / balance update
|
||||
onNoFunds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNoFunds() {
|
||||
setBanner("No Balance", FUND_NOW)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Inner classes and extensions
|
||||
//
|
||||
|
||||
enum class BannerAction(val action: String) {
|
||||
FUND_NOW(""),
|
||||
CANCEL("Cancel"),
|
||||
NONE(""),
|
||||
CLEAR("clear");
|
||||
|
||||
companion object {
|
||||
fun from(action: String?): BannerAction {
|
||||
values().forEach {
|
||||
if (it.action == action) return it
|
||||
}
|
||||
throw IllegalArgumentException("Invalid BannerAction: $action")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextView.asKey(): TextView {
|
||||
val c = text[0]
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
twig("CHAR TYPED: $c")
|
||||
viewModel.onChar(c)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// TODO: remove these troubleshooting logs
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
twig("HomeFragment.onCreate")
|
||||
}
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
twig("HomeFragment.onActivityCreated")
|
||||
}
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
twig("HomeFragment.onStart")
|
||||
}
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
twig("HomeFragment.onPause resumeScope.isActive: ${resumedScope.isActive}")
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
twig("HomeFragment.onStop")
|
||||
}
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
twig("HomeFragment.onDestroyView")
|
||||
}
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
twig("HomeFragment.onDestroy")
|
||||
}
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
twig("HomeFragment.onDetach")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.doOnLayout
|
||||
import cash.z.ecc.android.databinding.FragmentFeedbackBinding
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.UserFeedback
|
||||
import cash.z.ecc.android.feedback.Report.Tap.FEEDBACK_CANCEL
|
||||
import cash.z.ecc.android.feedback.Report.Tap.FEEDBACK_SUBMIT
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
|
||||
/**
|
||||
* Fragment representing the home screen of the app. This is the screen most often seen by the user when launching the
|
||||
* application.
|
||||
*/
|
||||
class FeedbackFragment : BaseFragment<FragmentFeedbackBinding>() {
|
||||
override val screen = Report.Screen.FEEDBACK
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentFeedbackBinding =
|
||||
FragmentFeedbackBinding.inflate(inflater)
|
||||
|
||||
private lateinit var ratings: Array<View>
|
||||
|
||||
// private val padder = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
// Toast.makeText(mainActivity, "LAYOUT", Toast.LENGTH_SHORT).show()
|
||||
// }
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// mainActivity!!.window.decorView.viewTreeObserver.addOnGlobalLayoutListener(padder)
|
||||
// mainActivity!!.findViewById<View>(android.R.id.content).viewTreeObserver.addOnGlobalLayoutListener(padder)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// mainActivity!!.window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(padder)
|
||||
// mainActivity!!.findViewById<View>(android.R.id.content).viewTreeObserver.removeOnGlobalLayoutListener(padder)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
with(binding) {
|
||||
backButtonHitArea.setOnClickListener(::onFeedbackCancel)
|
||||
buttonSubmit.setOnClickListener(::onFeedbackSubmit)
|
||||
|
||||
ratings = arrayOf(feedbackExp1, feedbackExp2, feedbackExp3, feedbackExp4, feedbackExp5)
|
||||
ratings.forEach {
|
||||
it.setOnClickListener(::onRatingClicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Private API
|
||||
//
|
||||
|
||||
private fun onFeedbackSubmit(view: View) {
|
||||
Toast.makeText(mainActivity, "Thanks for the feedback!", Toast.LENGTH_LONG).show()
|
||||
tapped(FEEDBACK_SUBMIT)
|
||||
|
||||
val q1 = binding.inputQuestion1.editText?.text.toString()
|
||||
val q2 = binding.inputQuestion2.editText?.text.toString()
|
||||
val q3 = binding.inputQuestion3.editText?.text.toString()
|
||||
val rating = ratings.indexOfFirst { it.isActivated } + 1
|
||||
|
||||
mainActivity?.reportFunnel(UserFeedback.Submitted(rating, q1, q2, q3))
|
||||
|
||||
mainActivity?.navController?.navigateUp()
|
||||
}
|
||||
private fun onFeedbackCancel(view: View) {
|
||||
tapped(FEEDBACK_CANCEL)
|
||||
mainActivity?.reportFunnel(UserFeedback.Cancelled)
|
||||
mainActivity?.navController?.navigateUp()
|
||||
}
|
||||
|
||||
private fun onRatingClicked(view: View) {
|
||||
ratings.forEach { it.isActivated = false }
|
||||
view.isActivated = !view.isActivated
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.content.FileProvider.getUriForFile
|
||||
import cash.z.ecc.android.BuildConfig
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentProfileBinding
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.onClick
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.feedback.FeedbackFile
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.UserFeedback
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Okio
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
||||
override val screen = Report.Screen.PROFILE
|
||||
|
||||
private val viewModel: ProfileViewModel by viewModel()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentProfileBinding =
|
||||
FragmentProfileBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaClose.onClickNavBack() { tapped(PROFILE_CLOSE) }
|
||||
binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup) { tapped(PROFILE_BACKUP) }
|
||||
binding.buttonFeedback.onClickNavTo(R.id.action_nav_profile_to_nav_feedback) {
|
||||
tapped(PROFILE_SEND_FEEDBACK)
|
||||
mainActivity?.reportFunnel(UserFeedback.Started)
|
||||
Unit
|
||||
}
|
||||
binding.textVersion.text = BuildConfig.VERSION_NAME
|
||||
onClick(binding.buttonLogs) {
|
||||
tapped(PROFILE_VIEW_USER_LOGS)
|
||||
onViewLogs()
|
||||
}
|
||||
binding.buttonLogs.setOnLongClickListener {
|
||||
tapped(PROFILE_VIEW_DEV_LOGS)
|
||||
onViewDevLogs()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumedScope.launch {
|
||||
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress(12, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onViewLogs() {
|
||||
shareFile(userLogFile())
|
||||
}
|
||||
|
||||
private fun onViewDevLogs() {
|
||||
shareFile(writeLogcat())
|
||||
}
|
||||
|
||||
private fun shareFiles(vararg files: File?) {
|
||||
val uris = arrayListOf<Uri>().apply {
|
||||
files.filterNotNull().mapNotNull {
|
||||
getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", it)
|
||||
}.forEach {
|
||||
add(it)
|
||||
}
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
type = "text/*"
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Share Log Files"))
|
||||
}
|
||||
|
||||
fun shareFile(file: File?) {
|
||||
file ?: return
|
||||
val uri = getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = "text/plain"
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Share Log File"))
|
||||
}
|
||||
|
||||
private fun userLogFile(): File? {
|
||||
return mainActivity?.feedbackCoordinator?.findObserver<FeedbackFile>()?.file
|
||||
}
|
||||
|
||||
private fun loadLogFileAsText(): String? {
|
||||
val feedbackFile: File = userLogFile() ?: return null
|
||||
Okio.buffer(Okio.source(feedbackFile)).use {
|
||||
return it.readUtf8()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeLogcat(): File? {
|
||||
try {
|
||||
val outputFile = File("${ZcashWalletApp.instance.filesDir}/logs", "developer_log.txt")
|
||||
val cmd = arrayOf("/bin/sh", "-c", "logcat -v time -d | grep \"@TWIG\" > ${outputFile.absolutePath}")
|
||||
Runtime.getRuntime().exec(cmd)
|
||||
return outputFile
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
twig("Failed to create log")
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProfileViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
suspend fun getAddress(): String = synchronizer.getAddress()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("ProfileViewModel cleared!")
|
||||
}
|
||||
}
|
|
@ -2,46 +2,50 @@ package cash.z.ecc.android.ui.receive
|
|||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.android.qrecycler.QRecycler
|
||||
import cash.z.ecc.android.databinding.FragmentReceiveBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentReceiveNewBinding
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.ext.onClickNavUp
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.android.synthetic.main.fragment_receive.*
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ReceiveFragment : BaseFragment<FragmentReceiveBinding>() {
|
||||
override fun inflate(inflater: LayoutInflater): FragmentReceiveBinding =
|
||||
FragmentReceiveBinding.inflate(inflater)
|
||||
class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
|
||||
override val screen = Report.Screen.RECEIVE
|
||||
|
||||
private val viewModel: ReceiveViewModel by viewModel()
|
||||
|
||||
lateinit var qrecycler: QRecycler
|
||||
|
||||
lateinit var addressParts: Array<TextView>
|
||||
// lateinit var addressParts: Array<TextView>
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentReceiveNewBinding =
|
||||
FragmentReceiveNewBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
addressParts = arrayOf(
|
||||
text_address_part_1,
|
||||
text_address_part_2,
|
||||
text_address_part_3,
|
||||
text_address_part_4,
|
||||
text_address_part_5,
|
||||
text_address_part_6,
|
||||
text_address_part_7,
|
||||
text_address_part_8
|
||||
)
|
||||
binding.backButtonHitArea.onClickNavUp()
|
||||
// addressParts = arrayOf(
|
||||
// text_address_part_1,
|
||||
// text_address_part_2,
|
||||
// text_address_part_3,
|
||||
// text_address_part_4,
|
||||
// text_address_part_5,
|
||||
// text_address_part_6,
|
||||
// text_address_part_7,
|
||||
// text_address_part_8
|
||||
// )
|
||||
binding.buttonScan.setOnClickListener {
|
||||
mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan).also { tapped(RECEIVE_SCAN) }
|
||||
}
|
||||
binding.backButtonHitArea.onClickNavBack() { tapped(RECEIVE_BACK) }
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
|
@ -51,38 +55,46 @@ class ReceiveFragment : BaseFragment<FragmentReceiveBinding>() {
|
|||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
lifecycleScope.launch {
|
||||
onAddressLoaded("zs1qduvdyuv83pyygjvc4cfcuc2wj5flnqn730iigf0tjct8k5ccs9y30p96j2gvn9gzyxm6q0vj12c4")
|
||||
resumedScope.launch {
|
||||
onAddressLoaded(viewModel.getAddress())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddressLoaded(address: String) {
|
||||
Log.e("TWIG", "onAddressLoaded: $address length: ${address.length}")
|
||||
twig("address loaded: $address length: ${address.length}")
|
||||
qrecycler.load(address)
|
||||
.withQuietZoneSize(3)
|
||||
.withCorrectionLevel(QRecycler.CorrectionLevel.MEDIUM)
|
||||
.into(receive_qr_code)
|
||||
.into(binding.receiveQrCode)
|
||||
|
||||
address.chunked(address.length/8).forEachIndexed { i, part ->
|
||||
setAddressPart(i, part)
|
||||
binding.receiveAddress.text = address.toAbbreviatedAddress(12, 12)
|
||||
|
||||
// address.distribute(8) { i, part ->
|
||||
// setAddressPart(i, part)
|
||||
// }
|
||||
}
|
||||
|
||||
private fun <T> String.distribute(chunks: Int, block: (Int, String) -> T) {
|
||||
val charsPerChunk = length / 8.0
|
||||
val wholeCharsPerChunk = charsPerChunk.toInt()
|
||||
val chunksWithExtra = ((charsPerChunk - wholeCharsPerChunk) * chunks).roundToInt()
|
||||
repeat(chunks) { i ->
|
||||
val part = if (i < chunksWithExtra) {
|
||||
substring(i * (wholeCharsPerChunk + 1), (i + 1) * (wholeCharsPerChunk + 1))
|
||||
} else {
|
||||
substring(i * wholeCharsPerChunk + chunksWithExtra, (i + 1) * wholeCharsPerChunk + chunksWithExtra)
|
||||
}
|
||||
block(i, part)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAddressPart(index: Int, addressPart: String) {
|
||||
Log.e("TWIG", "setting address for part $index) $addressPart")
|
||||
val thinSpace = "\u2005" // 0.25 em space
|
||||
val textSpan = SpannableString("${index + 1}$thinSpace$addressPart")
|
||||
|
||||
textSpan.setSpan(AddressPartNumberSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
addressParts[index].text = textSpan
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class ReceiveFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): ReceiveFragment
|
||||
// private fun setAddressPart(index: Int, addressPart: String) {
|
||||
// Log.e("TWIG", "setting address for part $index) $addressPart")
|
||||
// val thinSpace = "\u2005" // 0.25 em space
|
||||
// val textSpan = SpannableString("${index + 1}$thinSpace$addressPart")
|
||||
//
|
||||
// textSpan.setSpan(AddressPartNumberSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
//
|
||||
// addressParts[index].text = textSpan
|
||||
// }
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cash.z.ecc.android.ui.receive
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import javax.inject.Inject
|
||||
|
||||
class ReceiveViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
suspend fun getAddress(): String = synchronizer.getAddress()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("ReceiveViewModel cleared!")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import cash.z.ecc.android.sdk.ext.retrySimple
|
||||
import cash.z.ecc.android.sdk.ext.retryUpTo
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.ml.vision.FirebaseVision
|
||||
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode
|
||||
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetector
|
||||
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetectorOptions
|
||||
import com.google.firebase.ml.vision.common.FirebaseVisionImage
|
||||
import com.google.firebase.ml.vision.common.FirebaseVisionImageMetadata
|
||||
|
||||
class QrAnalyzer(val scanCallback: (qrContent: String, image: ImageProxy) -> Unit) :
|
||||
ImageAnalysis.Analyzer {
|
||||
private val detector: FirebaseVisionBarcodeDetector by lazy {
|
||||
val options = FirebaseVisionBarcodeDetectorOptions.Builder()
|
||||
.setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
FirebaseVision.getInstance().getVisionBarcodeDetector(options)
|
||||
}
|
||||
|
||||
var pendingTask: Task<out Any>? = null
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
var rotation = image.imageInfo.rotationDegrees % 360
|
||||
if (rotation < 0) {
|
||||
rotation += 360
|
||||
}
|
||||
|
||||
retrySimple {
|
||||
val mediaImage = FirebaseVisionImage.fromMediaImage(
|
||||
image.image!!, when (rotation) {
|
||||
0 -> FirebaseVisionImageMetadata.ROTATION_0
|
||||
90 -> FirebaseVisionImageMetadata.ROTATION_90
|
||||
180 -> FirebaseVisionImageMetadata.ROTATION_180
|
||||
270 -> FirebaseVisionImageMetadata.ROTATION_270
|
||||
else -> {
|
||||
FirebaseVisionImageMetadata.ROTATION_0
|
||||
}
|
||||
}
|
||||
)
|
||||
pendingTask = detector.detectInImage(mediaImage).also {
|
||||
it.addOnSuccessListener { result ->
|
||||
onImageScan(result, image)
|
||||
}
|
||||
it.addOnFailureListener(::onImageScanFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onImageScan(result: List<FirebaseVisionBarcode>, image: ImageProxy) {
|
||||
result.firstOrNull()?.rawValue?.let {
|
||||
scanCallback(it, image)
|
||||
} ?: runCatching { image.close() }
|
||||
}
|
||||
|
||||
private fun onImageScanFailure(e: Exception) {
|
||||
twig("Warning: Image scan failed")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentScanBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.SCAN_BACK
|
||||
import cash.z.ecc.android.feedback.Report.Tap.SCAN_RECEIVE
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
||||
override val screen = Report.Screen.SCAN
|
||||
private val viewModel: ScanViewModel by viewModel()
|
||||
|
||||
private val sendViewModel: SendViewModel by activityViewModel()
|
||||
|
||||
private lateinit var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>
|
||||
|
||||
private var cameraExecutor: ExecutorService? = null
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentScanBinding =
|
||||
FragmentScanBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
if (cameraExecutor != null) cameraExecutor?.shutdown()
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive) { tapped(SCAN_RECEIVE) }
|
||||
binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
if (!allPermissionsGranted()) getRuntimePermissions()
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener(Runnable {
|
||||
bindPreview(cameraProviderFuture.get())
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
cameraExecutor?.shutdown()
|
||||
cameraExecutor = null
|
||||
}
|
||||
|
||||
private fun bindPreview(cameraProvider: ProcessCameraProvider) {
|
||||
// Most of the code here is adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt
|
||||
// it's worth keeping tabs on that implementation because they keep making breaking changes to these APIs!
|
||||
|
||||
// Get screen metrics used to setup camera for full screen resolution
|
||||
val metrics = DisplayMetrics().also { binding.preview.display.getRealMetrics(it) }
|
||||
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels)
|
||||
val rotation = binding.preview.display.rotation
|
||||
|
||||
val preview =
|
||||
Preview.Builder().setTargetName("Preview").setTargetAspectRatio(screenAspectRatio)
|
||||
.setTargetRotation(rotation).build()
|
||||
|
||||
val cameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder().setTargetAspectRatio(screenAspectRatio)
|
||||
.setTargetRotation(rotation)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
imageAnalysis.setAnalyzer(cameraExecutor!!, QrAnalyzer { q, i ->
|
||||
onQrScanned(q, i)
|
||||
})
|
||||
|
||||
// Must unbind the use-cases before rebinding them
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
try {
|
||||
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis)
|
||||
preview.setSurfaceProvider(binding.preview.createSurfaceProvider())
|
||||
} catch (t: Throwable) {
|
||||
// TODO: consider bubbling this up to the user
|
||||
mainActivity?.feedback?.report(t)
|
||||
twig("Error while opening the camera: $t")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt#L350
|
||||
*/
|
||||
private fun aspectRatio(width: Int, height: Int): Int {
|
||||
val previewRatio = kotlin.math.max(width, height).toDouble() / kotlin.math.min(
|
||||
width,
|
||||
height
|
||||
)
|
||||
if (kotlin.math.abs(previewRatio - (4.0 / 3.0))
|
||||
<= kotlin.math.abs(previewRatio - (16.0 / 9.0))) {
|
||||
return AspectRatio.RATIO_4_3
|
||||
}
|
||||
return AspectRatio.RATIO_16_9
|
||||
}
|
||||
|
||||
private fun onQrScanned(qrContent: String, image: ImageProxy) {
|
||||
resumedScope.launch {
|
||||
if (viewModel.isNotValid(qrContent)) image.close() // continue scanning
|
||||
else {
|
||||
sendViewModel.toAddress = qrContent
|
||||
mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send_address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private fun updateOverlay(detectedObjects: DetectedObjects) {
|
||||
// if (detectedObjects.objects.isEmpty()) {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// overlay.setSize(detectedObjects.imageWidth, detectedObjects.imageHeight)
|
||||
// val list = mutableListOf<BoxData>()
|
||||
// for (obj in detectedObjects.objects) {
|
||||
// val box = obj.boundingBox
|
||||
// val name = "${categoryNames[obj.classificationCategory]}"
|
||||
// val confidence =
|
||||
// if (obj.classificationCategory != FirebaseVisionObject.CATEGORY_UNKNOWN) {
|
||||
// val confidence: Int = obj.classificationConfidence!!.times(100).toInt()
|
||||
// "$confidence%"
|
||||
// } else {
|
||||
// ""
|
||||
// }
|
||||
// list.add(BoxData("$name $confidence", box))
|
||||
// }
|
||||
// overlay.set(list)
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Permissions
|
||||
//
|
||||
|
||||
private val requiredPermissions: Array<String?>
|
||||
get() {
|
||||
return try {
|
||||
val info = mainActivity?.packageManager
|
||||
?.getPackageInfo(mainActivity?.packageName, PackageManager.GET_PERMISSIONS)
|
||||
val ps = info?.requestedPermissions
|
||||
if (ps != null && ps.isNotEmpty()) {
|
||||
ps
|
||||
} else {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun allPermissionsGranted(): Boolean {
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getRuntimePermissions() {
|
||||
val allNeededPermissions = arrayListOf<String>()
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
allNeededPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
|
||||
if (allNeededPermissions.isNotEmpty()) {
|
||||
requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (allPermissionsGranted()) {
|
||||
// view!!.postDelayed(
|
||||
// {
|
||||
// onStartCamera()
|
||||
// },
|
||||
// 2000L
|
||||
// ) // TODO: remove this temp hack to sidestep crash when permissions were not available
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CAMERA_PERMISSION_REQUEST = 1002
|
||||
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScanViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
suspend fun isNotValid(address: String) = synchronizer.validateAddress(address).isNotValid
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("${javaClass.simpleName} cleared!")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendAddressBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.WalletBalance
|
||||
import cash.z.ecc.android.sdk.ext.*
|
||||
import cash.z.ecc.android.sdk.validate.AddressType
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||
ClipboardManager.OnPrimaryClipChangedListener {
|
||||
override val screen = Report.Screen.SEND_ADDRESS
|
||||
|
||||
private var maxZatoshi: Long? = null
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModel()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendAddressBinding =
|
||||
FragmentSendAddressBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) { tapped(SEND_ADDRESS_BACK) }
|
||||
binding.buttonNext.setOnClickListener {
|
||||
onSubmit().also { tapped(SEND_ADDRESS_NEXT) }
|
||||
}
|
||||
binding.textBannerAction.setOnClickListener {
|
||||
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||
}
|
||||
binding.textBannerMessage.setOnClickListener {
|
||||
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||
}
|
||||
binding.textMax.setOnClickListener {
|
||||
onMax().also { tapped(SEND_ADDRESS_MAX) }
|
||||
}
|
||||
|
||||
// Apply View Model
|
||||
if (sendViewModel.zatoshiAmount > 0L) {
|
||||
sendViewModel.zatoshiAmount.convertZatoshiToZecString(8).let { amount ->
|
||||
binding.inputZcashAmount.setText(amount)
|
||||
}
|
||||
} else {
|
||||
binding.inputZcashAmount.setText(null)
|
||||
}
|
||||
if (!sendViewModel.toAddress.isNullOrEmpty()) {
|
||||
binding.inputZcashAddress.setText(sendViewModel.toAddress)
|
||||
} else {
|
||||
binding.inputZcashAddress.setText(null)
|
||||
}
|
||||
|
||||
binding.inputZcashAddress.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_ADDRESS) }
|
||||
binding.inputZcashAmount.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_AMOUNT) }
|
||||
|
||||
binding.inputZcashAddress.apply {
|
||||
doAfterTextChanged {
|
||||
val trim = text.toString().trim()
|
||||
if (text.toString() != trim) {
|
||||
binding.inputZcashAddress
|
||||
.findViewById<EditText>(R.id.input_zcash_address).setText(trim)
|
||||
}
|
||||
onAddressChanged(trim)
|
||||
}
|
||||
}
|
||||
|
||||
binding.textLayoutAddress.setEndIconOnClickListener {
|
||||
mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddressChanged(address: String) {
|
||||
resumedScope.launch {
|
||||
var type = when (sendViewModel.validateAddress(address)) {
|
||||
is AddressType.Transparent -> "This is a valid transparent address" to R.color.zcashGreen
|
||||
is AddressType.Shielded -> "This is a valid shielded address" to R.color.zcashGreen
|
||||
is AddressType.Invalid -> "This address appears to be invalid" to R.color.zcashRed
|
||||
}
|
||||
if (address == sendViewModel.synchronizer.getAddress()) type =
|
||||
"Warning, this appears to be your address!" to R.color.zcashRed
|
||||
binding.textLayoutAddress.helperText = type.first
|
||||
binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun onSubmit(unused: EditText? = null) {
|
||||
sendViewModel.toAddress = binding.inputZcashAddress.text.toString()
|
||||
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
|
||||
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
|
||||
if (it == null) {
|
||||
sendViewModel.funnel(Send.AddressPageComplete)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo)
|
||||
} else {
|
||||
resumedScope.launch {
|
||||
binding.textAddressError.text = it
|
||||
delay(1500L)
|
||||
binding.textAddressError.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMax() {
|
||||
if (maxZatoshi != null) {
|
||||
binding.inputZcashAmount.apply {
|
||||
setText(maxZatoshi.convertZatoshiToZecString(8))
|
||||
postDelayed({
|
||||
requestFocus()
|
||||
setSelection(text?.length ?: 0)
|
||||
}, 10L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
mainActivity?.clipboard?.addPrimaryClipChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
mainActivity?.clipboard?.removePrimaryClipChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateClipboardBanner()
|
||||
sendViewModel.synchronizer.balances.collectWith(resumedScope) {
|
||||
onBalanceUpdated(it)
|
||||
}
|
||||
binding.inputZcashAddress.text.toString().let {
|
||||
if (!it.isNullOrEmpty()) onAddressChanged(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBalanceUpdated(balance: WalletBalance) {
|
||||
binding.textLayoutAmount.helperText =
|
||||
"You have ${balance.availableZatoshi.coerceAtLeast(0L).convertZatoshiToZecString(8)} available"
|
||||
maxZatoshi = (balance.availableZatoshi - ZcashSdk.MINERS_FEE_ZATOSHI).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
override fun onPrimaryClipChanged() {
|
||||
twig("clipboard changed!")
|
||||
updateClipboardBanner()
|
||||
}
|
||||
|
||||
private fun updateClipboardBanner() {
|
||||
binding.groupBanner.goneIf(loadAddressFromClipboard() == null)
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
mainActivity?.clipboard?.let { clipboard ->
|
||||
if (clipboard.hasPrimaryClip()) {
|
||||
binding.inputZcashAddress.setText(clipboard.text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAddressFromClipboard(): String? {
|
||||
mainActivity?.clipboard?.apply {
|
||||
if (hasPrimaryClip()) {
|
||||
text()?.let { text ->
|
||||
if (text.startsWith("zs") && text.length > 70) {
|
||||
return@loadAddressFromClipboard text.toString()
|
||||
}
|
||||
// treat t-addrs differently in the future
|
||||
if (text.startsWith("t1") && text.length > 32) {
|
||||
return@loadAddressFromClipboard text.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ClipboardManager.text(): CharSequence =
|
||||
primaryClip!!.getItemAt(0).coerceToText(mainActivity)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendConfirmBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
|
||||
override val screen = Report.Screen.SEND_CONFIRM
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModel()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendConfirmBinding =
|
||||
FragmentSendConfirmBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonNext.setOnClickListener {
|
||||
onSend().also { tapped(SEND_CONFIRM_NEXT) }
|
||||
}
|
||||
R.id.action_nav_send_confirm_to_nav_send_memo.let {
|
||||
binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) }
|
||||
onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) }
|
||||
}
|
||||
mainActivity?.lifecycleScope?.launch {
|
||||
binding.textConfirmation.text =
|
||||
"Send ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel?.toAddress.toAbbreviatedAddress()}?"
|
||||
}
|
||||
sendViewModel.memo.trim().isNotEmpty().let { hasMemo ->
|
||||
binding.radioIncludeAddress.isChecked = hasMemo || sendViewModel.includeFromAddress
|
||||
binding.radioIncludeAddress.goneIf(!(hasMemo || sendViewModel.includeFromAddress))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSend() {
|
||||
sendViewModel.funnel(Send.ConfirmPageComplete)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlin.random.Random
|
||||
|
||||
class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
||||
override val screen = Report.Screen.SEND_FINAL
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModel()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendFinalBinding =
|
||||
FragmentSendFinalBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonNext.setOnClickListener {
|
||||
onExit().also { tapped(SEND_FINAL_EXIT) }
|
||||
}
|
||||
binding.buttonRetry.setOnClickListener {
|
||||
onRetry().also { tapped(SEND_FINAL_RETRY) }
|
||||
}
|
||||
binding.backButtonHitArea.setOnClickListener {
|
||||
onExit().also { tapped(SEND_FINAL_CLOSE) }
|
||||
}
|
||||
binding.textConfirmation.text =
|
||||
"Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
|
||||
sendViewModel.memo.trim().isNotEmpty().let { hasMemo ->
|
||||
binding.radioIncludeAddress.isChecked = hasMemo
|
||||
binding.radioIncludeAddress.goneIf(!hasMemo)
|
||||
}
|
||||
mainActivity?.preventBackPress(this)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
mainActivity?.apply {
|
||||
sendViewModel.send().onEach {
|
||||
onPendingTxUpdated(it)
|
||||
}.launchIn(mainActivity?.lifecycleScope!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
flow {
|
||||
val max = binding.progressHorizontal.max - 1
|
||||
var progress = 0
|
||||
while (progress < max) {
|
||||
emit(progress)
|
||||
delay(Random.nextLong(1000))
|
||||
progress++
|
||||
}
|
||||
}.onEach {
|
||||
binding.progressHorizontal.progress = it
|
||||
}.launchIn(resumedScope)
|
||||
}
|
||||
|
||||
private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) {
|
||||
try {
|
||||
if (pendingTransaction != null) sendViewModel.updateMetrics(pendingTransaction)
|
||||
val id = pendingTransaction?.id ?: -1
|
||||
var isSending = true
|
||||
var isFailure = false
|
||||
var step: Report.Funnel.Send? = null
|
||||
val message = when {
|
||||
pendingTransaction == null -> "Transaction not found".also { step = Report.Funnel.Send.ErrorNotFound }
|
||||
pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false; step = Report.Funnel.Send.Mined(pendingTransaction.minedHeight) }
|
||||
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . .".also { step = Report.Funnel.Send.Submitted }
|
||||
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorEncoding(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
|
||||
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorSubmitting(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
|
||||
pendingTransaction.isCreated() -> "Transaction creation complete!".also { step = Report.Funnel.Send.Created(id) }
|
||||
pendingTransaction.isCreating() -> "Creating transaction . . .".also { step = Report.Funnel.Send.Creating }
|
||||
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
|
||||
}
|
||||
|
||||
sendViewModel.funnel(step)
|
||||
|
||||
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
|
||||
binding.textStatus.apply {
|
||||
text = "$message"
|
||||
}
|
||||
binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting"))
|
||||
binding.buttonNext.goneIf((pendingTransaction?.isSubmitSuccess() != true) && (pendingTransaction?.isCreated() != true) && !isFailure)
|
||||
binding.buttonNext.text = if (isSending) "Done" else "Finished"
|
||||
binding.buttonRetry.goneIf(!isFailure)
|
||||
binding.progressHorizontal.goneIf(!isSending)
|
||||
|
||||
|
||||
if (pendingTransaction?.isSubmitSuccess() == true) {
|
||||
sendViewModel.reset()
|
||||
}
|
||||
} catch(t: Throwable) {
|
||||
val message = "ERROR: error while handling pending transaction update! $t"
|
||||
twig(message)
|
||||
mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t))
|
||||
mainActivity?.feedback?.report(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onExit() {
|
||||
mainActivity?.navController?.popBackStack(R.id.nav_home, false)
|
||||
}
|
||||
|
||||
private fun onRetry() {
|
||||
mainActivity?.navController?.popBackStack(R.id.nav_send_address, false)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import cash.z.ecc.android.databinding.FragmentSendBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.ext.onClickNavUp
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
class SendFragment : BaseFragment<FragmentSendBinding>() {
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendBinding =
|
||||
FragmentSendBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.onClickNavUp()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class SendFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): SendFragment
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendMemoBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.ext.gone
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onClickNavTo
|
||||
import cash.z.ecc.android.ext.onEditorActionDone
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||
override val screen = Report.Screen.SEND_MEMO
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModel()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendMemoBinding =
|
||||
FragmentSendMemoBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonNext.setOnClickListener {
|
||||
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||
}
|
||||
binding.buttonSkip.setOnClickListener {
|
||||
onBottomButton().also { tapped(SEND_MEMO_SKIP) }
|
||||
}
|
||||
binding.clearMemo.setOnClickListener {
|
||||
onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
|
||||
}
|
||||
|
||||
R.id.action_nav_send_memo_to_nav_send_address.let {
|
||||
binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||
onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||
}
|
||||
|
||||
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _->
|
||||
onIncludeMemo(binding.checkIncludeAddress.isChecked)
|
||||
}
|
||||
|
||||
binding.inputMemo.let { memo ->
|
||||
memo.onEditorActionDone {
|
||||
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||
}
|
||||
memo.doAfterTextChanged {
|
||||
binding.clearMemo.goneIf(memo.text.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
sendViewModel.afterInitFromAddress {
|
||||
binding.textIncludedAddress.text = "sent from ${sendViewModel.fromAddress}"
|
||||
}
|
||||
|
||||
binding.textIncludedAddress.gone()
|
||||
|
||||
applyModel()
|
||||
}
|
||||
|
||||
private fun onClearMemo() {
|
||||
binding.inputMemo.setText("")
|
||||
}
|
||||
|
||||
private fun applyModel() {
|
||||
sendViewModel.isShielded.let { isShielded ->
|
||||
binding.groupShielded.goneIf(!isShielded)
|
||||
binding.groupTransparent.goneIf(isShielded)
|
||||
binding.textIncludedAddress.goneIf(!sendViewModel.includeFromAddress)
|
||||
if (isShielded) {
|
||||
binding.inputMemo.setText(sendViewModel.memo)
|
||||
binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress
|
||||
binding.buttonNext.text = "ADD MEMO"
|
||||
binding.buttonSkip.text = "OMIT MEMO"
|
||||
} else {
|
||||
binding.buttonNext.text = "GO BACK"
|
||||
binding.buttonSkip.text = "PROCEED"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onIncludeMemo(checked: Boolean) {
|
||||
|
||||
binding.textIncludedAddress.goneIf(!checked)
|
||||
sendViewModel.includeFromAddress = checked
|
||||
binding.textInfoShielded.text = if (checked) {
|
||||
tapped(SEND_MEMO_INCLUDE)
|
||||
getString(R.string.send_memo_included_message)
|
||||
} else {
|
||||
tapped(SEND_MEMO_EXCLUDE)
|
||||
getString(R.string.send_memo_excluded_message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTopButton() {
|
||||
if (sendViewModel.isShielded) {
|
||||
sendViewModel.memo = binding.inputMemo.text.toString()
|
||||
onNext()
|
||||
} else {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_nav_send_address)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBottomButton() {
|
||||
binding.inputMemo.setText("")
|
||||
sendViewModel.memo = ""
|
||||
sendViewModel.includeFromAddress = false
|
||||
onNext()
|
||||
}
|
||||
|
||||
private fun onNext() {
|
||||
sendViewModel.funnel(Send.MemoPageComplete)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.Feedback.Keyed
|
||||
import cash.z.ecc.android.feedback.Feedback.TimeMetric
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send.SendSelected
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send.SpendingKeyFound
|
||||
import cash.z.ecc.android.feedback.Report.Issue
|
||||
import cash.z.ecc.android.feedback.Report.MetricType
|
||||
import cash.z.ecc.android.feedback.Report.MetricType.*
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import cash.z.ecc.android.sdk.validate.AddressType
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val metrics = mutableMapOf<String, TimeMetric>()
|
||||
|
||||
@Inject
|
||||
lateinit var lockBox: LockBox
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
@Inject
|
||||
lateinit var initializer: Initializer
|
||||
|
||||
@Inject
|
||||
lateinit var feedback: Feedback
|
||||
|
||||
var fromAddress: String = ""
|
||||
var toAddress: String = ""
|
||||
var memo: String = ""
|
||||
var zatoshiAmount: Long = -1L
|
||||
var includeFromAddress: Boolean = false
|
||||
set(value) {
|
||||
require(!value || (value && !fromAddress.isNullOrEmpty())) {
|
||||
"Error: fromAddress was empty while attempting to include it in the memo. Verify" +
|
||||
" that initFromAddress() has previously been called on this viewmodel."
|
||||
}
|
||||
field = value
|
||||
}
|
||||
val isShielded get() = toAddress.startsWith("z")
|
||||
|
||||
fun send(): Flow<PendingTransaction> {
|
||||
funnel(SendSelected)
|
||||
val memoToSend = createMemoToSend()
|
||||
val keys = initializer.deriveSpendingKeys(
|
||||
lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!!
|
||||
)
|
||||
funnel(SpendingKeyFound)
|
||||
reportIssues(memoToSend)
|
||||
return synchronizer.sendToAddress(
|
||||
keys[0],
|
||||
zatoshiAmount,
|
||||
toAddress,
|
||||
memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: ""
|
||||
).onEach {
|
||||
twig(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun createMemoToSend() = if (includeFromAddress) "$memo\n$INCLUDE_MEMO_PREFIX\n$fromAddress" else memo
|
||||
|
||||
private fun reportIssues(memoToSend: String) {
|
||||
if (toAddress == fromAddress) feedback.report(Issue.SelfSend)
|
||||
when {
|
||||
zatoshiAmount < ZcashSdk.MINERS_FEE_ZATOSHI -> feedback.report(Issue.TinyAmount)
|
||||
zatoshiAmount < 100 -> feedback.report(Issue.MicroAmount)
|
||||
zatoshiAmount == 1L -> feedback.report(Issue.MinimumAmount)
|
||||
}
|
||||
memoToSend.length.also {
|
||||
when {
|
||||
it > ZcashSdk.MAX_MEMO_SIZE -> feedback.report(Issue.TruncatedMemo(it))
|
||||
it > (ZcashSdk.MAX_MEMO_SIZE * 0.96) -> feedback.report(Issue.LargeMemo(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validateAddress(address: String): AddressType =
|
||||
synchronizer.validateAddress(address)
|
||||
|
||||
fun validate(maxZatoshi: Long?) = flow<String?> {
|
||||
|
||||
when {
|
||||
synchronizer.validateAddress(toAddress).isNotValid -> {
|
||||
emit("Please enter a valid address.")
|
||||
}
|
||||
zatoshiAmount < 1 -> {
|
||||
emit("Please enter at least 1 Zatoshi.")
|
||||
}
|
||||
maxZatoshi != null && zatoshiAmount > maxZatoshi -> {
|
||||
emit( "Please enter no more than ${maxZatoshi.convertZatoshiToZecString(8)} ZEC.")
|
||||
}
|
||||
createMemoToSend().length > ZcashSdk.MAX_MEMO_SIZE -> {
|
||||
emit( "Memo must be less than ${ZcashSdk.MAX_MEMO_SIZE} in length.")
|
||||
}
|
||||
else -> emit(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun afterInitFromAddress(block: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
fromAddress = synchronizer.getAddress()
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
fromAddress = ""
|
||||
toAddress = ""
|
||||
memo = ""
|
||||
zatoshiAmount = -1L
|
||||
includeFromAddress = false
|
||||
}
|
||||
|
||||
fun updateMetrics(tx: PendingTransaction) {
|
||||
try {
|
||||
when {
|
||||
tx.isMined() -> TRANSACTION_SUBMITTED to TRANSACTION_MINED by tx.id
|
||||
tx.isSubmitSuccess() -> TRANSACTION_CREATED to TRANSACTION_SUBMITTED by tx.id
|
||||
tx.isCreated() -> TRANSACTION_INITIALIZED to TRANSACTION_CREATED by tx.id
|
||||
tx.isCreating() -> +TRANSACTION_INITIALIZED by tx.id
|
||||
else -> null
|
||||
}?.let { metricId ->
|
||||
report(metricId)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
feedback.report(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun report(metricId: String?) {
|
||||
metrics[metricId]?.let { metric ->
|
||||
metric.takeUnless { (it.elapsedTime ?: 0) <= 0L }?.let {
|
||||
viewModelScope.launch {
|
||||
withContext(IO) {
|
||||
feedback.report(metric)
|
||||
|
||||
// does this metric complete another metric?
|
||||
metricId!!.toRelatedMetricId().let { relatedId ->
|
||||
metrics[relatedId]?.let { relatedMetric ->
|
||||
// then remove the related metric, itself. And the relation.
|
||||
metrics.remove(relatedMetric.toMetricIdFor(metricId!!.toTxId()))
|
||||
metrics.remove(relatedId)
|
||||
}
|
||||
}
|
||||
|
||||
// remove all top-level metrics
|
||||
if (metric.key == Report.MetricType.TRANSACTION_MINED.key) metrics.remove(metricId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun funnel(step: Report.Funnel.Send?) {
|
||||
step ?: return
|
||||
feedback.report(step)
|
||||
}
|
||||
|
||||
private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime()
|
||||
private infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this }
|
||||
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
|
||||
val startMetric = first.toMetricIdFor(txId).let { metricId ->
|
||||
metrics[metricId].also { if (it == null) println("Warning no start metric for id: $metricId") }
|
||||
}
|
||||
return startMetric?.endTime?.let { startMetricEndTime ->
|
||||
TimeMetric(second.key, second.description, mutableListOf(startMetricEndTime))
|
||||
.markTime().let { endMetric ->
|
||||
endMetric.toMetricIdFor(txId).also { metricId ->
|
||||
metrics[metricId] = endMetric
|
||||
metrics[metricId.toRelatedMetricId()] = startMetric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
|
||||
private fun String.toRelatedMetricId(): String = "$this.related"
|
||||
private fun String.toTxId(): Long = split('.').first().toLong()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -8,34 +8,34 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.activity.addCallback
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentBackupBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.ext.onClick
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.MetricType.SEED_PHRASE_LOADED
|
||||
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_DONE
|
||||
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_VERIFY
|
||||
import cash.z.ecc.android.feedback.measure
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_BACKUP
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
||||
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||
override val screen = Report.Screen.BACKUP
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
|
||||
val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||
|
||||
private var hasBackUp: Boolean? = null
|
||||
private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentBackupBinding =
|
||||
FragmentBackupBinding.inflate(inflater)
|
||||
|
@ -55,12 +55,19 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
|||
)
|
||||
}
|
||||
binding.buttonPositive.setOnClickListener {
|
||||
onEnterWallet()
|
||||
onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
|
||||
}
|
||||
if (hasBackUp == true) {
|
||||
if (hasBackUp) {
|
||||
binding.buttonPositive.text = "Done"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.onBackPressedDispatcher?.addCallback(this) {
|
||||
onEnterWallet(false)
|
||||
}
|
||||
}
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
walletSetup.checkSeed().onEach {
|
||||
|
@ -72,14 +79,21 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
|||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun onEnterWallet() {
|
||||
if (hasBackUp != true) {
|
||||
Toast.makeText(activity, "Backup verification coming soon!", Toast.LENGTH_LONG).show()
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumedScope.launch {
|
||||
binding.textBirtdate.text = "Birthday Height: %,d".format(walletSetup.loadBirthdayHeight())
|
||||
}
|
||||
mainActivity?.navController?.popBackStack(R.id.wallet_setup_navigation, true)
|
||||
}
|
||||
|
||||
private fun applySpan(vararg textViews: TextView) {
|
||||
private fun onEnterWallet(showMessage: Boolean = !this.hasBackUp) {
|
||||
if (showMessage) {
|
||||
Toast.makeText(activity, "Backup verification coming soon!", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
|
||||
private fun applySpan(vararg textViews: TextView) = lifecycleScope.launch {
|
||||
val words = loadSeedWords()
|
||||
val thinSpace = "\u2005" // 0.25 em space
|
||||
textViews.forEachIndexed { index, textView ->
|
||||
|
@ -92,18 +106,13 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadSeedWords(): List<CharArray> {
|
||||
val lockBox = LockBox(ZcashWalletApp.instance)
|
||||
val mnemonics = Mnemonics()
|
||||
val seed = lockBox.getBytes(LockBoxKey.SEED)!!
|
||||
return mnemonics.nextMnemonicList(seed)
|
||||
private suspend fun loadSeedWords(): List<CharArray> = withContext(Dispatchers.IO) {
|
||||
mainActivity!!.feedback.measure(SEED_PHRASE_LOADED) {
|
||||
val lockBox = LockBox(ZcashWalletApp.instance)
|
||||
val mnemonics = Mnemonics()
|
||||
val seedPhrase = lockBox.getCharsUtf8(LockBoxKey.SEED_PHRASE)!!
|
||||
val result = mnemonics.toWordList(seedPhrase)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class BackupFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): BackupFragment
|
||||
}
|
|
@ -6,33 +6,29 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentLandingBinding
|
||||
import cash.z.ecc.android.di.annotation.FragmentScope
|
||||
import cash.z.ecc.android.feedback.MetricType.SEED_CREATION
|
||||
import cash.z.ecc.android.feedback.measure
|
||||
import cash.z.ecc.android.isEmulator
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_BACKUP
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||
override val screen = Report.Screen.LANDING
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
|
||||
private var skipCount: Int = 0
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentLandingBinding =
|
||||
|
@ -42,13 +38,37 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
|||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonPositive.setOnClickListener {
|
||||
when (binding.buttonPositive.text.toString().toLowerCase()) {
|
||||
"new" -> onNewWallet()
|
||||
"backup" -> onBackupWallet()
|
||||
"new" -> onNewWallet().also { tapped(LANDING_NEW) }
|
||||
"backup" -> onBackupWallet().also { tapped(LANDING_BACKUP) }
|
||||
}
|
||||
}
|
||||
binding.buttonNegative.setOnLongClickListener {
|
||||
tapped(DEVELOPER_WALLET_PROMPT)
|
||||
if (binding.buttonNegative.text.toString().toLowerCase() == "restore") {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 0.0001 ZEC at a time and return some later so that the account remains funded.")
|
||||
.setTitle("Import Dev Wallet?")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Import") { dialog, _ ->
|
||||
tapped(DEVELOPER_WALLET_IMPORT)
|
||||
dialog.dismiss()
|
||||
onUseDevWallet()
|
||||
}
|
||||
.setNegativeButton("Cancel") { dialog, _ ->
|
||||
tapped(DEVELOPER_WALLET_CANCEL)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
true
|
||||
}
|
||||
false
|
||||
}
|
||||
binding.buttonNegative.setOnClickListener {
|
||||
when (binding.buttonNegative.text.toString().toLowerCase()) {
|
||||
"restore" -> onRestoreWallet()
|
||||
"restore" -> onRestoreWallet().also {
|
||||
mainActivity?.reportFunnel(Restore.Initiated)
|
||||
tapped(LANDING_RESTORE)
|
||||
}
|
||||
else -> onSkip(++skipCount)
|
||||
}
|
||||
}
|
||||
|
@ -59,64 +79,83 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
|||
walletSetup.checkSeed().onEach {
|
||||
when(it) {
|
||||
SEED_WITHOUT_BACKUP, SEED_WITH_BACKUP -> {
|
||||
mainActivity?.navController?.navigate(R.id.nav_backup)
|
||||
mainActivity?.safeNavigate(R.id.nav_backup)
|
||||
}
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.hideKeyboard()
|
||||
}
|
||||
|
||||
private fun onSkip(count: Int) {
|
||||
when (count) {
|
||||
1 -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_1)
|
||||
binding.textMessage.text =
|
||||
"Are you sure? Without a backup, funds can be lost FOREVER!"
|
||||
binding.buttonNegative.text = "Later"
|
||||
}
|
||||
2 -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_2)
|
||||
binding.textMessage.text =
|
||||
"You can't backup later. You're probably going to lose your funds!"
|
||||
binding.buttonNegative.text = "I've been warned"
|
||||
}
|
||||
else -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_3)
|
||||
onEnterWallet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreWallet() {
|
||||
if (ZcashWalletApp.instance.isEmulator()) {
|
||||
onEnterWallet()
|
||||
} else {
|
||||
Toast.makeText(activity, "Coming soon!", Toast.LENGTH_SHORT).show()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_landing_to_nav_restore)
|
||||
}
|
||||
|
||||
// AKA import wallet
|
||||
private fun onUseDevWallet() {
|
||||
val seedPhrase = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread"
|
||||
val birthday = 663174//626599
|
||||
mainActivity?.apply {
|
||||
lifecycleScope.launch {
|
||||
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
|
||||
}
|
||||
binding.buttonPositive.isEnabled = true
|
||||
binding.textMessage.text = "Wallet imported! Congratulations!"
|
||||
binding.buttonNegative.text = "Skip"
|
||||
binding.buttonPositive.text = "Backup"
|
||||
playSound("sound_receive_small.mp3")
|
||||
vibrateSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewWallet() {
|
||||
mainActivity?.feedback?.measure(SEED_CREATION) {
|
||||
walletSetup.createSeed()
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
val ogText = binding.buttonPositive.text
|
||||
binding.buttonPositive.text = "creating"
|
||||
binding.buttonPositive.isEnabled = false
|
||||
|
||||
binding.textMessage.text = "Wallet created! Congratulations!"
|
||||
binding.buttonNegative.text = "Skip"
|
||||
binding.buttonPositive.text = "Backup"
|
||||
mainActivity?.playSound("sound_receive_small.mp3")
|
||||
mainActivity?.vibrateSuccess()
|
||||
mainActivity?.startSync(walletSetup.newWallet())
|
||||
|
||||
binding.buttonPositive.isEnabled = true
|
||||
binding.textMessage.text = "Wallet created! Congratulations!"
|
||||
binding.buttonNegative.text = "Skip"
|
||||
binding.buttonPositive.text = "Backup"
|
||||
mainActivity?.playSound("sound_receive_small.mp3")
|
||||
mainActivity?.vibrateSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBackupWallet() {
|
||||
skipCount = 0
|
||||
mainActivity?.navController?.navigate(R.id.action_nav_landing_to_nav_backup)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_landing_to_nav_backup)
|
||||
}
|
||||
|
||||
private fun onEnterWallet() {
|
||||
skipCount = 0
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class LandingFragmentModule {
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFragment(): LandingFragment
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.text.InputType
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.MotionEvent.ACTION_DOWN
|
||||
import android.view.MotionEvent.ACTION_UP
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentRestoreBinding
|
||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.showInvalidSeedPhraseError
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.tylersuehr.chips.Chip
|
||||
import com.tylersuehr.chips.ChipsAdapter
|
||||
import com.tylersuehr.chips.SeedWordAdapter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener {
|
||||
override val screen = Report.Screen.RESTORE
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||
|
||||
private lateinit var seedWordRecycler: RecyclerView
|
||||
private var seedWordAdapter: SeedWordAdapter? = null
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentRestoreBinding =
|
||||
FragmentRestoreBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
seedWordRecycler = binding.chipsInput.findViewById<RecyclerView>(R.id.chips_recycler)
|
||||
seedWordAdapter = SeedWordAdapter(seedWordRecycler.adapter as ChipsAdapter).onDataSetChanged {
|
||||
onChipsModified()
|
||||
}.also { onChipsModified() }
|
||||
seedWordRecycler.adapter = seedWordAdapter
|
||||
|
||||
|
||||
binding.chipsInput.apply {
|
||||
setFilterableChipList(getChips())
|
||||
setDelimiter("[ ;,]", true)
|
||||
}
|
||||
|
||||
binding.buttonDone.setOnClickListener {
|
||||
onDone().also { tapped(RESTORE_DONE) }
|
||||
}
|
||||
|
||||
binding.buttonSuccess.setOnClickListener {
|
||||
onEnterWallet().also { tapped(RESTORE_SUCCESS) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.onFragmentBackPressed(this) {
|
||||
tapped(RESTORE_BACK)
|
||||
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
|
||||
onExit()
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setMessage("Are you sure? For security, the words that you have entered will be cleared!")
|
||||
.setTitle("Abort?")
|
||||
.setPositiveButton("Stay") { dialog, _ ->
|
||||
mainActivity?.reportFunnel(Restore.Stay)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onExit()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Require one less tap to enter the seed words
|
||||
touchScreenForUser()
|
||||
}
|
||||
|
||||
|
||||
private fun onExit() {
|
||||
mainActivity?.reportFunnel(Restore.Exit)
|
||||
hideAutoCompleteWords()
|
||||
mainActivity?.hideKeyboard()
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
|
||||
private fun onEnterWallet() {
|
||||
mainActivity?.reportFunnel(Restore.Success)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
|
||||
}
|
||||
|
||||
private fun onDone() {
|
||||
mainActivity?.reportFunnel(Restore.Done)
|
||||
mainActivity?.hideKeyboard()
|
||||
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
|
||||
it.title
|
||||
}
|
||||
var birthday = binding.root.findViewById<TextView>(R.id.input_birthdate).text.toString()
|
||||
.let { birthdateString ->
|
||||
if (birthdateString.isNullOrEmpty()) ZcashSdk.SAPLING_ACTIVATION_HEIGHT else birthdateString.toInt()
|
||||
}.coerceAtLeast(ZcashSdk.SAPLING_ACTIVATION_HEIGHT)
|
||||
|
||||
try {
|
||||
walletSetup.validatePhrase(seedPhrase)
|
||||
importWallet(seedPhrase, birthday)
|
||||
} catch (t: Throwable) {
|
||||
mainActivity?.showInvalidSeedPhraseError(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importWallet(seedPhrase: String, birthday: Int) {
|
||||
mainActivity?.reportFunnel(Restore.ImportStarted)
|
||||
mainActivity?.hideKeyboard()
|
||||
mainActivity?.apply {
|
||||
lifecycleScope.launch {
|
||||
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
|
||||
// bugfix: if the user proceeds before the synchronizer is created the app will crash!
|
||||
binding.buttonSuccess.isEnabled = true
|
||||
mainActivity?.reportFunnel(Restore.ImportCompleted)
|
||||
}
|
||||
playSound("sound_receive_small.mp3")
|
||||
vibrateSuccess()
|
||||
}
|
||||
|
||||
binding.groupDone.visibility = View.GONE
|
||||
binding.groupStart.visibility = View.GONE
|
||||
binding.groupSuccess.visibility = View.VISIBLE
|
||||
binding.buttonSuccess.isEnabled = false
|
||||
}
|
||||
|
||||
private fun onChipsModified() {
|
||||
seedWordAdapter?.editText?.apply {
|
||||
postDelayed({
|
||||
requestFocus()
|
||||
},40L)
|
||||
}
|
||||
setDoneEnabled()
|
||||
|
||||
view!!.postDelayed({
|
||||
mainActivity!!.showKeyboard(seedWordAdapter!!.editText)
|
||||
seedWordAdapter?.editText?.requestFocus()
|
||||
}, 500L)
|
||||
}
|
||||
|
||||
private fun setDoneEnabled() {
|
||||
val count = seedWordAdapter?.itemCount ?: 0
|
||||
reportWords(count - 1) // subtract 1 for the editText
|
||||
binding.groupDone.goneIf(count <= 24)
|
||||
}
|
||||
|
||||
private fun reportWords(count: Int) {
|
||||
mainActivity?.run {
|
||||
// reportFunnel(Restore.SeedWordCount(count))
|
||||
if (count == 1) {
|
||||
reportFunnel(Restore.SeedWordsStarted)
|
||||
} else if (count == 24) {
|
||||
reportFunnel(Restore.SeedWordsCompleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideAutoCompleteWords() {
|
||||
seedWordAdapter?.editText?.setText("")
|
||||
}
|
||||
|
||||
private fun getChips(): List<Chip> {
|
||||
return resources.getStringArray(R.array.word_list).map {
|
||||
SeedWordChip(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun touchScreenForUser() {
|
||||
seedWordAdapter?.editText?.apply {
|
||||
postDelayed({
|
||||
seedWordAdapter?.editText?.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
dispatchTouchEvent(motionEvent(ACTION_DOWN))
|
||||
dispatchTouchEvent(motionEvent(ACTION_UP))
|
||||
}, 100L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun motionEvent(action: Int) = SystemClock.uptimeMillis().let { now ->
|
||||
MotionEvent.obtain(now, now, action, 0f, 0f, 0)
|
||||
}
|
||||
|
||||
override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SeedWordChip(val word: String, var index: Int = -1) : Chip() {
|
||||
override fun getSubtitle(): String? = null//"subtitle for $word"
|
||||
override fun getAvatarDrawable(): Drawable? = null
|
||||
override fun getId() = index
|
||||
override fun getTitle() = word
|
||||
override fun getAvatarUri() = null
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package com.tylersuehr.chips
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ext.toAppColor
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import cash.z.ecc.android.ui.setup.SeedWordChip
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
|
||||
class SeedWordAdapter : ChipsAdapter {
|
||||
|
||||
constructor(existingAdapter: ChipsAdapter) : super(existingAdapter.mDataSource, existingAdapter.mEditText, existingAdapter.mOptions)
|
||||
|
||||
val editText = mEditText
|
||||
private var onDataSetChangedListener: (() -> Unit)? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == CHIP) SeedWordHolder(SeedWordChipView(parent.context))
|
||||
else object : RecyclerView.ViewHolder(mEditText) {}
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (getItemViewType(position) == CHIP) { // Chips
|
||||
// Display the chip information on the chip view
|
||||
(holder as SeedWordHolder).seedChipView.bind(mDataSource.getSelectedChip(position), position);
|
||||
} else {
|
||||
val size = mDataSource.selectedChips.size
|
||||
mEditText.hint = if (size < 3) {
|
||||
mEditText.isEnabled = true
|
||||
mEditText.setHintTextColor(R.color.text_light_dimmed.toAppColor())
|
||||
val ordinal = when(size) {2 -> "3rd"; 1 -> "2nd"; else -> "1st"}
|
||||
"Enter $ordinal seed word"
|
||||
} else if(size >= 24) {
|
||||
mEditText.setHintTextColor(R.color.zcashGreen.toAppColor())
|
||||
mEditText.isEnabled = false
|
||||
"done"
|
||||
} else {
|
||||
mEditText.isEnabled = true
|
||||
mEditText.setHintTextColor(R.color.zcashYellow.toAppColor())
|
||||
"${size + 1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipDataSourceChanged() {
|
||||
super.onChipDataSourceChanged()
|
||||
onDataSetChangedListener?.invoke()
|
||||
}
|
||||
|
||||
fun onDataSetChanged(block: () -> Unit): SeedWordAdapter {
|
||||
onDataSetChangedListener = block
|
||||
return this
|
||||
}
|
||||
|
||||
override fun onKeyboardActionDone(text: String?) {
|
||||
if (TextUtils.isEmpty(text)) return
|
||||
|
||||
if (mDataSource.originalChips.firstOrNull { it.title == text } != null) {
|
||||
mDataSource.addSelectedChip(DefaultCustomChip(text))
|
||||
mEditText.apply {
|
||||
postDelayed({
|
||||
setText("")
|
||||
requestFocus()
|
||||
}, 50L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardDelimiter(text: String) {
|
||||
if (mDataSource.filteredChips.size > 0) {
|
||||
onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
|
||||
val seedChipView = super.chipView as SeedWordChipView
|
||||
}
|
||||
|
||||
private inner class SeedWordChipView(context: Context) : ChipView(context) {
|
||||
private val indexView: TextView = findViewById(R.id.chip_index)
|
||||
|
||||
fun bind(chip: Chip, index: Int) {
|
||||
super.inflateFromChip(chip)
|
||||
indexView.text = (index + 1).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,34 @@
|
|||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.Report.MetricType.*
|
||||
import cash.z.ecc.android.feedback.measure
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.HAS_BACKUP
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.HAS_SEED
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.SEED
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore
|
||||
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore.Companion.ImportedWalletBirthdayStore
|
||||
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore.Companion.NewWalletBirthdayStore
|
||||
import cash.z.ecc.android.sdk.ext.twig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class WalletSetupViewModel @Inject constructor(val mnemonics: Mnemonics, val lockBox: LockBox) :
|
||||
ViewModel() {
|
||||
class WalletSetupViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var mnemonics: Mnemonics
|
||||
|
||||
@Inject
|
||||
lateinit var lockBox: LockBox
|
||||
|
||||
@Inject
|
||||
lateinit var feedback: Feedback
|
||||
|
||||
enum class WalletSetupState {
|
||||
UNKNOWN, SEED_WITH_BACKUP, SEED_WITHOUT_BACKUP, NO_SEED
|
||||
|
@ -20,27 +36,124 @@ class WalletSetupViewModel @Inject constructor(val mnemonics: Mnemonics, val loc
|
|||
|
||||
fun checkSeed(): Flow<WalletSetupState> = flow {
|
||||
when {
|
||||
lockBox.getBoolean(HAS_BACKUP) -> emit(SEED_WITH_BACKUP)
|
||||
lockBox.getBoolean(HAS_SEED) -> emit(SEED_WITHOUT_BACKUP)
|
||||
lockBox.getBoolean(LockBoxKey.HAS_BACKUP) -> emit(SEED_WITH_BACKUP)
|
||||
lockBox.getBoolean(LockBoxKey.HAS_SEED) -> emit(SEED_WITHOUT_BACKUP)
|
||||
else -> emit(NO_SEED)
|
||||
}
|
||||
}
|
||||
|
||||
fun createSeed() {
|
||||
check(!lockBox.getBoolean(HAS_SEED)) {
|
||||
/**
|
||||
* Re-open an existing wallet. This is the most common use case, where a user has previously
|
||||
* created or imported their seed and is returning to the wallet. In other words, this is the
|
||||
* non-FTUE case.
|
||||
*/
|
||||
fun openWallet(): Initializer {
|
||||
twig("Opening existing wallet")
|
||||
return ZcashWalletApp.component.initializerSubcomponent()
|
||||
.create(DefaultBirthdayStore(ZcashWalletApp.instance)).run {
|
||||
initializer().open(birthdayStore().getBirthday())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun newWallet(): Initializer {
|
||||
twig("Initializing new wallet")
|
||||
return ZcashWalletApp.component.initializerSubcomponent()
|
||||
.create(NewWalletBirthdayStore(ZcashWalletApp.instance)).run {
|
||||
initializer().apply {
|
||||
new(createWallet(), birthdayStore().getBirthday())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importWallet(seedPhrase: String, birthdayHeight: Int): Initializer {
|
||||
twig("Importing wallet. Requested birthday: $birthdayHeight")
|
||||
return ZcashWalletApp.component.initializerSubcomponent()
|
||||
.create(ImportedWalletBirthdayStore(ZcashWalletApp.instance, birthdayHeight)).run {
|
||||
initializer().apply {
|
||||
import(importWallet(seedPhrase.toCharArray()), birthdayStore().getBirthday())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take all the steps necessary to create a new wallet and measure how long it takes.
|
||||
*
|
||||
* @param feedback the object used for measurement.
|
||||
*/
|
||||
private suspend fun createWallet(): ByteArray = withContext(Dispatchers.IO) {
|
||||
check(!lockBox.getBoolean(LockBoxKey.HAS_SEED)) {
|
||||
"Error! Cannot create a seed when one already exists! This would overwrite the" +
|
||||
" existing seed and could lead to a loss of funds if the user has no backup!"
|
||||
}
|
||||
|
||||
mnemonics.apply {
|
||||
lockBox.setBytes(SEED, nextSeed())
|
||||
lockBox.setBoolean(HAS_SEED, true)
|
||||
feedback.measure(WALLET_CREATED) {
|
||||
mnemonics.run {
|
||||
feedback.measure(ENTROPY_CREATED) { nextEntropy() }.let { entropy ->
|
||||
feedback.measure(SEED_PHRASE_CREATED) { nextMnemonic(entropy) }
|
||||
.let { seedPhrase ->
|
||||
feedback.measure(SEED_CREATED) { toSeed(seedPhrase) }.let { bip39Seed ->
|
||||
|
||||
lockBox.setCharsUtf8(LockBoxKey.SEED_PHRASE, seedPhrase)
|
||||
lockBox.setBoolean(LockBoxKey.HAS_SEED_PHRASE, true)
|
||||
|
||||
lockBox.setBytes(LockBoxKey.SEED, bip39Seed)
|
||||
lockBox.setBoolean(LockBoxKey.HAS_SEED, true)
|
||||
|
||||
bip39Seed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadBirthdayHeight(): Int = withContext(Dispatchers.IO) {
|
||||
DefaultBirthdayStore(ZcashWalletApp.instance).getBirthday().height
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Take all the steps necessary to import a wallet and measure how long it takes.
|
||||
*
|
||||
* @param feedback the object used for measurement.
|
||||
*/
|
||||
private suspend fun importWallet(
|
||||
seedPhrase: CharArray
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
check(!lockBox.getBoolean(LockBoxKey.HAS_SEED)) {
|
||||
"Error! Cannot import a seed when one already exists! This would overwrite the" +
|
||||
" existing seed and could lead to a loss of funds if the user has no backup!"
|
||||
}
|
||||
|
||||
feedback.measure(WALLET_IMPORTED) {
|
||||
mnemonics.run {
|
||||
feedback.measure(SEED_IMPORTED) { toSeed(seedPhrase) }.let { bip39Seed ->
|
||||
|
||||
lockBox.setCharsUtf8(LockBoxKey.SEED_PHRASE, seedPhrase)
|
||||
lockBox.setBoolean(LockBoxKey.HAS_SEED_PHRASE, true)
|
||||
|
||||
lockBox.setBytes(LockBoxKey.SEED, bip39Seed)
|
||||
lockBox.setBoolean(LockBoxKey.HAS_SEED, true)
|
||||
|
||||
bip39Seed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an exception if the seed phrase is bad.
|
||||
*/
|
||||
fun validatePhrase(seedPhrase: String) {
|
||||
mnemonics.validate(seedPhrase.toCharArray())
|
||||
}
|
||||
|
||||
object LockBoxKey {
|
||||
const val SEED = "cash.z.ecc.android.SEED1"
|
||||
const val HAS_SEED = "cash.z.ecc.android.HAS_SEED1"
|
||||
const val HAS_BACKUP = "cash.z.ecc.android.HAS_BACKUP1"
|
||||
const val SEED = "cash.z.ecc.android.SEED"
|
||||
const val SEED_PHRASE = "cash.z.ecc.android.SEED_PHRASE"
|
||||
const val HAS_SEED = "cash.z.ecc.android.HAS_SEED"
|
||||
const val HAS_SEED_PHRASE = "cash.z.ecc.android.HAS_SEED_PHRASE"
|
||||
const val HAS_BACKUP = "cash.z.ecc.android.HAS_BACKUP"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package cash.z.ecc.android.ui.util
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
|
||||
const val INCLUDE_MEMO_PREFIX = "sent from"
|
||||
|
||||
inline fun ByteArray?.toUtf8Memo(): String {
|
||||
// TODO: make this more official but for now, this will do
|
||||
return if (this == null || this[0] >= 0xF5) "" else try {
|
||||
String(this, StandardCharsets.UTF_8).trim('\u0000')
|
||||
} catch (t: Throwable) {
|
||||
"unable to parse memo"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
if self.0[0] < 0xF5 {
|
||||
// Check if it is valid UTF8
|
||||
Some(str::from_utf8(&self.0).map(|memo| {
|
||||
// Drop trailing zeroes
|
||||
memo.trim_end_matches(char::from(0)).to_owned()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
*/
|
|
@ -0,0 +1,47 @@
|
|||
package cash.z.ecc.android.ui.util
|
||||
//
|
||||
//import android.Manifest
|
||||
//import android.content.Context
|
||||
//import android.content.pm.PackageManager
|
||||
//import android.os.Bundle
|
||||
//import android.widget.Toast
|
||||
//import androidx.core.content.ContextCompat
|
||||
//import androidx.fragment.app.Fragment
|
||||
//import cash.z.ecc.android.ui.MainActivity
|
||||
//
|
||||
//class PermissionFragment : Fragment() {
|
||||
//
|
||||
// val activity get() = context as MainActivity
|
||||
//
|
||||
// override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// super.onCreate(savedInstanceState)
|
||||
// if (!hasPermissions(activity)) {
|
||||
// requestPermissions(PERMISSIONS, REQUEST_CODE)
|
||||
// } else {
|
||||
// activity.openCamera()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onRequestPermissionsResult(
|
||||
// requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
// ) {
|
||||
// super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
//
|
||||
// if (requestCode == REQUEST_CODE) {
|
||||
// if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
// activity.openCamera()
|
||||
// } else {
|
||||
// Toast.makeText(context, "Camera request denied", Toast.LENGTH_LONG).show()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// companion object {
|
||||
// private const val REQUEST_CODE = 101
|
||||
// private val PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
|
||||
//
|
||||
// fun hasPermissions(context: Context) = PERMISSIONS.all {
|
||||
// ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
// }
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,20 @@
|
|||
package cash.z.ecc.android.ui.zircle
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import cash.z.ecc.android.databinding.FragmentZircleInviteBinding
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
|
||||
class InviteFragment : BaseFragment<FragmentZircleInviteBinding>() {
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentZircleInviteBinding =
|
||||
FragmentZircleInviteBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.onClickNavBack()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package cash.z.ecc.android.ui.zircle
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentZircleNewBinding
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
|
||||
class NewZircleFragment : BaseFragment<FragmentZircleNewBinding>() {
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentZircleNewBinding =
|
||||
FragmentZircleNewBinding.inflate(inflater)
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaScan.onClickNavBack()
|
||||
binding.buttonBottom.setOnClickListener {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_new_to_nav_zircle)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package cash.z.ecc.android.ui.zircle
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
|
||||
class ZircleAdapter<T : Zircler> :
|
||||
ListAdapter<T, ZirclerViewHolder>(
|
||||
object : DiffUtil.ItemCallback<T>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem.minedHeight == newItem.minedHeight && oldItem.noteId == newItem.noteId
|
||||
// bugfix: distinguish between self-transactions so they don't overwrite each other in the UI // TODO confirm that this is working, as intended
|
||||
&& ((oldItem.raw == null && newItem.raw == null) || (oldItem.raw != null && newItem.raw != null && oldItem.raw!!.contentEquals(newItem.raw!!)))
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem == newItem
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = ZirclerViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.item_transaction, parent, false)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: ZirclerViewHolder<T>,
|
||||
position: Int
|
||||
) = holder.bindTo(getItem(position))
|
||||
}
|
||||
|
||||
class ZirclerViewHolder {
|
||||
|
||||
}
|
||||
|
||||
data class Zircler(val nickname: String)
|
|
@ -0,0 +1,36 @@
|
|||
package cash.z.ecc.android.ui.zircle
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import cash.z.ecc.android.databinding.FragmentZircleDetailBinding
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.detail.TransactionAdapter
|
||||
import cash.z.ecc.android.ui.detail.TransactionsFooter
|
||||
|
||||
|
||||
class ZircleDetailFragment : BaseFragment<FragmentZircleDetailBinding>() {
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentZircleDetailBinding =
|
||||
FragmentZircleDetailBinding.inflate(inflater)
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.onClickNavBack()
|
||||
|
||||
initRecycler()
|
||||
}
|
||||
|
||||
private fun initRecycler() {
|
||||
binding.recyclerZirclers.apply {
|
||||
layoutManager =
|
||||
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
||||
adapter = TransactionAdapter()
|
||||
smoothScrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="0%" android:toXDelta="0%"
|
||||
android:fromYDelta="100%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="-100%" android:toXDelta="0%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="100%" android:toXDelta="0%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:interpolator="@android:interpolator/decelerate_cubic"
|
||||
android:fromXDelta="0%" android:toXDelta="-100%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300"/>
|
||||
</set>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:interpolator="@android:interpolator/decelerate_cubic"
|
||||
android:fromXDelta="0%" android:toXDelta="100%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300"/>
|
||||
</set>
|
|
@ -0,0 +1,6 @@
|
|||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0"
|
||||
android:duration="200"
|
||||
/>
|
|
@ -0,0 +1,6 @@
|
|||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0"
|
||||
android:duration="700"
|
||||
/>
|
|
@ -0,0 +1,6 @@
|
|||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="200"
|
||||
/>
|
|
@ -0,0 +1,6 @@
|
|||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="250"
|
||||
/>
|
|
@ -0,0 +1,6 @@
|
|||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="300"
|
||||
/>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="false" android:color="@color/text_dark"/>
|
||||
<item android:state_pressed="true" android:color="@color/text_light" />
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="false" android:color="@color/text_light_dimmed"/>
|
||||
<item android:state_pressed="true" android:color="@color/text_dark" />
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="false" android:color="@color/text_light"/>
|
||||
<item android:state_pressed="true" android:color="@color/text_light_dimmed" />
|
||||
</selector>
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#FFEB3B" android:state_focused="true" />
|
||||
<item android:color="#FF0000" android:state_enabled="true" />
|
||||
<item android:color="#43A047" android:state_activated="true" />
|
||||
<item android:color="#3949AB" android:state_active="true" />
|
||||
<item android:color="#00FFFF" android:state_selected="true" />
|
||||
<item android:color="#0040FF" android:state_checkable="true" />
|
||||
<item android:color="#F4511E" android:state_pressed="true" />
|
||||
<item android:color="#00ff00" android:state_checked="true" />
|
||||
<item android:color="#FFFFFF" />
|
||||
</selector>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:state_activated="false" android:color="@color/text_light"/>
|
||||
<item android:state_activated="true" android:color="@color/colorPrimary" />
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#DDE7F0" android:state_focused="true" />
|
||||
<item android:color="#E6F0F9" />
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/zircle_gradient_end" android:state_checked="true" />
|
||||
<item android:color="#6B616161" />
|
||||
</selector>
|
Binary file not shown.
After Width: | Height: | Size: 511 KiB |
Binary file not shown.
After Width: | Height: | Size: 242 KiB |
|
@ -0,0 +1,23 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M0,0h108v108h-108z"
|
||||
android:strokeWidth="1"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:gradientRadius="92.96752"
|
||||
android:centerX="54"
|
||||
android:centerY="36.01165"
|
||||
android:type="radial">
|
||||
<item android:offset="0" android:color="#FF3F3F4F"/>
|
||||
<item android:offset="1" android:color="#FF000000"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
|
@ -1,31 +1,102 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
android:width="108dp">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M78.6,53.6m-1.6,0a1.6,1.6 0,1 1,3.2 0a1.6,1.6 0,1 1,-3.2 0"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M28.6,53.6m-1.6,0a1.6,1.6 0,1 1,3.2 0a1.6,1.6 0,1 1,-3.2 0"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M54.4,78.6m-0,-1.6a1.6,1.6 0,1 1,-0 3.2a1.6,1.6 0,1 1,-0 -3.2"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M54.4,28.6m-0,-1.6a1.6,1.6 0,1 1,-0 3.2a1.6,1.6 0,1 1,-0 -3.2"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M77.8,54.601C77.8,54.79 77.799,54.963 77.799,54.963L46.564,54.967C46.755,58.942 50.012,62.117 53.999,62.117C56.367,62.117 58.475,60.995 59.842,59.255L60.739,59.255C59.264,61.419 56.794,62.843 53.999,62.843C49.752,62.843 46.262,59.557 45.872,55.375L45.862,55.261C45.855,55.164 45.855,55.065 45.851,54.966L39.9,54.967C39.769,55.519 39.351,55.953 38.814,56.109C39.569,63.892 46.087,69.996 54,69.996C60.81,69.996 66.592,65.475 68.553,59.254L69.305,59.254C67.32,65.881 61.211,70.721 54,70.721C45.704,70.721 38.873,64.313 38.097,56.149C37.49,56.035 37.009,55.569 36.866,54.967L32.082,54.968C31.949,55.527 31.522,55.965 30.974,56.116C31.752,68.246 41.779,77.875 54,77.875C65.147,77.875 74.471,69.865 76.615,59.254L77.346,59.254C75.19,70.267 65.545,78.6 54,78.6C41.392,78.6 31.048,68.661 30.254,56.146C29.538,56.001 29,55.364 29,54.6C29,53.836 29.539,53.199 30.254,53.054C31.048,40.539 41.391,30.6 54,30.6C67.003,30.601 77.597,41.172 77.791,54.239C77.793,54.36 77.8,54.48 77.8,54.601ZM77.072,54.239C76.878,41.572 66.606,31.327 53.999,31.327C41.778,31.327 31.75,40.957 30.973,53.085C31.522,53.236 31.95,53.677 32.082,54.239L36.863,54.239C37.005,53.635 37.487,53.167 38.095,53.053C38.871,44.888 45.702,38.48 53.999,38.48C61.069,38.48 67.078,43.134 69.182,49.561L68.422,49.561C66.347,43.541 60.667,39.206 53.999,39.206C46.086,39.206 39.568,45.31 38.813,53.092C39.351,53.25 39.77,53.685 39.9,54.239L45.845,54.239C45.852,54.091 45.86,53.961 45.86,53.961C46.186,49.716 49.706,46.36 53.999,46.36C56.623,46.36 58.955,47.617 60.451,49.561L59.513,49.561C58.148,48.045 56.185,47.085 53.999,47.085C50.01,47.085 46.752,50.263 46.562,54.239L46.562,54.239L77.072,54.239Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.5"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M77.8,53.601C77.8,53.79 77.799,53.963 77.799,53.963L46.564,53.967C46.755,57.942 50.012,61.117 53.999,61.117C56.367,61.117 58.475,59.995 59.842,58.255L60.739,58.255C59.264,60.419 56.794,61.843 53.999,61.843C49.752,61.843 46.262,58.557 45.872,54.375L45.862,54.261C45.855,54.164 45.855,54.065 45.851,53.966L39.9,53.967C39.769,54.519 39.351,54.953 38.814,55.109C39.569,62.892 46.087,68.996 54,68.996C60.81,68.996 66.592,64.475 68.553,58.254L69.305,58.254C67.32,64.881 61.211,69.721 54,69.721C45.704,69.721 38.873,63.313 38.097,55.149C37.49,55.035 37.009,54.569 36.866,53.967L32.082,53.968C31.949,54.527 31.522,54.965 30.974,55.116C31.752,67.246 41.779,76.875 54,76.875C65.147,76.875 74.471,68.865 76.615,58.254L77.346,58.254C75.19,69.267 65.545,77.6 54,77.6C41.392,77.6 31.048,67.661 30.254,55.146C29.538,55.001 29,54.364 29,53.6C29,52.836 29.539,52.199 30.254,52.054C31.048,39.539 41.391,29.6 54,29.6C67.003,29.601 77.597,40.172 77.791,53.239C77.793,53.36 77.8,53.48 77.8,53.601ZM77.072,53.239C76.878,40.572 66.606,30.327 53.999,30.327C41.778,30.327 31.75,39.957 30.973,52.085C31.522,52.236 31.95,52.677 32.082,53.239L36.863,53.239C37.005,52.635 37.487,52.167 38.095,52.053C38.871,43.888 45.702,37.48 53.999,37.48C61.069,37.48 67.078,42.134 69.182,48.561L68.422,48.561C66.347,42.541 60.667,38.206 53.999,38.206C46.086,38.206 39.568,44.31 38.813,52.092C39.351,52.25 39.77,52.685 39.9,53.239L45.845,53.239C45.852,53.091 45.86,52.961 45.86,52.961C46.186,48.716 49.706,45.36 53.999,45.36C56.623,45.36 58.955,46.617 60.451,48.561L59.513,48.561C58.148,47.045 56.185,46.085 53.999,46.085C50.01,46.085 46.752,49.263 46.562,53.239L46.562,53.239L77.072,53.239Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#FFB900"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M78.6,53.6m-1.6,0a1.6,1.6 0,1 1,3.2 0a1.6,1.6 0,1 1,-3.2 0"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M28.6,53.6m-1.6,0a1.6,1.6 0,1 1,3.2 0a1.6,1.6 0,1 1,-3.2 0"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M54.4,78.6m-0,-1.6a1.6,1.6 0,1 1,-0 3.2a1.6,1.6 0,1 1,-0 -3.2"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M54.4,28.6m-0,-1.6a1.6,1.6 0,1 1,-0 3.2a1.6,1.6 0,1 1,-0 -3.2"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#9013FE"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M77.8,54.601C77.8,54.79 77.799,54.963 77.799,54.963L46.564,54.967C46.755,58.942 50.012,62.117 53.999,62.117C56.367,62.117 58.475,60.995 59.842,59.255L60.739,59.255C59.264,61.419 56.794,62.843 53.999,62.843C49.752,62.843 46.262,59.557 45.872,55.375L45.862,55.261C45.855,55.164 45.855,55.065 45.851,54.966L39.9,54.967C39.769,55.519 39.351,55.953 38.814,56.109C39.569,63.892 46.087,69.996 54,69.996C60.81,69.996 66.592,65.475 68.553,59.254L69.305,59.254C67.32,65.881 61.211,70.721 54,70.721C45.704,70.721 38.873,64.313 38.097,56.149C37.49,56.035 37.009,55.569 36.866,54.967L32.082,54.968C31.949,55.527 31.522,55.965 30.974,56.116C31.752,68.246 41.779,77.875 54,77.875C65.147,77.875 74.471,69.865 76.615,59.254L77.346,59.254C75.19,70.267 65.545,78.6 54,78.6C41.392,78.6 31.048,68.661 30.254,56.146C29.538,56.001 29,55.364 29,54.6C29,53.836 29.539,53.199 30.254,53.054C31.048,40.539 41.391,30.6 54,30.6C67.003,30.601 77.597,41.172 77.791,54.239C77.793,54.36 77.8,54.48 77.8,54.601ZM77.072,54.239C76.878,41.572 66.606,31.327 53.999,31.327C41.778,31.327 31.75,40.957 30.973,53.085C31.522,53.236 31.95,53.677 32.082,54.239L36.863,54.239C37.005,53.635 37.487,53.167 38.095,53.053C38.871,44.888 45.702,38.48 53.999,38.48C61.069,38.48 67.078,43.134 69.182,49.561L68.422,49.561C66.347,43.541 60.667,39.206 53.999,39.206C46.086,39.206 39.568,45.31 38.813,53.092C39.351,53.25 39.77,53.685 39.9,54.239L45.845,54.239C45.852,54.091 45.86,53.961 45.86,53.961C46.186,49.716 49.706,46.36 53.999,46.36C56.623,46.36 58.955,47.617 60.451,49.561L59.513,49.561C58.148,48.045 56.185,47.085 53.999,47.085C50.01,47.085 46.752,50.263 46.562,54.239L46.562,54.239L77.072,54.239Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.5"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M77.8,53.601C77.8,53.79 77.799,53.963 77.799,53.963L46.564,53.967C46.755,57.942 50.012,61.117 53.999,61.117C56.367,61.117 58.475,59.995 59.842,58.255L60.739,58.255C59.264,60.419 56.794,61.843 53.999,61.843C49.752,61.843 46.262,58.557 45.872,54.375L45.862,54.261C45.855,54.164 45.855,54.065 45.851,53.966L39.9,53.967C39.769,54.519 39.351,54.953 38.814,55.109C39.569,62.892 46.087,68.996 54,68.996C60.81,68.996 66.592,64.475 68.553,58.254L69.305,58.254C67.32,64.881 61.211,69.721 54,69.721C45.704,69.721 38.873,63.313 38.097,55.149C37.49,55.035 37.009,54.569 36.866,53.967L32.082,53.968C31.949,54.527 31.522,54.965 30.974,55.116C31.752,67.246 41.779,76.875 54,76.875C65.147,76.875 74.471,68.865 76.615,58.254L77.346,58.254C75.19,69.267 65.545,77.6 54,77.6C41.392,77.6 31.048,67.661 30.254,55.146C29.538,55.001 29,54.364 29,53.6C29,52.836 29.539,52.199 30.254,52.054C31.048,39.539 41.391,29.6 54,29.6C67.003,29.601 77.597,40.172 77.791,53.239C77.793,53.36 77.8,53.48 77.8,53.601ZM77.072,53.239C76.878,40.572 66.606,30.327 53.999,30.327C41.778,30.327 31.75,39.957 30.973,52.085C31.522,52.236 31.95,52.677 32.082,53.239L36.863,53.239C37.005,52.635 37.487,52.167 38.095,52.053C38.871,43.888 45.702,37.48 53.999,37.48C61.069,37.48 67.078,42.134 69.182,48.561L68.422,48.561C66.347,42.541 60.667,38.206 53.999,38.206C46.086,38.206 39.568,44.31 38.813,52.092C39.351,52.25 39.77,52.685 39.9,53.239L45.845,53.239C45.852,53.091 45.86,52.961 45.86,52.961C46.186,48.716 49.706,45.36 53.999,45.36C56.623,45.36 58.955,46.617 60.451,48.561L59.513,48.561C58.148,47.045 56.185,46.085 53.999,46.085C50.01,46.085 46.752,49.263 46.562,53.239L46.562,53.239L77.072,53.239Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#FFB900"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M77.8,53.601C77.8,53.79 77.799,53.963 77.799,53.963L46.564,53.967C46.755,57.942 50.012,61.117 53.999,61.117C56.367,61.117 58.475,59.995 59.842,58.255L60.739,58.255C59.264,60.419 56.794,61.843 53.999,61.843C49.752,61.843 46.262,58.557 45.872,54.375L45.862,54.261C45.855,54.164 45.855,54.065 45.851,53.966L39.9,53.967C39.769,54.519 39.351,54.953 38.814,55.109C39.569,62.892 46.087,68.996 54,68.996C60.81,68.996 66.592,64.475 68.553,58.254L69.305,58.254C67.32,64.881 61.211,69.721 54,69.721C45.704,69.721 38.873,63.313 38.097,55.149C37.49,55.035 37.009,54.569 36.866,53.967L32.082,53.968C31.949,54.527 31.522,54.965 30.974,55.116C31.752,67.246 41.779,76.875 54,76.875C65.147,76.875 74.471,68.865 76.615,58.254L77.346,58.254C75.19,69.267 65.545,77.6 54,77.6C41.392,77.6 31.048,67.661 30.254,55.146C29.538,55.001 29,54.364 29,53.6C29,52.836 29.539,52.199 30.254,52.054C31.048,39.539 41.391,29.6 54,29.6C67.003,29.601 77.597,40.172 77.791,53.239C77.793,53.36 77.8,53.48 77.8,53.601ZM77.072,53.239C76.878,40.572 66.606,30.327 53.999,30.327C41.778,30.327 31.75,39.957 30.973,52.085C31.522,52.236 31.95,52.677 32.082,53.239L36.863,53.239C37.005,52.635 37.487,52.167 38.095,52.053C38.871,43.888 45.702,37.48 53.999,37.48C61.069,37.48 67.078,42.134 69.182,48.561L68.422,48.561C66.347,42.541 60.667,38.206 53.999,38.206C46.086,38.206 39.568,44.31 38.813,52.092C39.351,52.25 39.77,52.685 39.9,53.239L45.845,53.239C45.852,53.091 45.86,52.961 45.86,52.961C46.186,48.716 49.706,45.36 53.999,45.36C56.623,45.36 58.955,46.617 60.451,48.561L59.513,48.561C58.148,47.045 56.185,46.085 53.999,46.085C50.01,46.085 46.752,49.263 46.562,53.239L46.562,53.239L77.072,53.239Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#FFB900"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 841 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
After Width: | Height: | Size: 2.4 MiB |
|
@ -2,6 +2,6 @@
|
|||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="#282828"/>
|
||||
<stroke android:width="1dp" android:color="@color/background_banner_stroke"/>
|
||||
<solid android:color="@color/background_banner"/>
|
||||
</shape>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue