Compare commits

...

109 Commits

Author SHA1 Message Date
Kevin Gorham baf3522e89
Hackathon mid-point. 2020-06-27 03:46:47 -04:00
Kevin Gorham adda661311
remove analytics dependencies completely 2020-06-23 19:09:28 -04:00
Kevin Gorham a68981d788
Updated with helpful links that many will need. 2020-06-15 13:05:28 -04:00
Kevin Gorham 5d30a274fd
Merge pull request #164 from zcash/hotfix/seed-word-display
Fixes seed phrase display issue by upgrading dependency.
2020-06-11 19:32:57 -04:00
Kevin Gorham 258b622d58
Fixes seed phrase display issue by upgrading dependency. 2020-06-11 18:45:28 -04:00
Kevin Gorham e4d060dbdb
Touch up the README. 2020-06-11 17:13:57 -04:00
Kevin Gorham 9b199ceb36
Merge pull request #162 from zcash/task/update-sdk
Update to the latest SDK which adds explicity x86_64 compatibility.
2020-06-11 17:00:59 -04:00
Kevin Gorham 1ceaa2a996
Update to the latest SDK which adds explicity x86_64 compatibility. 2020-06-11 17:00:35 -04:00
Kevin Gorham 762eff533b
Merge pull request #159 from zcash/task/final-preparations
Contributing guide and helper file for easier builds.
2020-06-10 18:07:16 -04:00
Kevin Gorham 24064e7fd5
Contributing guide and helper file for easier builds. 2020-06-10 18:06:38 -04:00
Kevin Gorham 484f0d2368
Merge pull request #158 from zcash/task/final-preparations
Updated product name and removed local dependency.
2020-06-10 17:09:53 -04:00
Kevin Gorham 1bda85c1c4
Updated product name and removed local dependency. 2020-06-10 17:09:20 -04:00
Kevin Gorham 0afe8e05b0
Merge pull request #155 from zcash/task/update-readme-for-open-sourcing
Updated readme for public release
2020-06-10 15:04:23 -04:00
Linda Lee b568495089
Merge pull request #156 from zcash/lindanlee-update-readme-opensource
Update README.md
2020-06-10 14:01:37 -05:00
Linda Lee 768c8819a0
Update README.md
Add back in traffic analysis.
2020-06-10 13:57:12 -05:00
Kevin Gorham 1ff92a8269
Addressed feedback in https://github.com/zcash/zcash-android-wallet/pull/156#discussion_r438328102 2020-06-10 14:35:44 -04:00
Linda Lee 331498a97c
Update README.md
- clarify that this is maintained by ECC 
- delete duplicate sentence about the wallet threat model (kept in the disclaimers, deleted in the intro). 
- delete "Traffic analysis, like in other cryptocurrency wallets, can leak some privacy of the user." --we agreed that we didn't want to give off the impression that our wallet is worse than other apps, when it is actually better for privacy. 
- delete "We recommend backing up your seed and using this with amounts of funds..." --we reiterate that this is not a product, and Taylor has looked at our code enough to feel confident about our wallets not losing funds. 
- delete "We aim to make it as beautiful as it is useful. Internally, we will continue to extensively use it to innovate and interate on everything from [protocol changes](https://electriccoin.co/blog/introducing-heartwood/) to [lottie animations](https://lottiefiles.com/popular). Of course, Zcash has a strong history of being open-source, even when it's difficult. It would be easier to keep this internal-only so that we could fill it with crash-reporting and feedback tools but, instead, we decided to disable those things and make it available as a community resource." -- this takes away from the point that this is only for dogfooding, and that this is not a product.
2020-06-10 12:43:05 -05:00
Kevin Gorham e70c55e7a6
Iterated on README after merging in changes from #141. 2020-06-10 13:01:39 -04:00
Kevin Gorham e9d7bea423 Simplify the build, per the README instructions. 2020-06-10 12:53:23 -04:00
Kevin Gorham 9ff1e96ebd Initial draft 2020-06-10 12:53:23 -04:00
Kevin Gorham f38ba85e6a
Merge pull request #141 from defuse/link-to-threat-model
Add known areas for improvement to the README
2020-06-10 12:44:41 -04:00
Kevin Gorham 41422992ce
Merge pull request #154 from zcash/task/clean-up-dependencies
Task/clean up dependencies
2020-06-10 10:26:14 -04:00
Kevin Gorham de69567812
Explicitly disable all feedback, dev info and crash reporting.
Addresses https://github.com/zcash/zcash-android-wallet/issues/143 by placing everything behind a user setting that can be enabled in the future by users who want to continue helping us improve the user experience. For the most part, this will just be turned on for internal company releases in order to continue learning and improving the app.
2020-06-10 10:02:53 -04:00
Kevin Gorham 340fb8c993
Address security finding #127 by validating address.
This just needs to be tested on detail views with a lot of transactions to be sure that rapid scrolling doesn't cause too much backpressure.
2020-06-10 08:50:52 -04:00
Kevin Gorham ebbe69125c
New: Provide checksum warning to user.
If the user enters an invalid seed phrase, let them know immediately, per #120.
2020-06-10 08:26:21 -04:00
Kevin Gorham 4c4ef46efe
Updated to the newly refactored (and published) SDK. 2020-06-10 07:49:38 -04:00
Kevin Gorham c5a17ff876
Merge pull request #146 from zcash/task/clean-up-dependencies
Remove NovaCrypto dependency because of license issue.
2020-06-04 13:51:59 -04:00
Kevin Gorham 901db38ee0
Remove NovaCrypto dependency because of license issue.
Add Zcash implementation instead.
2020-06-04 13:50:43 -04:00
Taylor Hornby 5cd2091394 Add known areas for improvement to the README 2020-05-28 16:43:14 -06:00
Kevin Gorham e77423e3dd
Merge pull request #140 from defuse/link-to-threat-model
Add link to threat model
2020-05-28 01:15:40 -04:00
Taylor Hornby c3622f1d63 Add link to threat model 2020-05-27 17:00:21 -06:00
Kevin Gorham 7d2a62854a
Merge pull request #139 from zcash/task/clean-up-dependencies
Dependency cleanup so plugin dependencies are easier to find.
2020-05-27 14:38:32 -04:00
Kevin Gorham 7717612524
Dependency cleanup so plugin dependencies are easier to find. 2020-05-27 14:37:44 -04:00
Kevin Gorham 337a361ef1
Merge pull request #96 from zcash/release/internal-20200325
Internal ECC release 2020-03-25
2020-03-27 16:58:02 -04:00
Kevin Gorham 5632de7493
Bump version to 1.0.0-alpha25 2020-03-27 16:47:07 -04:00
Kevin Gorham 5a956a55d3
Improved transaction detail UI and behavior.
Differentiate pending transactions, show address when we can parse it from the memo, allow address copy.
2020-03-27 16:46:38 -04:00
Kevin Gorham 8371f9c53a
New: Feedback screen in profile. 2020-03-27 16:45:32 -04:00
Kevin Gorham 6e44614207
Improve behavior when disconnected from the server. 2020-03-27 16:43:44 -04:00
Kevin Gorham 27efadc218
Minor tweaks and improvements. 2020-03-27 16:43:08 -04:00
Kevin Gorham ae41bd50cf
Iterate on button styles, including initial loading animation. 2020-03-26 09:46:21 -04:00
Kevin Gorham 28d19bce1f
Fix: improve handling of memo length. 2020-03-26 09:45:31 -04:00
Kevin Gorham dcd1e63491
Fix: avoid negative numbers in the UI. 2020-03-26 09:43:28 -04:00
Kevin Gorham 436fa5fa74
Fix: ignore irrelevant crashes while closing the camera. 2020-03-26 09:42:16 -04:00
Kevin Gorham f724a74993
Fix: last digit of send amount always lingers when returning to home screen 2020-03-26 09:38:52 -04:00
Kevin Gorham 3d4ae2ae63
Minor tweaks.
Improved spannable ext function
Updated wallet details to wallet history
2020-03-26 09:37:21 -04:00
Kevin Gorham 9550cdbbc7
Merge pull request #80 from zcash/release/sprint-2
Release/sprint 2
2020-03-10 14:47:30 -04:00
Kevin Gorham 1367ef6eff
Updated build, dependencies and added changelog. 2020-02-21 19:03:00 -05:00
Kevin Gorham 6d08591452
General bug fixes. 2020-02-21 18:53:29 -05:00
Kevin Gorham 2fc572e434
Extend analytics to include taps, screen views, and send flow. 2020-02-21 18:52:57 -05:00
Kevin Gorham d5129e44fa
Download user logs and developer logs as files. 2020-02-21 18:50:57 -05:00
Kevin Gorham 5803a9dd71
Add crash reporting via Crashlytics. 2020-02-21 18:49:45 -05:00
Kevin Gorham 8331e8ff06
Pulled in error handling improvements from the SDK. 2020-02-21 18:49:16 -05:00
Kevin Gorham 4392f02dbe
Merge pull request #78 from zcash/release/sprint-2
Release/sprint-20-05
2020-02-12 08:06:20 -05:00
Kevin Gorham 9b756d60da
Updated build and dependencies. 2020-02-12 08:03:14 -05:00
Kevin Gorham a2a53f3cb8
Updated launcher icons. 2020-02-12 08:02:48 -05:00
Kevin Gorham 3bce43c32e
Modify z->t experience. 2020-02-12 08:01:39 -05:00
Kevin Gorham 8434e23014
Logging: Send failures.
Also improved crash reporting.
2020-02-12 08:01:08 -05:00
Kevin Gorham f02021709a
Refactor to add support for SDK plugins. 2020-02-12 07:59:47 -05:00
Kevin Gorham b630b9fa78
Bug and crash fixes. 2020-02-12 07:58:41 -05:00
Kevin Gorham 6da700d683
Restore feature cleanup.
Show birthdate in backup fragment.
2020-02-12 07:55:44 -05:00
Kevin Gorham a357afe09a
General fixes and cleanup.
- Allow tiny transaction amounts and improve display
- show toAddress and memo when we know it
- Bugfix: self transactions are not duplicated
- Turned Developer logs back on and cleaned up output a bit
2020-01-31 11:32:36 -05:00
Kevin Gorham 899e48b9f3
Implemented the Restore feature. 2020-01-31 11:31:36 -05:00
Kevin Gorham 61ec3bed66
Memo improvements.
- Added ability to clear the memo field
- Fixed the memo UI so the wording makes more sense and responds to checkbox
- Changed 'Send without memo' to 'Omit memo'
2020-01-31 11:28:55 -05:00
Linda Lee 2c0fcaacd5 Update issue templates
Shortened the bug report changes
2020-01-17 16:00:43 -06:00
Linda Lee a1424e2d3d Update issue templates
These are the default github templates. Let me know if you'd like tweaks.
2020-01-16 16:26:46 -06:00
Kevin Gorham cccfbe2271
Hotfixes. 2020-01-15 11:27:09 -05:00
Kevin Gorham 27a78a90b4
Merge pull request #65 from zcash/task/stress-test-fixes
Task/stress test fixes
2020-01-15 10:33:39 -05:00
Kevin Gorham b72b1434ac
Final improvements before beta release. 2020-01-15 10:32:54 -05:00
Kevin Gorham 64461197b6
Improved loading animation. 2020-01-15 10:32:10 -05:00
Kevin Gorham 3028f99ced
New launcher icon files. 2020-01-15 10:30:45 -05:00
Kevin Gorham 4283a771f6
Added metrics around sending funds. 2020-01-15 10:29:08 -05:00
Kevin Gorham f8603d424a
Code freeze fixes 2020-01-13 20:58:09 -05:00
Kevin Gorham f7e438431d
Mucho fixes. 2020-01-13 19:09:22 -05:00
Kevin Gorham 771d10358e
Fix nearly all bugs from yesterday's stress test.
- reset the send data after a successful transaction submission
    - failed txs will keep the data around for a retry
- improved wallet details
    - added a footer so that it is easier to see scroll behavior
    - unblocked the navigation icons
    - fix change displayed to be ZEC instead of zatoshi
    - fix spacing and sizing for amount text
    - colored the amount available text at the top (emphasize amount)
- improved t-address experience
    - valid t-addresses are recognized in the UI
    - remove shield for transparent sends
    - don't allow memos
    - message the user about unshielded transactions
- improved icon positioning on home screen (and profile)
2020-01-10 13:58:10 -05:00
Kevin Gorham 62bbd30c40
Fixes after the team testing session. 2020-01-10 02:53:16 -05:00
Kevin Gorham fd5a0ff831
Merge pull request #64 from zcash/task/fix-include-address
Added the behavior to include the senders address
2020-01-09 13:04:44 -05:00
Kevin Gorham 83969f0eb4
Added the behavior to include the senders address
Addresses #63
2020-01-09 13:01:29 -05:00
Kevin Gorham cb9e6cc4b4
Merge pull request #62 from zcash/feature/profile-screen
Feature/profile screen
2020-01-09 11:07:24 -05:00
Kevin Gorham 655d959282
Bumped version to alpha05 2020-01-09 11:05:02 -05:00
Kevin Gorham d5c8d17c3d
Home screen improvements and bug fixes.
- Implemented long press to clear amount
- fixed bugs around No Funds being shown when available was zero but change was pending confirmation
- colored the gray z-icon on the home screen
- added color emphasis when change is pending
- added profile button
2020-01-09 11:05:02 -05:00
Kevin Gorham 4c8adf5180
Improved back navigation flow and animations.
Addresses #61
2020-01-09 11:05:02 -05:00
Kevin Gorham 6ab46f75bb
Implemented wallet detail screen redesign.
Addresses #60
2020-01-09 11:05:01 -05:00
Kevin Gorham 931bf5c280
Enabled crash reporting.
Addresses #54
2020-01-09 11:05:01 -05:00
Kevin Gorham 5fbee70b58
Added profile screen.
Addresses #45
2020-01-09 10:17:13 -05:00
Kevin Gorham 7f026f033e
Merge pull request #59 from zcash/feature/qr-code-scan
Feature/qr code scan
2020-01-08 03:50:27 -05:00
Kevin Gorham a93cf27eea
Switched to ECC's lightwalletd 2020-01-08 03:47:50 -05:00
Kevin Gorham 074b4fe1ee
Repaired integration tests. 2020-01-08 03:47:37 -05:00
Kevin Gorham f72f33477d
Revamped navigation. 2020-01-08 03:47:12 -05:00
Kevin Gorham df651dddad
Implemented redesigned receive screen. 2020-01-08 03:46:40 -05:00
Kevin Gorham c42a0063c2
Added QR Scanner UI 2020-01-08 03:45:45 -05:00
Kevin Gorham 4922d690e9
Added configuration to support QR scanning. 2020-01-08 03:42:49 -05:00
Kevin Gorham 8ef4edd88b
Round all the corners. 2020-01-07 02:02:14 -05:00
Kevin Gorham 4ecac12f03
Merge pull request #52 from zcash/task/fix-dagger
Task/fix dagger
2020-01-07 01:56:01 -05:00
Kevin Gorham c99deb7447
Fixed broken back button behavior on the wallet backup screen. 2020-01-07 01:47:19 -05:00
Kevin Gorham 69b32f14b9
Leveraged new ability to safely inject Synchronizers.
This was a key benefit of the refactor for #39.
2020-01-07 01:46:51 -05:00
Kevin Gorham ed7577f4a8
Starting the synchronizer in a more clear way with less room for error.
This was a key benefit of the refactor for #39. Also removed the bandaid of exposing the rustBackend in the Synchronizer, which is a huge win.
2020-01-07 01:45:24 -05:00
Kevin Gorham 1937c19a14
Improved viewmodel logic for #39.
- added separate factories for VMs that require the synchronizer and those that do not
- witched to using the factories directly, instead of creating providers, similar to how Google does it in their generic extension functions but removed the need to inject the factory
2020-01-07 01:43:15 -05:00
Kevin Gorham ac626917f4
Added Synchronizer subcomponents.
Related to #39
2020-01-07 01:41:02 -05:00
Kevin Gorham 1d25feadf9
Fix dagger implementation by simplifying things and removing Dagger Android.
Dagger Android is overly complex and confusing and Google admitted that they need to change it and simply its use. In the meantime, it is easier to just remove it and setup dagger the old way.
2020-01-06 00:01:06 -05:00
Kevin Gorham b2908989aa
Merge pull request #51 from zcash/feature/transaction-history
Feature/transaction history
2020-01-04 16:20:25 -05:00
Kevin Gorham ce09ed7bd2
Format outbound transactions. 2020-01-02 18:53:06 -05:00
Kevin Gorham b53992534a
Basic support for transaction history. 2019-12-23 19:07:10 -05:00
Kevin Gorham 5cdfc97945
Merge pull request #40 from zcash/sprint/49
Sprint/49
2019-12-23 14:42:22 -05:00
Kevin Gorham e1bbf1b6e8
Cleanup and bugfixes.
- Corrected logic for splitting address into 8 parts
  - Corrected bug in loading seed phrase
2019-12-23 14:41:16 -05:00
Kevin Gorham cdcc39121b
Modifications to configuration and tests. 2019-12-23 14:41:15 -05:00
Kevin Gorham f81c6b2dff
Implemented homescreen.
- Added logic for numberpad and send button
  - Added logic for starting sync and displaying progress
2019-12-23 14:41:15 -05:00
Kevin Gorham fa4415ae99
Implement send flow.
Added 4 screens and the related logic.
2019-12-23 14:41:15 -05:00
Kevin Gorham 29c024c563
Iterated on wallet creation
- Added additional metrics
 - Added support for using a funded wallet for development
 - Added (hidden) logic for importing an existing wallet
2019-12-23 14:41:14 -05:00
Kevin Gorham 65edb2f69a
Added archive naming. 2019-12-18 13:24:06 -05:00
205 changed files with 13838 additions and 1481 deletions

25
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

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

View File

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

4
.gitignore vendored
View File

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

44
CHANGELOG.md Normal file
View File

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

98
CONTRIBUTING.md Normal file
View File

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

View File

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

View File

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

69
app/google-services.json Normal file
View File

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

View File

@ -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.** { *; }

View File

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

View File

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

View File

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

View File

@ -1,8 +0,0 @@
package cash.z.ecc.android.di
import dagger.Module
@Module
abstract class AppBindingModule {
}

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package cash.z.ecc.android.di.annotation
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.SOURCE)
annotation class SynchronizerScope

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package cash.z.ecc.android.ext
fun Boolean.asString(ifTrue: String = "", ifFalse: String = "") = if(this) ifTrue else ifFalse

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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