Merge pull request #3 from Agorise/develop

Develop
This commit is contained in:
Agorise 2019-01-02 10:36:02 -06:00 committed by GitHub
commit 09fd15ecce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 7211 additions and 727 deletions

View file

@ -1,7 +1,10 @@
apply plugin: 'com.android.application'
apply plugin: "androidx.navigation.safeargs"
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'io.fabric'
apply plugin: 'com.google.gms.google-services'
android {
compileSdkVersion 28
@ -15,31 +18,69 @@ android {
}
buildTypes {
release {
minifyEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
// TODO enabling minify breaks the debugger breakpoints, find a way to fix it and enable minify again
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
resValue("string", "PORT_NUMBER", "8082")
}
}
android.packagingOptions {
exclude 'lib/x86_64/darwin/libscrypt.dylib'
exclude 'lib/x86_64/freebsd/libscrypt.so'
exclude 'lib/x86_64/linux/libscrypt.so'
}
}
dependencies {
def lifecycle_version = "2.0.0"
def room_version = "2.1.0-alpha02"
def room_version = "2.1.0-alpha03"
def nav_version = "1.0.0-alpha09"
def rx_bindings_version = "3.0.0-alpha2"
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':graphenejlib:graphenej')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.0.0'
// AndroidX
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
// Google
implementation 'com.google.zxing:core:3.3.1'
implementation 'me.dm7.barcodescanner:zxing:1.9.8'
implementation 'com.google.code.gson:gson:2.8.5'
implementation 'com.google.android.material:material:1.0.0'
implementation 'com.google.android.gms:play-services-maps:16.0.0'
// AAC Lifecycle
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
// AAC Room
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-rxjava2:$room_version" // RxJava support for Room
// AAC Navigation
implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version"
implementation "android.arch.navigation:navigation-ui-ktx:$nav_version"
// RxBindings
implementation "com.jakewharton.rxbinding3:rxbinding:$rx_bindings_version"
implementation "com.jakewharton.rxbinding3:rxbinding-material:$rx_bindings_version" // Material Components widgets
implementation "com.jakewharton.rxbinding3:rxbinding-appcompat:$rx_bindings_version" // AndroidX appcompat widgets
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
//Firebase
implementation 'com.google.firebase:firebase-core:16.0.6'
implementation 'com.google.firebase:firebase-crash:16.2.1'
implementation 'com.crashlytics.sdk.android:crashlytics:2.9.7'
// Others
implementation 'org.bitcoinj:bitcoinj-core:0.14.3'
implementation 'com.moldedbits.r2d2:r2d2:1.0.1'
implementation 'me.dm7.barcodescanner:zxing:1.9.8'
implementation 'com.afollestad.material-dialogs:core:2.0.0-rc3'
// Android Debug Database
debugImplementation 'com.amitshekhar.android:debug-db:1.0.4'
// TODO enable and make proper testing
// testImplementation 'junit:junit:4.12'

View file

@ -19,3 +19,14 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontwarn sun.misc.Unsafe
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn org.bitcoinj.store**
-dontwarn org.slf4j.**
-dontwarn okhttp3.internal.platform.*
# Firabase Crashlytics
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception

View file

@ -0,0 +1,24 @@
<resources>
<!--
Before you run your application, you need a Google Maps API key.
To get one, follow this link, follow the directions and press "Create" at the end:
https://console.developers.google.com/flows/enableapi?apiid=maps_android_backend&keyType=CLIENT_SIDE_ANDROID&r=08:07:B8:EC:7B:1E:00:82:29:24:A8:08:A6:AD:84:76:1C:D2:69:1A%3Bcy.agorise.bitsybitshareswallet.activities
You can also add your credentials to an existing key, using these values:
Package name:
08:07:B8:EC:7B:1E:00:82:29:24:A8:08:A6:AD:84:76:1C:D2:69:1A
SHA-1 certificate fingerprint:
08:07:B8:EC:7B:1E:00:82:29:24:A8:08:A6:AD:84:76:1C:D2:69:1A
Alternatively, follow the directions here:
https://developers.google.com/maps/documentation/android/start#get-key
Once you have your key (it starts with "AIza"), replace the "google_maps_key"
string in this file.
-->
<string name="google_maps_key" translatable="false" templateMergeStrategy="preserve">AIzaSyDIYbjdkZqbLUINQXrAzNSjNwep5jGNjKA</string>
</resources>

View file

@ -3,42 +3,64 @@
xmlns:tools="http://schemas.android.com/tools"
package="cy.agorise.bitsybitshareswallet">
<!--
The ACCESS_COARSE/FINE_LOCATION permissions are not required to use
Google Maps Android API v2, but you must specify either coarse or fine
location permissions for the 'MyLocation' functionality.
-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:name=".utils.BitsyApplication"
android:allowBackup="true"
android:icon="@drawable/bts_logo"
android:icon="@drawable/ic_bitsy_logo"
android:label="@string/app_name"
android:roundIcon="@drawable/bts_logo"
android:roundIcon="@drawable/ic_bitsy_logo"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:theme="@style/Theme.Bitsy"
tools:ignore="GoogleAppIndexingWarning">
<!--
The API key for Google Maps-based APIs is defined as a string resource.
(See the file "res/values/google_maps_api.xml").
Note that the API key is linked to the encryption key used to sign the APK.
You need a different API key for each encryption key, including the release key that is used to
sign the APK for publishing.
You can define the keys for the debug and release targets in src/debug/ and src/release/.
-->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key"/>
<activity
android:name=".activities.MainActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
android:name=".activities.SplashActivity"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name=".activities.LicenseActivity"/>
<activity android:name=".activities.ImportBrainkeyActivity"/>
<activity
android:name=".activities.SettingsActivity"
android:label="@string/title_settings">
</activity>
<activity android:name=".activities.SendTransactionActivity">
</activity>
<activity android:name=".activities.ReceiveTransactionActivity">
</activity>
<!--<activity
android:name="de.bitshares_munich.smartcoinswallet.ReceiveActivity"
android:screenOrientation="portrait"
android:theme="@style/AppTheme" />-->
<activity
android:name=".activities.RequestActivity"
android:screenOrientation="portrait"
android:theme="@style/AppTheme" />
android:name=".activities.MainActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Bitsy.NoActionBar"
android:windowSoftInputMode="adjustPan"/>
<!-- Used to share QR code on QRCodeActivity -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="cy.agorise.bitsybitshareswallet.FileProvider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/tmp_image_path" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,420 @@
package cy.agorise.bitsybitshareswallet.activities
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import cy.agorise.bitsybitshareswallet.database.entities.Balance
import cy.agorise.bitsybitshareswallet.processors.TransfersLoader
import cy.agorise.bitsybitshareswallet.repositories.AssetRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.viewmodels.BalanceViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.TransferViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel
import cy.agorise.graphenej.Asset
import cy.agorise.graphenej.AssetAmount
import cy.agorise.graphenej.UserAccount
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.*
import cy.agorise.graphenej.models.AccountProperties
import cy.agorise.graphenej.models.BlockHeader
import cy.agorise.graphenej.models.FullAccountDetails
import cy.agorise.graphenej.models.JsonRpcResponse
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
/**
* Class in charge of managing the connection to graphenej's NetworkService
*/
abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
private val TAG = this.javaClass.simpleName
private val RESPONSE_GET_FULL_ACCOUNTS = 1
private val RESPONSE_GET_ACCOUNTS = 2
private val RESPONSE_GET_ACCOUNT_BALANCES = 3
private val RESPONSE_GET_ASSETS = 4
private val RESPONSE_GET_BLOCK_HEADER = 5
private lateinit var mUserAccountViewModel: UserAccountViewModel
private lateinit var mBalanceViewModel: BalanceViewModel
private lateinit var mTransferViewModel: TransferViewModel
private lateinit var mAssetRepository: AssetRepository
/* Current user account */
protected var mCurrentAccount: UserAccount? = null
private val mHandler = Handler()
// Disposable returned at the bus subscription
private var mDisposable: Disposable? = null
private var storedOpCount: Long = -1
private var missingUserAccounts = ArrayList<UserAccount>()
private var missingAssets = ArrayList<Asset>()
/* Network service connection */
protected var mNetworkService: NetworkService? = null
// Map used to keep track of request and response id pairs
private val responseMap = HashMap<Long, Int>()
/** Map used to keep track of request id and block number pairs */
private val requestIdToBlockNumberMap = HashMap<Long, Long>()
private var blockNumberWithMissingTime = 0L
/**
* Flag used to keep track of the NetworkService binding state
*/
private var mShouldUnbindNetwork: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val userId = PreferenceManager.getDefaultSharedPreferences(this)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
if (userId != "")
mCurrentAccount = UserAccount(userId)
mAssetRepository = AssetRepository(this)
// Configure UserAccountViewModel to obtain the missing account ids
mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java)
mUserAccountViewModel.getMissingUserAccountIds().observe(this, Observer<List<String>>{ userAccountIds ->
if (userAccountIds.isNotEmpty()) {
missingUserAccounts.clear()
for (userAccountId in userAccountIds)
missingUserAccounts.add(UserAccount(userAccountId))
mHandler.postDelayed(mRequestMissingUserAccountsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
})
// Configure UserAccountViewModel to obtain the missing account ids
mBalanceViewModel = ViewModelProviders.of(this).get(BalanceViewModel::class.java)
mBalanceViewModel.getMissingAssetIds().observe(this, Observer<List<String>>{ assetIds ->
if (assetIds.isNotEmpty()) {
missingAssets.clear()
for (assetId in assetIds)
missingAssets.add(Asset(assetId))
mHandler.postDelayed(mRequestMissingAssetsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
})
//Configure TransferViewModel to obtain the Transfer's block numbers with missing time information, one by one
mTransferViewModel = ViewModelProviders.of(this).get(TransferViewModel::class.java)
mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer<Long>{ blockNumber ->
if (blockNumber != null && blockNumber != blockNumberWithMissingTime) {
blockNumberWithMissingTime = blockNumber
Log.d(TAG, "Block number: $blockNumber, Time: ${System.currentTimeMillis()}")
mHandler.postDelayed(mRequestBlockMissingTimeTask, 10)
}
})
mDisposable = RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { handleIncomingMessage(it) }
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
// Generic processing taken care by subclasses
handleJsonRpcResponse(message)
if (message.error == null) {
if (responseMap.containsKey(message.id)) {
val responseType = responseMap[message.id]
when (responseType) {
RESPONSE_GET_FULL_ACCOUNTS ->
handleAccountDetails((message.result as List<*>)[0] as FullAccountDetails)
RESPONSE_GET_ACCOUNTS ->
handleAccountProperties(message.result as List<AccountProperties>)
RESPONSE_GET_ACCOUNT_BALANCES ->
handleBalanceUpdate(message.result as List<AssetAmount>)
RESPONSE_GET_ASSETS ->
handleAssets(message.result as List<Asset>)
RESPONSE_GET_BLOCK_HEADER -> {
val blockNumber = requestIdToBlockNumberMap[message.id] ?: 0L
handleBlockHeader(message.result as BlockHeader, blockNumber)
requestIdToBlockNumberMap.remove(message.id)
}
}
responseMap.remove(message.id)
}
} else {
// In case of error
Log.e(TAG, "Got error message from full node. Msg: " + message.error.message)
Toast.makeText(
this@ConnectedActivity,
String.format("Error from full node. Msg: %s", message.error.message),
Toast.LENGTH_LONG
).show()
}
} else if (message is ConnectionStatusUpdate) {
handleConnectionStatusUpdate(message)
if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) {
// If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset
responseMap.clear()
}
}
}
/**
* Method called whenever a response to the 'get_full_accounts' API call has been detected.
* @param accountDetails De-serialized account details object
*/
private fun handleAccountDetails(accountDetails: FullAccountDetails) {
val latestOpCount = accountDetails.statistics.total_ops
Log.d(TAG, "handleAccountDetails. prev count: $storedOpCount, current count: $latestOpCount")
if (latestOpCount == 0L) {
Log.d(TAG, "The node returned 0 total_ops for current account and may not have installed the history plugin. " +
"\nAsk the NetworkService to remove the node from the list and connect to another one.")
mNetworkService!!.removeCurrentNodeAndReconnect()
} else if (storedOpCount == -1L) {
// Initial case when the app starts
storedOpCount = latestOpCount
PreferenceManager.getDefaultSharedPreferences(this)
.edit().putLong(Constants.KEY_ACCOUNT_OPERATION_COUNT, latestOpCount).apply()
TransfersLoader(this)
updateBalances()
} else if (latestOpCount > storedOpCount) {
storedOpCount = latestOpCount
TransfersLoader(this)
updateBalances()
}
}
/**
* Receives a list of missing [AccountProperties] from which it extracts the required information to
* create a list of BiTSy's UserAccount objects and stores them into the database
*/
private fun handleAccountProperties(accountPropertiesList: List<AccountProperties>) {
val userAccounts = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.UserAccount>()
for (accountProperties in accountPropertiesList) {
val userAccount = cy.agorise.bitsybitshareswallet.database.entities.UserAccount(
accountProperties.id,
accountProperties.name,
accountProperties.membership_expiration_date == Constants.LIFETIME_EXPIRATION_DATE
)
userAccounts.add(userAccount)
}
mUserAccountViewModel.insertAll(userAccounts)
missingUserAccounts.clear()
}
private fun handleBalanceUpdate(assetAmountList: List<AssetAmount>) {
Log.d(TAG, "handleBalanceUpdate")
val now = System.currentTimeMillis() / 1000
val balances = ArrayList<Balance>()
for (assetAmount in assetAmountList) {
val balance = Balance(
assetAmount.asset.objectId,
assetAmount.amount.toLong(),
now
)
balances.add(balance)
}
mBalanceViewModel.insertAll(balances)
}
/**
* Receives a list of missing [Asset] from which it extracts the required information to
* create a list of BiTSy's Asset objects and stores them into the database
*/
private fun handleAssets(_assets: List<Asset>) {
val assets = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.Asset>()
for (_asset in _assets) {
val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset(
_asset.objectId,
_asset.symbol,
_asset.precision,
_asset.description ?: "",
_asset.bitassetId ?: ""
)
assets.add(asset)
}
mAssetRepository.insertAll(assets)
missingAssets.clear()
}
/**
* Receives the [BlockHeader] related to a Transfer's missing time and saves it into the database.
*/
private fun handleBlockHeader(blockHeader: BlockHeader, blockNumber: Long) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("GMT")
try {
val date = dateFormat.parse(blockHeader.timestamp)
mTransferViewModel.setBlockTime(blockNumber, date.time / 1000)
} catch (e: ParseException) {
Log.e(TAG, "ParseException. Msg: " + e.message)
}
}
private fun updateBalances() {
if (mNetworkService!!.isConnected) {
val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()),
GetAccountBalances.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ACCOUNT_BALANCES
}
}
/**
* Task used to obtain the missing UserAccounts from Graphenej's NetworkService.
*/
private val mRequestMissingUserAccountsTask = object : Runnable {
override fun run() {
if (mNetworkService!!.isConnected) {
val id = mNetworkService!!.sendMessage(GetAccounts(missingUserAccounts), GetAccounts.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ACCOUNTS
} else if (missingUserAccounts.isNotEmpty()){
mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
}
}
/**
* Task used to obtain the missing Assets from Graphenej's NetworkService.
*/
private val mRequestMissingAssetsTask = object : Runnable {
override fun run() {
if (mNetworkService!!.isConnected) {
val id = mNetworkService!!.sendMessage(GetAssets(missingAssets), GetAssets.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ASSETS
} else if (missingAssets.isNotEmpty()){
mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
}
}
/**
* Task used to perform a redundant payment check.
*/
private val mCheckMissingPaymentsTask = object : Runnable {
override fun run() {
if (mNetworkService != null && mNetworkService!!.isConnected) {
if (mCurrentAccount != null) {
val userAccounts = ArrayList<String>()
userAccounts.add(mCurrentAccount!!.objectId)
val id = mNetworkService!!.sendMessage(GetFullAccounts(userAccounts, false),
GetFullAccounts.REQUIRED_API)
responseMap[id] = RESPONSE_GET_FULL_ACCOUNTS
}
} else {
Log.w(TAG, "NetworkService is null or is not connected. mNetworkService: $mNetworkService")
}
mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD)
}
}
/**
* Task used to obtain the missing time from a block from Graphenej's NetworkService.
*/
private val mRequestBlockMissingTimeTask = object : Runnable {
override fun run() {
if (mNetworkService != null && mNetworkService!!.isConnected) {
val id = mNetworkService!!.sendMessage(GetBlockHeader(blockNumberWithMissingTime),
GetBlockHeader.REQUIRED_API)
responseMap[id] = RESPONSE_GET_BLOCK_HEADER
requestIdToBlockNumberMap[id] = blockNumberWithMissingTime
} else {
mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD)
}
}
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as NetworkService.LocalBinder
mNetworkService = binder.service
}
override fun onServiceDisconnected(name: ComponentName?) { }
override fun onPause() {
super.onPause()
// Unbinding from network service
if (mShouldUnbindNetwork) {
unbindService(this)
mShouldUnbindNetwork = false
}
mHandler.removeCallbacks(mCheckMissingPaymentsTask)
mHandler.removeCallbacks(mRequestMissingUserAccountsTask)
mHandler.removeCallbacks(mRequestMissingAssetsTask)
}
override fun onResume() {
super.onResume()
val intent = Intent(this, NetworkService::class.java)
if (bindService(intent, this, Context.BIND_AUTO_CREATE)) {
mShouldUnbindNetwork = true
} else {
Log.e(TAG, "Binding to the network service failed.")
}
mHandler.postDelayed(mCheckMissingPaymentsTask, Constants.MISSING_PAYMENT_CHECK_PERIOD)
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposable!!.isDisposed) mDisposable!!.dispose()
}
/**
* Method to be implemented by all subclasses in order to be notified of JSON-RPC responses.
* @param response
*/
internal abstract fun handleJsonRpcResponse(response: JsonRpcResponse<*>)
/**
* Method to be implemented by all subclasses in order to be notified of connection status updates
* @param connectionStatusUpdate
*/
internal abstract fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate)
}

View file

@ -0,0 +1,357 @@
package cy.agorise.bitsybitshareswallet.activities
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.jakewharton.rxbinding3.widget.textChanges
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.entities.Authority
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
import cy.agorise.bitsybitshareswallet.repositories.UserAccountRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
import cy.agorise.graphenej.*
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.calls.GetAccounts
import cy.agorise.graphenej.api.calls.GetKeyReferences
import cy.agorise.graphenej.models.AccountProperties
import cy.agorise.graphenej.models.JsonRpcResponse
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.activity_import_brainkey.*
import org.bitcoinj.core.ECKey
import java.util.ArrayList
import java.util.concurrent.TimeUnit
// TODO Add method to load the 20? most important assets
// TODO add progress bar or something while the user waits for the import response from the node
class ImportBrainkeyActivity : ConnectedActivity() {
private val TAG = "ImportBrainkeyActivity"
/**
* Private variable that will hold an instance of the [BrainKey] class
*/
private var mBrainKey: BrainKey? = null
/**
* User account associated with the key derived from the brainkey that the user just typed in
*/
private var mUserAccount: UserAccount? = null
/**
* List of user account candidates, this is required in order to allow the user to select a single
* user account in case one key (derived from the brainkey) controls more than one account.
*/
private var mUserAccountCandidates: List<UserAccount>? = null
private var mKeyReferencesAttempts = 0
private var keyReferencesRequestId: Long = 0
private var getAccountsRequestId: Long = 0
private var mDisposables = CompositeDisposable()
private var isPINValid = false
private var isPINConfirmationValid = false
private var isBrainKeyValid = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_import_brainkey)
// Use RxJava Debounce to update the PIN error only after the user stops writing for > 500 ms
mDisposables.add(
tietPin.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { validatePIN() }
)
// Use RxJava Debounce to update the PIN Confirmation error only after the user stops writing for > 500 ms
mDisposables.add(
tietPinConfirmation.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { validatePINConfirmation() }
)
// Use RxJava Debounce to update the BrainKey error only after the user stops writing for > 500 ms
mDisposables.add(
tietBrainKey.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.map { it.toString().trim() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { validateBrainKey(it) }
)
btnImport.isEnabled = false
btnImport.setOnClickListener { verifyBrainKey(false) }
}
private fun validatePIN() {
val pin = tietPin.text.toString()
if (pin.length < Constants.MIN_PIN_LENGTH) {
tilPin.error = getString(R.string.error__pin_too_short)
isPINValid = false
} else {
tilPin.isErrorEnabled = false
isPINValid = true
}
validatePINConfirmation()
}
private fun validatePINConfirmation() {
val pinConfirmation = tietPinConfirmation.text.toString()
if (pinConfirmation != tietPin.text.toString()) {
tilPinConfirmation.error = getString(R.string.error__pin_mismatch)
isPINConfirmationValid = false
} else {
tilPinConfirmation.isErrorEnabled = false
isPINConfirmationValid = true
}
enableDisableImportButton()
}
private fun validateBrainKey(brainKey: String) {
if (brainKey.isEmpty() || !brainKey.contains(" ") || brainKey.split(" ").size !in 12..16) {
tilBrainKey.error = getString(R.string.error__enter_correct_brainkey)
isBrainKeyValid = false
} else {
tilBrainKey.isErrorEnabled = false
isBrainKeyValid = true
}
enableDisableImportButton()
}
private fun enableDisableImportButton() {
btnImport.isEnabled = (isPINValid && isPINConfirmationValid && isBrainKeyValid)
}
/**
* Method that will verify the provided brain key, and if valid will retrieve the account information
* associated to the user id.
*
* This method will perform a network lookup to look which accounts use the public key associated
* with the user provided brainkey.
*
* Some sources use brainkeys in capital letters, while others use lowercase. The activity should
* initially call this method with the 'switchCase' parameter as false, in order to try the
* brainkey as it was provided by the user.
*
* But in case this lookup fails, it is expected that the activity makes another attempt. This time
* with the 'switchCase' argument set to true.
*
* If both attempts fail, then we can be certain that the provided brainkey is not currently
* associated with any account.
*
* @param switchCase Whether to switch the case used in the brainkey or not.
*/
private fun verifyBrainKey(switchCase: Boolean) {
//showDialog("", getString(R.string.importing_your_wallet))
val brainKey = tietBrainKey.text.toString()
// Should we switch the brainkey case?
if (switchCase) {
if (Character.isUpperCase(brainKey.toCharArray()[brainKey.length - 1])) {
// If the last character is an uppercase, we assume the whole brainkey
// was given in capital letters and turn it to lowercase
getAccountFromBrainkey(brainKey.toLowerCase())
} else {
// Otherwise we turn the whole brainkey to capital letters
getAccountFromBrainkey(brainKey.toUpperCase())
}
} else {
// If no case switching should take place, we perform the network call with
// the brainkey as it was provided to us.
getAccountFromBrainkey(brainKey)
}
}
/**
* Method that will send a network request asking for all the accounts that make use of the
* key derived from a give brain key.
*
* @param brainKey The brain key the user has just typed
*/
private fun getAccountFromBrainkey(brainKey: String) {
mBrainKey = BrainKey(brainKey, 0)
val address = Address(ECKey.fromPublicOnly(mBrainKey!!.privateKey.pubKey))
Log.d(TAG, String.format("Brainkey would generate address: %s", address.toString()))
keyReferencesRequestId = mNetworkService!!.sendMessage(GetKeyReferences(address), GetKeyReferences.REQUIRED_API)
}
override fun handleJsonRpcResponse(response: JsonRpcResponse<*>) {
Log.d(TAG, "handleResponse.Thread: " + Thread.currentThread().name)
if (response.id == keyReferencesRequestId) {
val resp = response.result as List<List<UserAccount>>
val accountList: List<UserAccount> = resp[0].distinct()
if (accountList.isEmpty() && mKeyReferencesAttempts == 0) {
mKeyReferencesAttempts++
verifyBrainKey(true)
} else {
if (accountList.isEmpty()) {
//hideDialog()
Toast.makeText(applicationContext, R.string.error__invalid_brainkey, Toast.LENGTH_SHORT).show()
} else {
if (accountList.size == 1) {
// If we only found one account linked to this key, then we just proceed
// trying to find out the account name
mUserAccount = accountList[0]
getAccountsRequestId =
mNetworkService!!.sendMessage(GetAccounts(mUserAccount), GetAccounts.REQUIRED_API)
} else {
// If we found more than one account linked to this key, we must also
// find out the account names, but the procedure is a bit different in
// that after having those, we must still ask the user to decide which
// account should be imported.
mUserAccountCandidates = accountList
getAccountsRequestId = mNetworkService!!.sendMessage(
GetAccounts(mUserAccountCandidates),
GetAccounts.REQUIRED_API
)
}
}
}
} else if (response.id == getAccountsRequestId) {
val accountPropertiesList = response.result as List<AccountProperties>
if (accountPropertiesList.size > 1) {
val candidates = ArrayList<String>()
for (accountProperties in accountPropertiesList) {
candidates.add(accountProperties.name)
}
// hideDialog()
MaterialDialog(this)
.title(R.string.dialog__account_candidates_title)
.message(R.string.dialog__account_candidates_content)
.listItemsSingleChoice (items = candidates, initialSelection = -1) { _, index, _ ->
if (index >= 0) {
// If one account was selected, we keep a reference to it and
// store the account properties
mUserAccount = mUserAccountCandidates!![index]
onAccountSelected(accountPropertiesList[index])
}
}
.positiveButton(android.R.string.ok)
.negativeButton(android.R.string.cancel) {
mKeyReferencesAttempts = 0
}
.cancelable(false)
.show()
} else if (accountPropertiesList.size == 1) {
onAccountSelected(accountPropertiesList[0])
} else {
Toast.makeText(applicationContext, R.string.error__try_again, Toast.LENGTH_SHORT).show()
}
}
}
override fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) {
Log.d(TAG, "handleConnectionStatusUpdate. code: " + connectionStatusUpdate.updateCode)
}
/**
* Method called internally once an account has been detected. This method will store internally
* the following details:
*
* - Account name in the database
* - Account authorities in the database
* - The current account id in the shared preferences
*
* @param accountProperties Account properties object
*/
private fun onAccountSelected(accountProperties: AccountProperties) {
mUserAccount!!.name = accountProperties.name
val encryptedPIN = CryptoUtils.encrypt(this, tietPin.text!!.toString())
// Stores the user selected PIN encrypted
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putString(Constants.KEY_ENCRYPTED_PIN, encryptedPIN)
.apply()
// Stores the accounts this key refers to
val id = accountProperties.id
val name = accountProperties.name
val isLTM = accountProperties.membership_expiration_date == Constants.LIFETIME_EXPIRATION_DATE
val userAccount = cy.agorise.bitsybitshareswallet.database.entities.UserAccount(id, name, isLTM)
val userAccountRepository = UserAccountRepository(application)
userAccountRepository.insert(userAccount)
// Stores the id of the currently active user account
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putString(Constants.KEY_CURRENT_ACCOUNT_ID, mUserAccount!!.objectId)
.apply()
// Trying to store all possible authorities (owner, active and memo) into the database
val ownerAuthority = accountProperties.owner
val activeAuthority = accountProperties.active
val options = accountProperties.options
for (i in 0..2) {
mBrainKey!!.sequenceNumber = i
val publicKey = PublicKey(ECKey.fromPublicOnly(mBrainKey!!.privateKey.pubKey))
if (ownerAuthority.keyAuths.keys.contains(publicKey)) {
addAuthorityToDatabase(accountProperties.id, AuthorityType.OWNER.ordinal, mBrainKey!!)
}
if (activeAuthority.keyAuths.keys.contains(publicKey)) {
addAuthorityToDatabase(accountProperties.id, AuthorityType.ACTIVE.ordinal, mBrainKey!!)
}
if (options.memoKey == publicKey) {
addAuthorityToDatabase(accountProperties.id, AuthorityType.MEMO.ordinal, mBrainKey!!)
}
}
// Stores a flag into the SharedPreferences to tell the app there is an active account and there is no need
// to show this activity again, until the account is removed.
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putBoolean(Constants.KEY_INITIAL_SETUP_DONE, true)
.apply()
// Send the user to the MainActivity
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
/**
* Adds the given BrainKey encrypted as AuthorityType of userId.
*/
private fun addAuthorityToDatabase(userId: String, authorityType: Int, brainKey: BrainKey) {
val brainKeyWords = brainKey.brainKey
val wif = brainKey.walletImportFormat
val sequenceNumber = brainKey.sequenceNumber
val encryptedBrainKey = CryptoUtils.encrypt(this, brainKeyWords)
val encryptedSequenceNumber = CryptoUtils.encrypt(this, sequenceNumber.toString())
val encryptedWIF = CryptoUtils.encrypt(this, wif)
val authority = Authority(0, userId, authorityType, encryptedWIF, encryptedBrainKey, encryptedSequenceNumber)
val authorityRepository = AuthorityRepository(this)
authorityRepository.insert(authority)
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposables.isDisposed) mDisposables.dispose()
}
}

View file

@ -0,0 +1,55 @@
package cy.agorise.bitsybitshareswallet.activities
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import androidx.appcompat.app.AppCompatActivity
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.utils.Constants
import kotlinx.android.synthetic.main.activity_license.*
class LicenseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_license)
// Get version number of the last agreed license version
val agreedLicenseVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(Constants.KEY_LAST_AGREED_LICENSE_VERSION, 0)
// If the last agreed license version is the actual one then proceed to the following Activities
if (agreedLicenseVersion == Constants.CURRENT_LICENSE_VERSION) {
agree()
} else {
wbLA.loadData(getString(R.string.licence_html), "text/html", "UTF-8")
btnDisagree.setOnClickListener { finish() }
btnAgree.setOnClickListener { agree() }
}
}
/**
* This function stores the version of the current accepted license version into the Shared Preferences and
* sends the user to import/create account if there is no active account or to the MainActivity otherwise.
*/
private fun agree() {
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putInt(Constants.KEY_LAST_AGREED_LICENSE_VERSION, Constants.CURRENT_LICENSE_VERSION).apply()
val intent : Intent?
val initialSetupDone = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_INITIAL_SETUP_DONE, false)
intent = if (!initialSetupDone)
Intent(this, ImportBrainkeyActivity::class.java)
else
Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
}

View file

@ -1,90 +1,114 @@
package cy.agorise.bitsybitshareswallet.activities
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentPagerAdapter
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import cy.agorise.bitsybitshareswallet.BuildConfig
import android.os.Handler
import android.preference.PreferenceManager
import android.view.MenuItem
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.onNavDestinationSelected
import androidx.navigation.ui.setupActionBarWithNavController
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.fragments.BalancesFragment
import cy.agorise.bitsybitshareswallet.fragments.MerchantsFragment
import cy.agorise.bitsybitshareswallet.fragments.TransactionsFragment
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.models.JsonRpcResponse
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
class MainActivity : ConnectedActivity() {
private val TAG = this.javaClass.simpleName
/**
* The [androidx.fragment.app.FragmentPagerAdapter] that will provide
* fragments for each of the sections. We use a
* {@link FragmentPagerAdapter} derivative, which will keep every
* loaded fragment in memory. If this becomes too memory intensive, it
* may be best to switch to a
* [androidx.fragment.app.FragmentStatePagerAdapter].
*/
private var mSectionsPagerAdapter: SectionsPagerAdapter? = null
private lateinit var appBarConfiguration : AppBarConfiguration
// Handler and Runnable used to add a timer for user inaction and close the app if enough time has passed
private lateinit var mHandler: Handler
private lateinit var mRunnable: Runnable
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Sets the theme to night mode if it has been selected by the user
if (PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false)) {
setTheme(R.style.Theme_Bitsy_Dark_NoActionBar)
}
setContentView(R.layout.activity_main)
// Create the adapter that will return a fragment for each of the three
// primary sections of the activity.
mSectionsPagerAdapter = SectionsPagerAdapter(supportFragmentManager)
setSupportActionBar(toolbar)
// Set up the ViewPager with the sections adapter.
viewPager.adapter = mSectionsPagerAdapter
tabLayout.setupWithViewPager(viewPager)
val host: NavHostFragment = supportFragmentManager
.findFragmentById(R.id.navHostFragment) as NavHostFragment? ?: return
// Force first tab to show BTS icon
tabLayout.getTabAt(0)?.setIcon(R.drawable.tab_home_selector)
// Set up Action Bar with Navigation's controller
val navController = host.navController
initBottomBar()
appBarConfiguration = AppBarConfiguration(navController.graph)
ivSettings.setOnClickListener {
val intent = Intent(this, SettingsActivity::class.java)
startActivity(intent)
// Sets up the ActionBar with the navigation controller so that it automatically responds to clicks on toolbar
// menu items and shows the up navigation button on all fragments except home (Balances)
setupActionBarWithNavController(navController, appBarConfiguration)
mHandler = Handler()
// When this runnable finishes it first verifies if the auto close feature is enabled and if it is then it
// closes the app, if not then it just restarts the Handler (timer)
mRunnable = Runnable {
if (PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, true))
finish()
else
restartHandler()
}
}
private fun initBottomBar() {
// Show app version number in bottom bar
tvBuildVersion.text = String.format("v%s", BuildConfig.VERSION_NAME)
// Show block number in bottom bar
tvBlockNumber.text = getString(R.string.block_number_bottom_bar, "-----")
// TODO add listener to update block number
startHandler()
}
/**
* A [FragmentPagerAdapter] that returns a fragment corresponding to
* one of the sections/tabs/pages.
* Restarts the Handler (timer) each time there is user's interaction
*/
inner class SectionsPagerAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) {
override fun onUserInteraction() {
super.onUserInteraction()
restartHandler()
}
override fun getItem(position: Int): Fragment {
return when (position) {
0 -> BalancesFragment()
1 -> TransactionsFragment()
else -> MerchantsFragment()
}
}
/**
* Stops and then restarts the Handler
*/
private fun restartHandler() {
stopHandler()
startHandler()
}
override fun getCount(): Int {
// Show 3 total pages.
return 3
}
private fun stopHandler() {
mHandler.removeCallbacks(mRunnable)
}
private fun startHandler() {
mHandler.postDelayed(mRunnable, 3 * 60 * 1000) //for 3 minutes
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Have the NavigationUI look for an action or destination matching the menu
// item id and navigate there if found.
// Otherwise, bubble up to the parent.
return item.onNavDestinationSelected(findNavController(R.id.navHostFragment))
|| super.onOptionsItemSelected(item)
}
override fun onSupportNavigateUp(): Boolean {
// Allows NavigationUI to support proper up navigation or the drawer layout
// drawer menu, depending on the situation
return findNavController(R.id.navHostFragment).navigateUp(appBarConfiguration)
}
override fun handleJsonRpcResponse(response: JsonRpcResponse<*>) {
}
/**
* Private method called whenever there's an update to the connection status
* @param connectionStatusUpdate Connection status update.
*/
override fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) {
override fun getPageTitle(position: Int): CharSequence? {
return when (position) {
0 -> ""
1 -> getString(R.string.title_transactions)
else -> getString(R.string.title_merchants)
}
}
}
}

View file

@ -1,13 +0,0 @@
package cy.agorise.bitsybitshareswallet.activities
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import cy.agorise.bitsybitshareswallet.R
class SendTransactionActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_send_transaction)
}
}

View file

@ -1,65 +0,0 @@
package cy.agorise.bitsybitshareswallet.activities
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.utils.Constants
import kotlinx.android.synthetic.main.activity_settings.*
/**
* A simple activity for the user to select his preferences
*/
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// TODO move the below to a BaseActivity to apply it to all activities
// Sets the theme to night mode if it has been selected by the user
if (PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false)
) {
setTheme(R.style.AppTheme_Dark)
}
setContentView(R.layout.activity_settings)
setupActionBar()
initNightModeSwitch()
}
/**
* Set up the [android.app.ActionBar], if the API is available.
*/
private fun setupActionBar() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
private fun initNightModeSwitch() {
val nightModeOn = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false)
switchNightMode.isChecked = nightModeOn
switchNightMode.setOnCheckedChangeListener { buttonView, isChecked ->
PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit()
.putBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, isChecked).apply()
// Recreates the activity to apply the selected theme
this.recreate()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return super.onOptionsItemSelected(item)
}
}

View file

@ -1,13 +1,16 @@
package cy.agorise.bitsybitshareswallet.activities
import androidx.appcompat.app.AppCompatActivity
import android.content.Intent
import android.os.Bundle
import cy.agorise.bitsybitshareswallet.R
import androidx.appcompat.app.AppCompatActivity
class ReceiveTransactionActivity : AppCompatActivity() {
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_receive_transaction)
val intent = Intent(this, LicenseActivity::class.java)
startActivity(intent)
finish()
}
}
}

View file

@ -0,0 +1,40 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import cy.agorise.bitsybitshareswallet.database.entities.Asset
class AssetsAdapter(context: Context, resource: Int, data: List<Asset>) :
ArrayAdapter<Asset>(context, resource, data) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var cv = convertView
if (cv == null) {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
cv = inflater.inflate(android.R.layout.simple_spinner_item, parent, false)
}
val text: TextView = cv!!.findViewById(android.R.id.text1)
val asset = getItem(position)
text.text = asset!!.symbol
return cv
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val v = inflater.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false)
val text: TextView = v.findViewById(android.R.id.text1)
val asset = getItem(position)
text.text = asset!!.symbol
return v
}
}

View file

@ -0,0 +1,24 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.widget.ArrayAdapter
import cy.agorise.bitsybitshareswallet.database.entities.Asset
class AutoSuggestAssetAdapter(context: Context, resource: Int):
ArrayAdapter<Asset>(context, resource) {
private var mAssets = ArrayList<Asset>()
fun setData(assets: List<Asset>) {
mAssets.clear()
mAssets.addAll(assets)
}
override fun getCount(): Int {
return mAssets.size
}
override fun getItem(position: Int): Asset? {
return mAssets[position]
}
}

View file

@ -0,0 +1,107 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
class BalancesAdapter(private val context: Context) :
RecyclerView.Adapter<BalancesAdapter.ViewHolder>() {
private val mComparator =
Comparator<BalanceDetail> { a, b -> a.symbol.compareTo(b.symbol) }
private val mSortedList =
SortedList<BalanceDetail>(BalanceDetail::class.java, object : SortedList.Callback<BalanceDetail>() {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int) {
notifyItemRangeChanged(position, count)
}
override fun compare(a: BalanceDetail, b: BalanceDetail): Int {
return mComparator.compare(a, b)
}
override fun areContentsTheSame(oldItem: BalanceDetail, newItem: BalanceDetail): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(item1: BalanceDetail, item2: BalanceDetail): Boolean {
return item1.id == item2.id
}
})
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvSymbol: TextView = itemView.findViewById(R.id.tvSymbol)
val tvAmount: TextView = itemView.findViewById(R.id.tvAmount)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BalancesAdapter.ViewHolder {
val inflater = LayoutInflater.from(context)
val balanceView = inflater.inflate(R.layout.item_balance, parent, false)
return ViewHolder(balanceView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val balance = mSortedList.get(position)
viewHolder.tvSymbol.text = balance.symbol
val amount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble())
viewHolder.tvAmount.text = String.format("%." + Math.min(balance.precision, 8) + "f", amount)
}
fun add(balance: BalanceDetail) {
mSortedList.add(balance)
}
fun remove(balance: BalanceDetail) {
mSortedList.remove(balance)
}
fun add(balances: List<BalanceDetail>) {
mSortedList.addAll(balances)
}
fun remove(balances: List<BalanceDetail>) {
mSortedList.beginBatchedUpdates()
for (balance in balances) {
mSortedList.remove(balance)
}
mSortedList.endBatchedUpdates()
}
fun replaceAll(balances: List<BalanceDetail>) {
mSortedList.beginBatchedUpdates()
for (i in mSortedList.size() - 1 downTo 0) {
val balance = mSortedList.get(i)
if (!balances.contains(balance)) {
mSortedList.remove(balance)
}
}
mSortedList.addAll(balances)
mSortedList.endBatchedUpdates()
}
override fun getItemCount(): Int {
return mSortedList.size()
}
}

View file

@ -0,0 +1,42 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
class BalancesDetailsAdapter(context: Context, resource: Int, data: List<BalanceDetail>) :
ArrayAdapter<BalanceDetail>(context, resource, data) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var cv = convertView
if (cv == null) {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
cv = inflater.inflate(android.R.layout.simple_spinner_item, parent, false)
}
val text: TextView = cv!!.findViewById(android.R.id.text1)
val balanceDetail = getItem(position)
text.text = balanceDetail!!.symbol
return cv
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val v = inflater.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false)
val text: TextView = v.findViewById(android.R.id.text1)
val balanceDetail = getItem(position)
text.text = balanceDetail!!.symbol
return v
}
}

View file

@ -0,0 +1,140 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.RecyclerView
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.graphenej.network.FullNode
import java.util.*
/**
* Adapter used to populate the elements of the Bitshares nodes dialog in order to show a list of
* nodes with their latency.
*/
class FullNodesAdapter(private val context: Context) : RecyclerView.Adapter<FullNodesAdapter.ViewHolder>() {
val TAG: String = this.javaClass.name
private val mComparator =
Comparator<FullNode> { a, b -> java.lang.Double.compare(a.latencyValue, b.latencyValue) }
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvNodeName: TextView = itemView.findViewById(R.id.tvNodeName)
val ivNodeStatus: ImageView = itemView.findViewById(R.id.ivNodeStatus)
}
private val mSortedList = SortedList(FullNode::class.java, object : SortedList.Callback<FullNode>() {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int) {
notifyItemRangeChanged(position, count)
}
override fun compare(a: FullNode, b: FullNode): Int {
return mComparator.compare(a, b)
}
override fun areContentsTheSame(oldItem: FullNode, newItem: FullNode): Boolean {
return oldItem.latencyValue == newItem.latencyValue
}
override fun areItemsTheSame(item1: FullNode, item2: FullNode): Boolean {
return item1.url == item2.url
}
})
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FullNodesAdapter.ViewHolder {
val inflater = LayoutInflater.from(context)
val transactionView = inflater.inflate(R.layout.item_node, parent, false)
return ViewHolder(transactionView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val fullNode = mSortedList[position]
// Show the green check mark before the node name if that node is the one being used
if (fullNode.isConnected)
viewHolder.ivNodeStatus.setImageResource(R.drawable.ic_connected)
else
viewHolder.ivNodeStatus.setImageDrawable(null)
val latency = fullNode.latencyValue
// Select correct color span according to the latency value
val colorSpan = when {
latency < 400 -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.ppGreen))
latency < 800 -> ForegroundColorSpan(Color.rgb(255,136,0)) // Holo orange
else -> ForegroundColorSpan(Color.rgb(204,0,0)) // Holo red
}
// Create a string with the latency number colored according to their amount
val ssb = SpannableStringBuilder()
ssb.append(fullNode.url.replace("wss://", ""), StyleSpan(Typeface.BOLD), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
ssb.append(" (")
// 2000 ms is the timeout of the websocket used to calculate the latency, therefore if the
// received latency is greater than such value we can assume the node was not reachable.
val ms = if(latency < 2000) "%.0f ms".format(latency) else "??"
ssb.append(ms, colorSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
ssb.append(")")
viewHolder.tvNodeName.text = ssb
}
/**
* Functions that adds/updates a FullNode to the SortedList
*/
fun add(fullNode: FullNode) {
// Remove the old instance of the FullNode before adding a new one. My understanding is that
// the sorted list should be able to automatically find repeated elements and update them
// instead of adding duplicates but it wasn't working so I opted for manually removing old
// instances of FullNodes before adding the updated ones.
var removed = 0
for (i in 0 until mSortedList.size())
if (mSortedList[i-removed].url == (fullNode.url))
mSortedList.removeItemAt(i-removed++)
mSortedList.add(fullNode)
}
/**
* Function that adds a whole list of nodes to the SortedList. It should only be used at the
* moment of populating the SortedList for the first time.
*/
fun add(fullNodes: List<FullNode>) {
mSortedList.addAll(fullNodes)
}
fun remove(fullNode: FullNode) {
mSortedList.remove(fullNode)
}
override fun getItemCount(): Int {
return mSortedList.size()
}
}

View file

@ -0,0 +1,173 @@
package cy.agorise.bitsybitshareswallet.adapters
import android.content.Context
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.utils.Constants
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.SimpleDateFormat
import java.util.*
class TransfersDetailsAdapter(private val context: Context) :
RecyclerView.Adapter<TransfersDetailsAdapter.ViewHolder>() {
val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")!!
private val mComparator =
Comparator<TransferDetail> { a, b -> b.id.compareTo(a.id) }
private val mSortedList =
SortedList<TransferDetail>(TransferDetail::class.java, object : SortedList.Callback<TransferDetail>() {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int) {
notifyItemRangeChanged(position, count)
}
override fun compare(a: TransferDetail, b: TransferDetail): Int {
return mComparator.compare(a, b)
}
override fun areContentsTheSame(oldItem: TransferDetail, newItem: TransferDetail): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(item1: TransferDetail, item2: TransferDetail): Boolean {
return item1.id == item2.id
}
})
private val dateFormat: SimpleDateFormat
private val timeFormat: SimpleDateFormat
init {
val locale = context.resources.configuration.locale
dateFormat = SimpleDateFormat("dd MMM", locale)
timeFormat = SimpleDateFormat("HH:mm:ss z", locale)
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val rootView: ConstraintLayout = itemView.findViewById(R.id.rootView)
val vPaymentDirection: View = itemView.findViewById(R.id.vPaymentDirection)
val tvFrom: TextView = itemView.findViewById(R.id.tvFrom)
val ivDirectionArrow: ImageView = itemView.findViewById(R.id.ivDirectionArrow)
val tvTo: TextView = itemView.findViewById(R.id.tvTo)
val llMemo: LinearLayout = itemView.findViewById(R.id.llMemo)
val tvMemo: TextView = itemView.findViewById(R.id.tvMemo)
val tvDate: TextView = itemView.findViewById(R.id.tvDate)
val tvTime: TextView = itemView.findViewById(R.id.tvTime)
val tvCryptoAmount: TextView = itemView.findViewById(R.id.tvCryptoAmount)
val tvFiatEquivalent: TextView = itemView.findViewById(R.id.tvFiatEquivalent)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransfersDetailsAdapter.ViewHolder {
val inflater = LayoutInflater.from(context)
val transactionView = inflater.inflate(R.layout.item_transaction, parent, false)
return ViewHolder(transactionView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val transferDetail = mSortedList.get(position)
viewHolder.vPaymentDirection.setBackgroundColor(context.resources.getColor(
if(transferDetail.direction) R.color.colorReceive else R.color.colorSend
))
viewHolder.tvFrom.text = transferDetail.from ?: ""
viewHolder.tvTo.text = transferDetail.to ?: ""
// Shows memo if available
val memo = transferDetail.memo
if (memo == "") {
viewHolder.tvMemo.text = ""
viewHolder.llMemo.visibility = View.GONE
} else {
viewHolder.tvMemo.text = memo
viewHolder.llMemo.visibility = View.VISIBLE
}
// Format date and time
val date = Date(transferDetail.date * 1000)
viewHolder.tvDate.text = dateFormat.format(date)
viewHolder.tvTime.text = timeFormat.format(date)
// Show the crypto amount correctly formatted
// TODO lift the DecimalFormat declaration to other place to make things more efficient
val df = DecimalFormat("####."+("#".repeat(transferDetail.cryptoPrecision)))
df.roundingMode = RoundingMode.CEILING
df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
val amount = transferDetail.cryptoAmount.toDouble() /
Math.pow(10.toDouble(), transferDetail.cryptoPrecision.toDouble())
val cryptoAmount = "${df.format(amount)} ${transferDetail.cryptoSymbol}"
viewHolder.tvCryptoAmount.text = cryptoAmount
viewHolder.tvFiatEquivalent.text = "$4119.75"
viewHolder.ivDirectionArrow.setImageDrawable(context.getDrawable(
if(transferDetail.direction) R.drawable.ic_arrow_receive else R.drawable.ic_arrow_send
))
}
fun add(transferDetail: TransferDetail) {
mSortedList.add(transferDetail)
}
fun remove(transferDetail: TransferDetail) {
mSortedList.remove(transferDetail)
}
fun add(transfersDetails: List<TransferDetail>) {
mSortedList.addAll(transfersDetails)
}
fun remove(transfersDetails: List<TransferDetail>) {
mSortedList.beginBatchedUpdates()
for (transferDetail in transfersDetails) {
mSortedList.remove(transferDetail)
}
mSortedList.endBatchedUpdates()
}
fun replaceAll(transfersDetails: List<TransferDetail>) {
mSortedList.beginBatchedUpdates()
for (i in mSortedList.size() - 1 downTo 0) {
val transferDetail = mSortedList.get(i)
if (!transfersDetails.contains(transferDetail)) {
mSortedList.remove(transferDetail)
}
}
mSortedList.addAll(transfersDetails)
mSortedList.endBatchedUpdates()
}
override fun getItemCount(): Int {
return mSortedList.size()
}
}

View file

@ -0,0 +1,52 @@
package cy.agorise.bitsybitshareswallet.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import cy.agorise.bitsybitshareswallet.database.daos.*
import cy.agorise.bitsybitshareswallet.database.entities.*
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetailDao
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetailDao
@Database(entities = [
Asset::class,
Authority::class,
Balance::class,
EquivalentValue::class,
Transfer::class,
UserAccount::class
],
version = 1, exportSchema = false)
abstract class BitsyDatabase : RoomDatabase() {
abstract fun assetDao(): AssetDao
abstract fun authorityDao(): AuthorityDao
abstract fun balanceDao(): BalanceDao
abstract fun equivalentValueDao(): EquivalentValueDao
abstract fun transferDao(): TransferDao
abstract fun userAccountDao(): UserAccountDao
abstract fun balanceDetailDao(): BalanceDetailDao
abstract fun transferDetailDao(): TransferDetailDao
companion object {
// To make sure there is always only one instance of the database open
@Volatile
private var INSTANCE: BitsyDatabase? = null
internal fun getDatabase(context: Context): BitsyDatabase? {
if (INSTANCE == null) {
synchronized(BitsyDatabase::class.java) {
INSTANCE = Room.databaseBuilder(
context.applicationContext,
BitsyDatabase::class.java, "BiTSyWallet.db"
)
.build()
}
}
return INSTANCE
}
}
}

View file

@ -0,0 +1,20 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.Asset
@Dao
interface AssetDao {
@Insert
fun insert(asset: Asset)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(assets: List<Asset>)
@Query("SELECT * FROM assets")
fun getAll(): LiveData<List<Asset>>
}

View file

@ -0,0 +1,23 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.Authority
import io.reactivex.Single
@Dao
interface AuthorityDao {
@Insert
fun insert(authority: Authority)
@Query("SELECT * FROM authorities WHERE user_id=:userId LIMIT 1")
fun get(userId: String): Single<Authority>
@Query("SELECT * FROM authorities")
fun getAll(): LiveData<List<Authority>>
@Query("SELECT encrypted_wif FROM authorities WHERE user_id=:userId AND authority_type=:authorityType")
fun getWIF(userId: String, authorityType: Int): Single<String>
}

View file

@ -0,0 +1,24 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.Balance
@Dao
interface BalanceDao {
@Insert
fun insert(balance: Balance)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(balances: List<Balance>)
@Query("SELECT * FROM balances")
fun getAll(): LiveData<List<Balance>>
// TODO not sure if this is the best place for this query as it involves two entities
@Query("SELECT DISTINCT asset_id FROM balances WHERE asset_id NOT IN (SELECT id FROM assets)")
fun getMissingAssetIds(): LiveData<List<String>>
}

View file

@ -0,0 +1,16 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.EquivalentValue
@Dao
interface EquivalentValueDao {
@Insert
fun insert(equivalentValue: EquivalentValue)
@Query("SELECT * FROM equivalent_values")
fun getAllEquivalentValues(): LiveData<List<EquivalentValue>>
}

View file

@ -0,0 +1,34 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import io.reactivex.Single
@Dao
interface TransferDao {
@Insert
fun insert(transfer: Transfer)
// TODO find a way to return number of added rows
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertAll(transfers: List<Transfer>)
@Query("UPDATE transfers SET timestamp=:timestamp WHERE block_number=:blockNumber")
fun setBlockTime(blockNumber: Long, timestamp: Long)
@Query("SELECT * FROM transfers")
fun getAll(): LiveData<List<Transfer>>
@Query("SELECT COUNT(*) FROM transfers")
fun getCount(): Single<Int>
@Query("SELECT block_number FROM transfers WHERE timestamp='0' LIMIT 1")
fun getTransferBlockNumberWithMissingTime(): LiveData<Long>
@Query("DELETE FROM transfers")
fun deleteAll()
}

View file

@ -0,0 +1,27 @@
package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.UserAccount
@Dao
interface UserAccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(userAccount: UserAccount)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(userAccounts: List<UserAccount>)
@Query("SELECT * FROM user_accounts WHERE user_accounts.id = :id")
fun getUserAccount(id: String): LiveData<UserAccount>
@Query("SELECT * FROM user_accounts")
fun getAll(): LiveData<List<UserAccount>>
// TODO not sure if this is the best place for this query as it involves two entities
@Query("SELECT DISTINCT destination FROM transfers WHERE destination NOT IN (SELECT id FROM user_accounts) UNION SELECT DISTINCT source FROM transfers WHERE source NOT IN (SELECT id FROM user_accounts)")
fun getMissingAccountIds(): LiveData<List<String>>
}

View file

@ -0,0 +1,19 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "assets")
data class Asset(
@PrimaryKey
@ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "symbol") val symbol: String,
@ColumnInfo(name = "precision") val precision: Int,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "bit_asset_id") val bitAssetId: String
) {
override fun toString(): String {
return symbol
}
}

View file

@ -0,0 +1,16 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "authorities")
data class Authority (
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "authority_type") val authorityType: Int,
@ColumnInfo(name = "encrypted_wif") val encryptedWIF: String,
@ColumnInfo(name = "encrypted_brain_key") val encryptedBrainKey: String,
@ColumnInfo(name = "encrypted_sequence_number") val encryptedSequenceNumber: String
)

View file

@ -0,0 +1,13 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "balances")
data class Balance(
@PrimaryKey
@ColumnInfo(name = "asset_id") val assetId: String, // TODO should be foreign key?
@ColumnInfo(name = "asset_amount") val assetAmount: Long,
@ColumnInfo(name = "last_update") val lastUpdate: Long
)

View file

@ -0,0 +1,25 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(tableName = "equivalent_values",foreignKeys =
[ForeignKey(
entity = Transfer::class,
parentColumns = ["id"],
childColumns = ["transfer_id"]
), ForeignKey(
entity = Asset::class,
parentColumns = ["id"],
childColumns = ["asset_id"]
)]
)
data class EquivalentValue (
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long,
@ColumnInfo(name = "transfer_id") val transferId: String,
@ColumnInfo(name = "value") val value: Long,
@ColumnInfo(name = "asset_id") val assetId: String
)

View file

@ -0,0 +1,20 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "transfers")
data class Transfer (
@PrimaryKey
@ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "block_number") val blockNumber: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "fee_amount") val feeAmount: Long,
@ColumnInfo(name = "fee_asset_id") val feeAssetId: String, // TODO should be foreign key to Asset
@ColumnInfo(name = "source") val source: String, // TODO should be foreign key to UserAccount
@ColumnInfo(name = "destination") val destination: String, // TODO should be foreign key to UserAccount
@ColumnInfo(name = "transfer_amount") val transferAmount: Long,
@ColumnInfo(name = "transfer_asset_id") val transferAssetId: String, // TODO should be foreign key to Asset
@ColumnInfo(name = "memo") val memo: String
)

View file

@ -0,0 +1,13 @@
package cy.agorise.bitsybitshareswallet.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "user_accounts")
data class UserAccount (
@PrimaryKey
@ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "is_ltm") val isLtm: Boolean
)

View file

@ -0,0 +1,8 @@
package cy.agorise.bitsybitshareswallet.database.joins
data class BalanceDetail(
val id: String,
val amount: Long,
val precision: Int,
val symbol: String
)

View file

@ -0,0 +1,12 @@
package cy.agorise.bitsybitshareswallet.database.joins
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Query
@Dao
interface BalanceDetailDao {
@Query("SELECT assets.id AS id, balances.asset_amount AS amount, assets.precision, assets.symbol " +
"FROM balances INNER JOIN assets on balances.asset_id = assets.id")
fun getAll(): LiveData<List<BalanceDetail>>
}

View file

@ -0,0 +1,15 @@
package cy.agorise.bitsybitshareswallet.database.joins
data class TransferDetail(
val id: String,
val from: String?,
val to: String?,
val direction: Boolean, // True -> Received, False -> Sent
val memo: String,
val date: Long,
val cryptoAmount: Long,
val cryptoPrecision: Int,
val cryptoSymbol: String
// val fiatAmount: Long,
// val fiatCurrency: String
)

View file

@ -0,0 +1,12 @@
package cy.agorise.bitsybitshareswallet.database.joins
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Query
@Dao
interface TransferDetailDao {
@Query("SELECT transfers.id, (SELECT name FROM user_accounts WHERE user_accounts.id=transfers.source) AS `from`, (SELECT name FROM user_accounts WHERE user_accounts.id=transfers.destination) AS `to`, (CASE WHEN destination=:userId THEN 1 ELSE 0 END) AS `direction`, transfers.memo AS `memo`, transfers.timestamp AS `date`, transfers.transfer_amount AS `cryptoAmount`, assets.precision AS `cryptoPrecision`, assets.symbol AS cryptoSymbol FROM transfers INNER JOIN assets WHERE transfers.transfer_asset_id = assets.id")
fun getAll(userId: String): LiveData<List<TransferDetail>>
}

View file

@ -1,52 +1,55 @@
package cy.agorise.bitsybitshareswallet.fragments
import androidx.lifecycle.ViewModelProviders
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.activities.ReceiveTransactionActivity
import cy.agorise.bitsybitshareswallet.activities.SendTransactionActivity
import cy.agorise.bitsybitshareswallet.viewmodels.BalancesViewModel
import cy.agorise.bitsybitshareswallet.adapters.BalancesAdapter
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel
import kotlinx.android.synthetic.main.fragment_balances.*
class BalancesFragment : Fragment() {
class BalancesFragment: Fragment() {
private val TAG = this.javaClass.simpleName
companion object {
fun newInstance() = BalancesFragment()
}
private lateinit var viewModel: BalancesViewModel
private lateinit var mBalanceDetailViewModel: BalanceDetailViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_balances, container, false)
}
setHasOptionsMenu(true)
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(BalancesViewModel::class.java)
// TODO: Use the ViewModel
return inflater.inflate(R.layout.fragment_balances, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
btnReceive.setOnClickListener {
val intent = Intent(view.context, ReceiveTransactionActivity::class.java)
startActivity(intent)
}
// Configure BalanceDetailViewModel to show the current balances
mBalanceDetailViewModel = ViewModelProviders.of(this).get(BalanceDetailViewModel::class.java)
btnSend.setOnClickListener {
val intent = Intent(view.context, SendTransactionActivity::class.java)
startActivity(intent)
}
val balancesAdapter = BalancesAdapter(context!!)
rvBalances.adapter = balancesAdapter
rvBalances.layoutManager = LinearLayoutManager(context!!)
rvBalances.addItemDecoration(DividerItemDecoration(context!!, DividerItemDecoration.VERTICAL))
mBalanceDetailViewModel.getAll().observe(this, Observer<List<BalanceDetail>> { balancesDetails ->
balancesAdapter.replaceAll(balancesDetails)
})
}
}
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
super.setUserVisibleHint(isVisibleToUser)
if (isVisibleToUser) {
// TODO find a better way to recreate the fragment, that does it only when the theme has been changed
fragmentManager!!.beginTransaction().detach(this).attach(this).commit()
}
}
}

View file

@ -0,0 +1,316 @@
package cy.agorise.bitsybitshareswallet.fragments
import android.app.Dialog
import android.content.res.Resources
import android.os.Bundle
import android.os.Handler
import android.os.Message
import androidx.fragment.app.DialogFragment
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import cy.agorise.bitsybitshareswallet.R
import java.text.SimpleDateFormat
import java.util.*
import kotlin.ClassCastException
/**
* Creates a Dialog that communicates with {@link TransactionsActivity} to give it parameters about
* how to filter the list of Transactions
*/
class FilterOptionsDialog : DialogFragment() {
// Widgets TODO use android-kotlin-extensions {onViewCreated}
lateinit var rbTransactionAll: RadioButton
lateinit var rbTransactionSent: RadioButton
lateinit var rbTransactionReceived: RadioButton
lateinit var cbDateRange: CheckBox
lateinit var llDateRange: LinearLayout
lateinit var tvStartDate: TextView
lateinit var tvEndDate: TextView
lateinit var cbCryptocurrency: CheckBox
lateinit var sCryptocurrency: Spinner
lateinit var cbFiatAmount: CheckBox
lateinit var llFiatAmount: LinearLayout
// lateinit var etFromFiatAmount: CurrencyEditText
// lateinit var etToFiatAmount: CurrencyEditText
private var mCallback: OnFilterOptionsSelectedListener? = null
private var mDatePickerHandler: DatePickerHandler? = null
private var dateFormat: SimpleDateFormat = SimpleDateFormat("d/MMM/yyyy",
Resources.getSystem().configuration.locale)
private var startDate: Long = 0
private var endDate: Long = 0
// /**
// * Variable used to keep track of the current user's currency
// */
// private val mUserCurrency = RuntimeData.EXTERNAL_CURRENCY
companion object {
const val KEY_FILTER_TRANSACTION_DIRECTION = "key_filter_transaction_direction"
const val KEY_FILTER_DATE_RANGE_ALL = "key_filter_date_range_all"
const val KEY_FILTER_START_DATE = "key_filter_start_date"
const val KEY_FILTER_END_DATE = "key_filter_end_date"
const val KEY_FILTER_CRYPTOCURRENCY_ALL = "key_filter_cryptocurrency_all"
const val KEY_FILTER_CRYPTOCURRENCY = "key_filter_cryptocurrency"
const val KEY_FILTER_FIAT_AMOUNT_ALL = "key_filter_fiat_amount_all"
const val KEY_FILTER_FROM_FIAT_AMOUNT = "filter_from_fiat_amount"
const val KEY_FILTER_TO_FIAT_AMOUNT = "filter_to_fiat_amount"
const val KEY_TIMESTAMP = "key_timestamp"
const val START_DATE_PICKER = 0
const val END_DATE_PICKER = 1
fun newInstance(filterTransactionsDirection: Int, filterDateRangeAll: Boolean,
filterStartDate: Long, filterEndDate: Long, filterCryptocurrencyAll: Boolean,
filterCryptocurrency: String, filterFiatAmountAll: Boolean,
filterFromFiatAmount: Long, filterToFiatAmount: Long): FilterOptionsDialog {
val frag = FilterOptionsDialog()
val args = Bundle()
args.putInt(KEY_FILTER_TRANSACTION_DIRECTION, filterTransactionsDirection)
args.putBoolean(KEY_FILTER_DATE_RANGE_ALL, filterDateRangeAll)
args.putLong(KEY_FILTER_START_DATE, filterStartDate)
args.putLong(KEY_FILTER_END_DATE, filterEndDate)
args.putBoolean(KEY_FILTER_CRYPTOCURRENCY_ALL, filterCryptocurrencyAll)
args.putString(KEY_FILTER_CRYPTOCURRENCY, filterCryptocurrency)
args.putBoolean(KEY_FILTER_FIAT_AMOUNT_ALL, filterFiatAmountAll)
args.putLong(KEY_FILTER_FROM_FIAT_AMOUNT, filterFromFiatAmount)
args.putLong(KEY_FILTER_TO_FIAT_AMOUNT, filterToFiatAmount)
frag.arguments = args
return frag
}
}
/**
* DatePicker message handler.
*/
inner class DatePickerHandler : Handler() {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
val bundle = msg.data
val timestamp = bundle.get(KEY_TIMESTAMP) as Long
//Log.d(TAG, "timestamp: $timestamp")
when (msg.arg1) {
START_DATE_PICKER -> {
startDate = timestamp
updateDateTextViews()
}
END_DATE_PICKER -> {
endDate = timestamp
// Make sure there is at least one moth difference between start and end time
val calendar = Calendar.getInstance()
calendar.timeInMillis = endDate
calendar.add(Calendar.MONTH, -1)
val tmpTime = calendar.timeInMillis
if (tmpTime < startDate)
startDate = tmpTime
updateDateTextViews()
}
}
}
}
private fun updateDateTextViews() {
var date = Date(startDate)
tvStartDate.text = dateFormat.format(date)
date = Date(endDate)
tvEndDate.text = dateFormat.format(date)
}
// Container Activity must implement this interface
interface OnFilterOptionsSelectedListener {
fun onFilterOptionsSelected(filterTransactionsDirection: Int,
filterDateRangeAll: Boolean,
filterStartDate: Long,
filterEndDate: Long,
filterCryptocurrencyAll: Boolean,
filterCryptocurrency: String,
filterFiatAmountAll: Boolean,
filterFromFiatAmount: Long,
filterToFiatAmount: Long)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
onAttachToParentFragment(parentFragment!!)
// Initialize handler for communication with the DatePicker
mDatePickerHandler = DatePickerHandler()
val builder = AlertDialog.Builder(context!!)
.setTitle("Filter options")
.setPositiveButton("Filter") { _, _ -> validateFields() }
.setNegativeButton("Cancel") { _, _ -> dismiss() }
// Inflate layout
val inflater = activity!!.layoutInflater
val view = inflater.inflate(R.layout.dialog_filter_options, null)
// Initialize Transactions direction
rbTransactionAll = view.findViewById(R.id.rbTransactionAll)
rbTransactionSent = view.findViewById(R.id.rbTransactionSent)
rbTransactionReceived = view.findViewById(R.id.rbTransactionReceived)
val radioButtonChecked = arguments!!.getInt(KEY_FILTER_TRANSACTION_DIRECTION, 0)
when (radioButtonChecked) {
0 -> rbTransactionAll.isChecked = true
1 -> rbTransactionSent.isChecked = true
2 -> rbTransactionReceived.isChecked = true
}
// Initialize Date range
cbDateRange = view.findViewById(R.id.cbDateRange)
// llDateRange = view.findViewById(R.id.llDateRange)
// cbDateRange.setOnCheckedChangeListener { _, isChecked ->
// llDateRange.visibility = if(isChecked) View.GONE else View.VISIBLE }
cbDateRange.isChecked = arguments!!.getBoolean(KEY_FILTER_DATE_RANGE_ALL, true)
//
// tvStartDate = view.findViewById(R.id.tvStartDate)
// tvEndDate = view.findViewById(R.id.tvEndDate)
//
// startDate = arguments!!.getLong(KEY_FILTER_START_DATE, 0)
// tvStartDate.setOnClickListener(mDateClickListener)
//
// endDate = arguments!!.getLong(KEY_FILTER_END_DATE, 0)
// tvEndDate.setOnClickListener(mDateClickListener)
//
// updateDateTextViews()
// Initialize Cryptocurrency
cbCryptocurrency = view.findViewById(R.id.cbCryptocurrency)
// sCryptocurrency = view.findViewById(R.id.sCryptocurrency)
// cbCryptocurrency.setOnCheckedChangeListener { _, isChecked ->
// sCryptocurrency.visibility = if(isChecked) View.GONE else View.VISIBLE }
cbCryptocurrency.isChecked = arguments!!.getBoolean(KEY_FILTER_CRYPTOCURRENCY_ALL, true)
// sCryptocurrency = view.findViewById(R.id.sCryptocurrency)
// initializeCryptocurrencySpinner()
// Initialize Fiat amount
cbFiatAmount = view.findViewById(R.id.cbFiatAmount)
// llFiatAmount = view.findViewById(R.id.llFiatAmount)
// cbFiatAmount.setOnCheckedChangeListener { _, isChecked ->
// llFiatAmount.visibility = if(isChecked) View.GONE else View.VISIBLE }
cbFiatAmount.isChecked = arguments!!.getBoolean(KEY_FILTER_FIAT_AMOUNT_ALL, true)
// val locale = Resources.getSystem().configuration.locale
//
// etFromFiatAmount = view.findViewById(R.id.etFromFiatAmount)
// etFromFiatAmount.locale = locale
// val fromFiatAmount = arguments!!.getLong(KEY_FILTER_FROM_FIAT_AMOUNT, 0)
// etFromFiatAmount.setText("$fromFiatAmount", TextView.BufferType.EDITABLE)
//
// etToFiatAmount = view.findViewById(R.id.etToFiatAmount)
// etToFiatAmount.locale = locale
// val toFiatAmount = arguments!!.getLong(KEY_FILTER_TO_FIAT_AMOUNT, 0)
// etToFiatAmount.setText("$toFiatAmount", TextView.BufferType.EDITABLE)
builder.setView(view)
return builder.create()
}
/**
* Attaches the current [DialogFragment] to its [Fragment] parent, to initialize the
* [OnFilterOptionsSelectedListener] interface
*/
private fun onAttachToParentFragment(fragment: Fragment) {
try {
mCallback = fragment as OnFilterOptionsSelectedListener
} catch (e: ClassCastException) {
throw ClassCastException(fragment.toString() + " must implement OnFilterOptionsSelectedListener")
}
}
// private fun initializeCryptocurrencySpinner() {
// val cryptoCurrencyList = database!!.getSortedCryptoCurrencies(false,
// SortType.DESCENDING, true)
//
// val cryptocurrencySpinnerAdapter = CryptocurrencySpinnerAdapter(context!!,
// R.layout.item_cryptocurrency,
// R.id.tvCryptocurrencyName,
// cryptoCurrencyList)
//
// sCryptocurrency.adapter = cryptocurrencySpinnerAdapter
//
// val cryptocurrencySelected = arguments!!.getString(KEY_FILTER_CRYPTOCURRENCY)
//
// val index = Math.max(cryptocurrencySpinnerAdapter.getPosition(database!!.getCryptocurrencyBySymbol(
// cryptocurrencySelected)), 0)
//
// sCryptocurrency.setSelection(index)
// }
// private val mDateClickListener = View.OnClickListener { v ->
// val calendar = Calendar.getInstance()
//
// // Variable used to select that date on the calendar
// var currentTime = calendar.timeInMillis
// var maxTime = currentTime
//
// var which = -1
// if (v.id == R.id.tvStartDate) {
// which = START_DATE_PICKER
// currentTime = startDate
// calendar.timeInMillis = endDate
// calendar.add(Calendar.MONTH, -1)
// maxTime = calendar.timeInMillis
// } else if (v.id == R.id.tvEndDate) {
// which = END_DATE_PICKER
// currentTime = endDate
// }
//
// val datePickerFragment = DatePickerFragment.newInstance(which, currentTime,
// maxTime, mDatePickerHandler)
// datePickerFragment.show(activity!!.supportFragmentManager, "date-picker")
// }
private fun validateFields() {
val filterTransactionsDirection = when {
rbTransactionAll.isChecked -> 0
rbTransactionSent.isChecked -> 1
rbTransactionReceived.isChecked -> 2
else -> { 0 }
}
val filterDateRangeAll = cbDateRange.isChecked
val filterCryptocurrencyAll = cbCryptocurrency.isChecked
val filterCryptocurrency = "" //(sCryptocurrency.selectedItem as CryptoCurrency).symbol
val filterFiatAmountAll = cbFiatAmount.isChecked
val filterFromFiatAmount = 0L//(etFromFiatAmount.currencyDouble *
// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble())).toLong()
var filterToFiatAmount = 1L//(etToFiatAmount.currencyDouble *
// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble())).toLong()
// Make sure ToFiatAmount is at least 50 units bigger than FromFiatAmount
// if (!filterFiatAmountAll && filterToFiatAmount <= filterFromFiatAmount) {
// filterToFiatAmount = filterFromFiatAmount + 50 *
// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble()).toLong()
// }
mCallback!!.onFilterOptionsSelected(filterTransactionsDirection, filterDateRangeAll,
startDate, endDate, filterCryptocurrencyAll, filterCryptocurrency, filterFiatAmountAll,
filterFromFiatAmount, filterToFiatAmount)
}
}

View file

@ -0,0 +1,101 @@
package cy.agorise.bitsybitshareswallet.fragments
import androidx.lifecycle.ViewModelProviders
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.view.*
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.Observer
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.entities.UserAccount
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel
import kotlinx.android.synthetic.main.fragment_home.*
class HomeFragment : Fragment() {
private lateinit var mUserAccountViewModel: UserAccountViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
setHasOptionsMenu(true)
val toolbar: Toolbar? = activity?.findViewById(R.id.toolbar)
toolbar?.navigationIcon = resources.getDrawable(R.drawable.ic_bitsy_logo_2, null)
return inflater.inflate(R.layout.fragment_home, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Configure UserAccountViewModel to show the current account
mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java)
val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
mUserAccountViewModel.getUserAccount(userId!!).observe(this, Observer<UserAccount>{ user ->
tvAccountName.text = user.name
})
// Navigate to the Receive Transaction Fragment
fabReceiveTransaction.setOnClickListener (
Navigation.createNavigateOnClickListener(R.id.receive_action)
)
// Navigate to the Send Transaction Fragment without activating the camera
fabSendTransaction.setOnClickListener(
Navigation.createNavigateOnClickListener(R.id.send_action)
)
// Navigate to the Send Transaction Fragment using Navigation's SafeArgs to activate the camera
fabSendTransactionCamera.setOnClickListener {
val action = HomeFragmentDirections.sendActionCamera()
action.setOpenCamera(true)
findNavController().navigate(action)
}
// Configure ViewPager with PagerAdapter and TabLayout to display the Balances/NetWorth section
val pagerAdapter = PagerAdapter(fragmentManager!!)
viewPager.adapter = pagerAdapter
tabLayout.setupWithViewPager(viewPager)
// Set the pie chart icon for the third tab
tabLayout.getTabAt(2)?.setIcon(R.drawable.ic_pie_chart)
}
/**
* Pager adapter to create the placeholder fragments
*/
private inner class PagerAdapter internal constructor(fm: FragmentManager) : FragmentPagerAdapter(fm) {
override fun getItem(position: Int): Fragment {
// getItem is called to instantiate the fragment for the given page.
return if (position == 0)
BalancesFragment()
else
NetWorthFragment()
}
override fun getPageTitle(position: Int): CharSequence? {
return listOf(getString(R.string.title_balances), getString(R.string.title_net_worth), "")[position]
}
override fun getCount(): Int {
return 3
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_home, menu)
}
}

View file

@ -1,22 +1,42 @@
package cy.agorise.bitsybitshareswallet.fragments
import androidx.lifecycle.ViewModelProviders
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MarkerOptions
import com.google.gson.GsonBuilder
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.viewmodels.MerchantsViewModel
import cy.agorise.bitsybitshareswallet.models.Merchant
import cy.agorise.bitsybitshareswallet.network.AmbassadorService
import cy.agorise.bitsybitshareswallet.network.FeathersResponse
import retrofit2.Call
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import com.google.android.gms.maps.model.BitmapDescriptor
import cy.agorise.bitsybitshareswallet.utils.Constants
class MerchantsFragment : Fragment() {
companion object {
fun newInstance() = MerchantsFragment()
}
class MerchantsFragment : Fragment(), OnMapReadyCallback, retrofit2.Callback<FeathersResponse<Merchant>> {
private lateinit var viewModel: MerchantsViewModel
private lateinit var mMap: GoogleMap
private var merchants: List<Merchant>? = null
private lateinit var merchantIcon: BitmapDescriptor
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@ -25,10 +45,73 @@ class MerchantsFragment : Fragment() {
return inflater.inflate(R.layout.fragment_merchants, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(MerchantsViewModel::class.java)
// TODO: Use the ViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Obtain the SupportMapFragment and get notified when the map is ready to be used.
val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
merchantIcon = getMarkerIconFromDrawable(resources.getDrawable(R.drawable.ic_pin_merchants, null))
// TODO https://github.com/Agorise/bitsy-wallet/blob/feat_merchants/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MapFragment.kt
}
/**
* Manipulates the map once available.
* This callback is triggered when the map is ready to be used.
* This is where we can add markers or lines, add listeners or move the camera. In this case,
* we just add a marker near Sydney, Australia.
* If Google Play services is not installed on the device, the user will be prompted to install
* it inside the SupportMapFragment. This method will only be triggered once the user has
* installed Google Play services and returned to the app.
*/
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
val gson = GsonBuilder()
.setLenient()
.create()
val retrofit = Retrofit.Builder()
.baseUrl(Constants.MERCHANTS_WEBSERVICE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
val ambassadorService = retrofit.create<AmbassadorService>(AmbassadorService::class.java)
val call = ambassadorService.allMerchants
call.enqueue(this)
}
override fun onResponse(call: Call<FeathersResponse<Merchant>>, response: Response<FeathersResponse<Merchant>>) {
if (response.isSuccessful) {
val res: FeathersResponse<Merchant>? = response.body()
merchants = res!!.data
for (mer in merchants!!) {
val location = LatLng(mer.lat.toDouble(), mer.lon.toDouble())
mMap.addMarker(
MarkerOptions().position(location).title(mer.name).snippet(mer.address).icon(merchantIcon)
)
}
} else {
try {
Log.e("error_bitsy", response.errorBody()?.string())
} catch (e: IOException) {
e.printStackTrace()
}
}
}
override fun onFailure(call: Call<FeathersResponse<Merchant>>, t: Throwable) {
}
private fun getMarkerIconFromDrawable(drawable: Drawable): BitmapDescriptor {
val canvas = Canvas()
val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
canvas.setBitmap(bitmap)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
}

View file

@ -0,0 +1,28 @@
package cy.agorise.bitsybitshareswallet.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import cy.agorise.bitsybitshareswallet.R
class NetWorthFragment: Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
setHasOptionsMenu(true)
return inflater.inflate(R.layout.fragment_net_worth, container, false)
}
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
super.setUserVisibleHint(isVisibleToUser)
if (isVisibleToUser) {
// TODO find a better way to recreate the fragment, that does it only when the theme has been changed
fragmentManager!!.beginTransaction().detach(this).attach(this).commit()
}
}
}

View file

@ -0,0 +1,444 @@
package cy.agorise.bitsybitshareswallet.fragments
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
import android.view.*
import android.widget.AdapterView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.common.primitives.UnsignedLong
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import com.jakewharton.rxbinding3.widget.textChanges
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.adapters.AssetsAdapter
import cy.agorise.bitsybitshareswallet.adapters.AutoSuggestAssetAdapter
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.Helper
import cy.agorise.bitsybitshareswallet.viewmodels.AssetViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel
import cy.agorise.graphenej.*
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.ListAssets
import cy.agorise.graphenej.models.JsonRpcResponse
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.fragment_receive_transaction.*
import java.lang.Exception
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
class ReceiveTransactionFragment : Fragment(), ServiceConnection {
private val TAG = this.javaClass.simpleName
private val RESPONSE_LIST_ASSETS = 1
private val REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION = 100
/** Number of assets to request from the NetworkService to show as suggestions in the AutoCompleteTextView */
private val AUTO_SUGGEST_ASSET_LIMIT = 5
private val OTHER_ASSET = "other_asset"
private lateinit var mUserAccountViewModel: UserAccountViewModel
private lateinit var mAssetViewModel: AssetViewModel
/** Current user account */
private var mUserAccount: UserAccount? = null
private var mDisposables = CompositeDisposable()
private var mAsset: Asset? = null
private var mAssetsAdapter: AssetsAdapter? = null
private lateinit var mAutoSuggestAssetAdapter: AutoSuggestAssetAdapter
private var mAssets = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.Asset>()
private var selectedAssetSymbol = ""
/** Used to avoid erasing the QR code when the user selects an item from the AutoComplete suggestions */
private var selectedInAutoCompleteTextView = false
// Map used to keep track of request and response id pairs
private val responseMap = HashMap<Long, Int>()
/* Network service connection */
private var mNetworkService: NetworkService? = null
/** Flag used to keep track of the NetworkService binding state */
private var mShouldUnbindNetwork: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true)
return inflater.inflate(R.layout.fragment_receive_transaction, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Configure UserAccountViewModel to show the current account
mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java)
val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
mUserAccountViewModel.getUserAccount(userId!!).observe(this,
Observer<cy.agorise.bitsybitshareswallet.database.entities.UserAccount>{ user ->
mUserAccount = UserAccount(user.id, user.name)
})
// Configure Assets spinner to show Assets already saved into the db
mAssetViewModel = ViewModelProviders.of(this).get(AssetViewModel::class.java)
mAssetViewModel.getAll().observe(this,
Observer<List<cy.agorise.bitsybitshareswallet.database.entities.Asset>> { assets ->
mAssets.clear()
mAssets.addAll(assets)
// Add an option at the end so the user can search for an asset other than the ones saved in the db
val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset(
OTHER_ASSET, "Other...", 0, "", ""
)
mAssets.add(asset)
mAssetsAdapter = AssetsAdapter(context!!, android.R.layout.simple_spinner_item, mAssets)
spAsset.adapter = mAssetsAdapter
// Try to select the selectedAssetSymbol
for (i in 0 until mAssetsAdapter!!.count) {
if (mAssetsAdapter!!.getItem(i)!!.symbol == selectedAssetSymbol) {
spAsset.setSelection(i)
break
}
}
})
spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener{
override fun onNothingSelected(parent: AdapterView<*>?) { }
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val asset = mAssetsAdapter!!.getItem(position)!!
if (asset.id == OTHER_ASSET) {
tilAsset.visibility = View.VISIBLE
mAsset = null
} else {
tilAsset.visibility = View.GONE
selectedAssetSymbol = asset.symbol
mAsset = Asset(asset.id, asset.symbol, asset.precision)
}
updateQR()
}
}
// Use RxJava Debounce to create QR code only after the user stopped typing an amount
mDisposables.add(
tietAmount.textChanges()
.debounce(1000, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { updateQR() }
)
// Add adapter to the Assets AutoCompleteTextView
mAutoSuggestAssetAdapter = AutoSuggestAssetAdapter(context!!, android.R.layout.simple_dropdown_item_1line)
actvAsset.setAdapter(mAutoSuggestAssetAdapter)
// Use RxJava Debounce to avoid making calls to the NetworkService on every text change event and also avoid
// the first call when the View is created
mDisposables.add(
actvAsset.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.map { it.toString().trim().toUpperCase() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
if (!selectedInAutoCompleteTextView) {
mAsset = null
updateQR()
}
selectedInAutoCompleteTextView = false
// Get a list of assets that match the already typed string by the user
if (it.length > 1 && mNetworkService != null) {
val id = mNetworkService!!.sendMessage(ListAssets(it, AUTO_SUGGEST_ASSET_LIMIT),
ListAssets.REQUIRED_API)
responseMap[id] = RESPONSE_LIST_ASSETS
}
}
)
actvAsset.setOnItemClickListener { parent, _, position, _ ->
val asset = parent.adapter.getItem(position) as cy.agorise.bitsybitshareswallet.database.entities.Asset
mAsset = Asset(asset.id, asset.symbol, asset.precision)
selectedInAutoCompleteTextView = true
updateQR()
}
// Connect to the RxBus, which receives events from the NetworkService
mDisposables.add(
RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { handleIncomingMessage(it) }
)
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
if (responseMap.containsKey(message.id)) {
val responseType = responseMap[message.id]
when (responseType) {
RESPONSE_LIST_ASSETS -> handleListAssets(message.result as List<Asset>)
}
responseMap.remove(message.id)
}
} else if (message is ConnectionStatusUpdate) {
if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) {
// If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset
responseMap.clear()
}
}
}
private fun handleListAssets(assetList: List<Asset>) {
Log.d(TAG, "handleListAssets")
val assets = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.Asset>()
for (_asset in assetList) {
val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset(
_asset.objectId,
_asset.symbol,
_asset.precision,
_asset.description ?: "",
_asset.bitassetId ?: ""
)
assets.add(asset)
}
mAutoSuggestAssetAdapter.setData(assets)
mAutoSuggestAssetAdapter.notifyDataSetChanged()
}
private fun updateQR() {
if (mAsset == null) {
ivQR.setImageDrawable(null)
// TODO clean the please pay and to text at the bottom too
return
}
// Try to obtain the amount from the Amount Text Field or make it zero otherwise
val amount: Long = try {
val tmpAmount = tietAmount.text.toString().toDouble()
(tmpAmount * Math.pow(10.0, mAsset!!.precision.toDouble())).toLong()
}catch (e: Exception) {
0
}
val total = AssetAmount(UnsignedLong.valueOf(amount), mAsset!!)
val totalInDouble = Util.fromBase(total)
val items = arrayOf(LineItem("transfer", 1, totalInDouble))
val invoice = Invoice(mUserAccount!!.name, "", "#bitsy", mAsset!!.symbol, items, "", "")
Log.d(TAG, "invoice: " + invoice.toJsonString())
try {
val bitmap = encodeAsBitmap(Invoice.toQrCode(invoice), "#139657") // PalmPay green
ivQR.setImageBitmap(bitmap)
updateAmountAddressUI(total, mUserAccount!!.name)
} catch (e: WriterException) {
Log.e(TAG, "WriterException. Msg: " + e.message)
}
}
/**
* Encodes the provided data as a QR-code. Used to provide payment requests.
* @param data: Data containing payment request data as the recipient's address and the requested amount.
* @param color: The color used for the QR-code
* @return Bitmap with the QR-code encoded data
* @throws WriterException if QR Code cannot be generated
*/
@Throws(WriterException::class)
internal fun encodeAsBitmap(data: String, color: String): Bitmap? {
val result: BitMatrix
// Get measured width and height of the ImageView where the QR code will be placed
var w = ivQR.width
var h = ivQR.height
// Gets minimum side length and sets both width and height to that value so the final
// QR code has a squared shape
val minSide = if (w < h) w else h
h = minSide
w = h
try {
val hints = HashMap<EncodeHintType, Any>()
hints[EncodeHintType.MARGIN] = 0
result = MultiFormatWriter().encode(
data,
BarcodeFormat.QR_CODE, w, h, hints
)
} catch (iae: IllegalArgumentException) {
// Unsupported format
return null
}
val pixels = IntArray(w * h)
for (y in 0 until h) {
val offset = y * w
for (x in 0 until w) {
pixels[offset + x] = if (result.get(x, y)) Color.parseColor(color) else Color.WHITE
}
}
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, w, 0, 0, w, h)
return bitmap
}
/**
* Updates the UI to show amount and account to send the payment
*
* @param total Total Amount in crypto to be paid
* @param account Account to pay total
*/
private fun updateAmountAddressUI(total: AssetAmount, account: String) {
val df = DecimalFormat("####."+("#".repeat(total.asset.precision)))
df.roundingMode = RoundingMode.CEILING
df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
val amount = total.amount.toDouble() / Math.pow(10.toDouble(), total.asset.precision.toDouble())
val strAmount = df.format(amount)
val txtAmount = getString(R.string.template__please_pay, strAmount, total.asset.symbol)
val txtAccount = getString(R.string.template__to, account)
tvPleasePay.text = txtAmount
tvTo.text = txtAccount
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_receive_transaction, menu)
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
if (item?.itemId == R.id.menu_share) {
verifyStoragePermission()
return true
}
return super.onOptionsItemSelected(item)
}
private fun verifyStoragePermission() {
if (ContextCompat.checkSelfPermission(activity!!, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
// Permission is not already granted
requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION)
} else {
// Permission is already granted
shareQRScreenshot()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
shareQRScreenshot()
} else {
// TODO extract string resource
Toast.makeText(context!!, "Storage permission is necessary to share QR codes.", Toast.LENGTH_SHORT).show()
}
return
}
}
/**
* This function takes a screenshot as a bitmap, saves it into a temporal cache image and then
* sends an intent so the user can select the desired method to share the image.
*/
private fun shareQRScreenshot() {
// TODO improve, show errors where necessary so the user can fix it
// Avoid sharing the QR code image if the fields are not filled correctly
if (mAsset == null)
return
// Get Screenshot
val screenshot = Helper.loadBitmapFromView(container)
val imageUri = Helper.saveTemporalBitmap(context!!, screenshot)
// Prepare information for share intent
val subject = getString(R.string.msg__invoice_subject, mUserAccount?.name)
val content = tvPleasePay.text.toString() + "\n" +
tvTo.text.toString()
// Create share intent and call it
val shareIntent = Intent()
shareIntent.action = Intent.ACTION_SEND
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // temp permission for receiving app to read this file
shareIntent.putExtra(Intent.EXTRA_STREAM, imageUri)
shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject)
shareIntent.putExtra(Intent.EXTRA_TEXT, content)
shareIntent.type = "*/*"
startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with)))
}
override fun onResume() {
super.onResume()
val intent = Intent(context, NetworkService::class.java)
if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) {
mShouldUnbindNetwork = true
} else {
Log.e(TAG, "Binding to the network service failed.")
}
}
override fun onPause() {
super.onPause()
// Unbinding from network service
if (mShouldUnbindNetwork) {
context?.unbindService(this)
mShouldUnbindNetwork = false
}
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposables.isDisposed) mDisposables.dispose()
}
override fun onServiceDisconnected(name: ComponentName?) { }
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as NetworkService.LocalBinder
mNetworkService = binder.service
}
}

View file

@ -0,0 +1,476 @@
package cy.agorise.bitsybitshareswallet.fragments
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.common.primitives.UnsignedLong
import com.google.zxing.BarcodeFormat
import com.google.zxing.Result
import com.jakewharton.rxbinding3.widget.textChanges
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.adapters.BalancesDetailsAdapter
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel
import cy.agorise.graphenej.*
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.BroadcastTransaction
import cy.agorise.graphenej.api.calls.GetAccountByName
import cy.agorise.graphenej.api.calls.GetDynamicGlobalProperties
import cy.agorise.graphenej.api.calls.GetRequiredFees
import cy.agorise.graphenej.models.AccountProperties
import cy.agorise.graphenej.models.DynamicGlobalProperties
import cy.agorise.graphenej.models.JsonRpcResponse
import cy.agorise.graphenej.operations.TransferOperationBuilder
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.fragment_send_transaction.*
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.bitcoinj.core.DumpedPrivateKey
import org.bitcoinj.core.ECKey
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.ArrayList
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.crypto.AEADBadTagException
class SendTransactionFragment : Fragment(), ZXingScannerView.ResultHandler, ServiceConnection {
private val TAG = this.javaClass.simpleName
// Camera Permission
private val REQUEST_CAMERA_PERMISSION = 1
private val RESPONSE_GET_ACCOUNT_BY_NAME = 1
private val RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS = 2
private val RESPONSE_GET_REQUIRED_FEES = 3
private val RESPONSE_BROADCAST_TRANSACTION = 4
private var isCameraPreviewVisible = false
private var isToAccountCorrect = false
private var isAmountCorrect = false
private var mBalancesDetails: List<BalanceDetail>? = null
private lateinit var mBalanceDetailViewModel: BalanceDetailViewModel
private var mBalancesDetailsAdapter: BalancesDetailsAdapter? = null
private var selectedAssetSymbol = ""
/** Current user account */
private var mUserAccount: UserAccount? = null
/** User account to which send the funds */
private var mSelectedUserAccount: UserAccount? = null
private var mDisposables = CompositeDisposable()
/* Network service connection */
private var mNetworkService: NetworkService? = null
/** Flag used to keep track of the NetworkService binding state */
private var mShouldUnbindNetwork: Boolean = false
// Map used to keep track of request and response id pairs
private val responseMap = HashMap<Long, Int>()
private var transaction: Transaction? = null
/** Variable holding the current user's private key in the WIF format */
private var wifKey: String? = null
/** Repository to access and update Authorities */
private var authorityRepository: AuthorityRepository? = null
/* This is one of the of the recipient account's public key, it will be used for memo encoding */
private var destinationPublicKey: PublicKey? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_send_transaction, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
if (userId != "")
mUserAccount = UserAccount(userId)
// Use Navigation SafeArgs to decide if we should activate or not the camera feed
val safeArgs = SendTransactionFragmentArgs.fromBundle(arguments)
if (safeArgs.openCamera)
verifyCameraPermission()
fabOpenCamera.setOnClickListener { if (isCameraPreviewVisible) stopCameraPreview() else verifyCameraPermission() }
// Configure BalanceDetailViewModel to show the current balances
mBalanceDetailViewModel = ViewModelProviders.of(this).get(BalanceDetailViewModel::class.java)
mBalanceDetailViewModel.getAll().observe(this, Observer<List<BalanceDetail>> { balancesDetails ->
mBalancesDetails = balancesDetails
mBalancesDetailsAdapter = BalancesDetailsAdapter(context!!, android.R.layout.simple_spinner_item, mBalancesDetails!!)
spAsset.adapter = mBalancesDetailsAdapter
// Try to select the selectedAssetSymbol
for (i in 0 until mBalancesDetailsAdapter!!.count) {
if (mBalancesDetailsAdapter!!.getItem(i)!!.symbol == selectedAssetSymbol) {
spAsset.setSelection(i)
break
}
}
})
spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener{
override fun onNothingSelected(parent: AdapterView<*>?) { }
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val balance = mBalancesDetailsAdapter!!.getItem(position)!!
selectedAssetSymbol = balance.symbol
val amount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble())
tvAvailableAssetAmount.text =
String.format("%." + Math.min(balance.precision, 8) + "f %s", amount, balance.symbol)
}
}
fabSendTransaction.setOnClickListener { startSendTransferOperation() }
fabSendTransaction.hide()
authorityRepository = AuthorityRepository(context!!)
mDisposables.add(
authorityRepository!!.getWIF(userId!!, AuthorityType.ACTIVE.ordinal)
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { encryptedWIF ->
try {
wifKey = CryptoUtils.decrypt(context!!, encryptedWIF)
} catch (e: AEADBadTagException) {
Log.e(TAG, "AEADBadTagException. Class: " + e.javaClass + ", Msg: " + e.message)
}
}
)
// Use RxJava Debounce to avoid making calls to the NetworkService on every text change event
mDisposables.add(
tietTo.textChanges()
.debounce(500, TimeUnit.MILLISECONDS)
.map { it.toString().trim() }
.filter { it.length > 1 }
.subscribe {
val id = mNetworkService!!.sendMessage(GetAccountByName(it!!), GetAccountByName.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ACCOUNT_BY_NAME
}
)
// Use RxJava Debounce to update the Amount error only after the user stops writing for > 500 ms
mDisposables.add(
tietAmount.textChanges()
.debounce(500, TimeUnit.MILLISECONDS)
.filter { it.isNotEmpty() }
.map { it.toString().trim().toDouble() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { validateAmount(it!!) }
)
// Connect to the RxBus, which receives events from the NetworkService
mDisposables.add(
RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { handleIncomingMessage(it) }
)
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
if (responseMap.containsKey(message.id)) {
val responseType = responseMap[message.id]
when (responseType) {
RESPONSE_GET_ACCOUNT_BY_NAME -> handleAccountName(message.result)
RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS -> handleDynamicGlobalProperties(message.result)
RESPONSE_GET_REQUIRED_FEES -> handleRequiredFees(message.result)
RESPONSE_BROADCAST_TRANSACTION -> handleBroadcastTransaction(message)
}
responseMap.remove(message.id)
}
} else if (message is ConnectionStatusUpdate) {
if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) {
// If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset
responseMap.clear()
}
}
}
private fun handleAccountName(result: Any?) {
if (result is AccountProperties) {
mSelectedUserAccount = UserAccount(result.id, result.name)
destinationPublicKey = result.active.keyAuths.keys.iterator().next()
tilTo.isErrorEnabled = false
isToAccountCorrect = true
} else {
mSelectedUserAccount = null
destinationPublicKey = null
tilTo.error = "Invalid account"
isToAccountCorrect = false
}
enableDisableSendFAB()
}
private fun handleDynamicGlobalProperties(result: Any?) {
if (result is DynamicGlobalProperties) {
val expirationTime = (result.time.time / 1000) + Transaction.DEFAULT_EXPIRATION_TIME
val headBlockId = result.head_block_id
val headBlockNumber = result.head_block_number
transaction!!.blockData = BlockData(headBlockNumber, headBlockId, expirationTime)
val asset = Asset(mBalancesDetailsAdapter!!.getItem(spAsset.selectedItemPosition)!!.id)
val id = mNetworkService!!.sendMessage(GetRequiredFees(transaction!!, asset), GetRequiredFees.REQUIRED_API)
responseMap[id] = RESPONSE_GET_REQUIRED_FEES
} else {
// TODO unableToSendTransactionError()
}
}
private fun handleRequiredFees(result: Any?) {
if (result is List<*> && result[0] is AssetAmount) {
Log.d(TAG, "GetRequiredFees: " + transaction.toString())
transaction!!.setFees(result as List<AssetAmount>) // TODO find how to remove this warning
val id = mNetworkService!!.sendMessage(BroadcastTransaction(transaction), BroadcastTransaction.REQUIRED_API)
responseMap[id] = RESPONSE_BROADCAST_TRANSACTION
} else {
// TODO unableToSendTransactionError()
}
}
private fun handleBroadcastTransaction(message: JsonRpcResponse<*>) {
if (message.result == null && message.error == null) {
// TODO extract string resources
Toast.makeText(context!!, "Transaction sent!", Toast.LENGTH_SHORT).show()
// Remove information from the text fields and disable send button
tietTo.setText("")
tietAmount.setText("")
tietMemo.setText("")
isToAccountCorrect = false
isAmountCorrect = false
enableDisableSendFAB()
} else {
// TODO extract error messages to show a better explanation to the user
Toast.makeText(context!!, message.error.message, Toast.LENGTH_LONG).show()
}
}
private fun verifyCameraPermission() {
if (ContextCompat.checkSelfPermission(activity!!, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
// Permission is not already granted
requestPermissions(arrayOf(android.Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION)
} else {
// Permission is already granted
startCameraPreview()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CAMERA_PERMISSION) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
startCameraPreview()
} else {
// TODO extract string resource
Toast.makeText(context!!, "Camera permission is necessary to read QR codes.", Toast.LENGTH_SHORT).show()
}
return
}
}
private fun startCameraPreview() {
cameraPreview.visibility = View.VISIBLE
fabOpenCamera.setImageResource(R.drawable.ic_close)
isCameraPreviewVisible = true
// Configure QR scanner
cameraPreview.setFormats(listOf(BarcodeFormat.QR_CODE))
cameraPreview.setAspectTolerance(0.5f)
cameraPreview.setAutoFocus(true)
cameraPreview.setLaserColor(R.color.colorAccent)
cameraPreview.setMaskColor(R.color.colorAccent)
cameraPreview.setResultHandler(this)
cameraPreview.startCamera()
}
private fun stopCameraPreview() {
cameraPreview.visibility = View.INVISIBLE
fabOpenCamera.setImageResource(R.drawable.ic_camera)
isCameraPreviewVisible = false
cameraPreview.stopCamera()
}
override fun handleResult(result: Result?) {
try {
val invoice = Invoice.fromQrCode(result!!.text)
Log.d(TAG, "QR Code read: " + invoice.toJsonString())
tietTo.setText(invoice.to)
for (i in 0 until mBalancesDetailsAdapter!!.count) {
if (mBalancesDetailsAdapter!!.getItem(i)!!.symbol == invoice.currency.toUpperCase()) {
spAsset.setSelection(i)
break
}
}
tietMemo.setText(invoice.memo)
var amount = 0.0
for (nextItem in invoice.lineItems) {
amount += nextItem.quantity * nextItem.price
}
// TODO Improve pattern to account for different asset precisions
val df = DecimalFormat("####.#####")
df.roundingMode = RoundingMode.CEILING
df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
tietAmount.setText(df.format(amount))
}catch (e: Exception) {
Log.d(TAG, "Invoice error: " + e.message)
}
}
private fun validateAmount(amount: Double) {
if (mBalancesDetailsAdapter?.isEmpty != false) return
val balance = mBalancesDetailsAdapter?.getItem(spAsset.selectedItemPosition) ?: return
val currentAmount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble())
if (currentAmount < amount) {
// TODO extract string resource
tilAmount.error = "Not enough funds"
isAmountCorrect = false
} else {
tilAmount.isErrorEnabled = false
isAmountCorrect = true
}
enableDisableSendFAB()
}
private fun enableDisableSendFAB() {
if (isToAccountCorrect && isAmountCorrect)
fabSendTransaction.show()
else
fabSendTransaction.hide()
}
private fun startSendTransferOperation() {
// Create TransferOperation
if (mNetworkService!!.isConnected) {
val balance = mBalancesDetailsAdapter!!.getItem(spAsset.selectedItemPosition)!!
val amount = (tietAmount.text.toString().toDouble() * Math.pow(10.0, balance.precision.toDouble())).toLong()
val transferAmount = AssetAmount(UnsignedLong.valueOf(amount), Asset(balance.id))
val operationBuilder = TransferOperationBuilder()
.setSource(mUserAccount)
.setDestination(mSelectedUserAccount)
.setTransferAmount(transferAmount)
val privateKey = ECKey.fromPrivate(DumpedPrivateKey.fromBase58(null, wifKey).key.privKeyBytes)
// Add memo if exists TODO enable memo
// val memoMsg = tietMemo.text.toString()
// if (memoMsg.isNotEmpty()) {
// val nonce = SecureRandomGenerator.getSecureRandom().nextLong().toBigInteger()
// val encryptedMemo = Memo.encryptMessage(privateKey, destinationPublicKey!!, nonce, memoMsg)
// val from = Address(ECKey.fromPublicOnly(privateKey.pubKey))
// val to = Address(destinationPublicKey!!.key)
// val memo = Memo(from, to, nonce, encryptedMemo)
// operationBuilder.setMemo(memo)
// }
val operations = ArrayList<BaseOperation>()
operations.add(operationBuilder.build())
transaction = Transaction(privateKey, null, operations)
val id = mNetworkService!!.sendMessage(GetDynamicGlobalProperties(),
GetDynamicGlobalProperties.REQUIRED_API)
responseMap[id] = RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS
} else
Log.d(TAG, "Network Service is not connected")
}
override fun onResume() {
super.onResume()
if (isCameraPreviewVisible)
startCameraPreview()
val intent = Intent(context, NetworkService::class.java)
if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) {
mShouldUnbindNetwork = true
} else {
Log.e(TAG, "Binding to the network service failed.")
}
}
override fun onPause() {
super.onPause()
// Unbinding from network service
if (mShouldUnbindNetwork) {
context?.unbindService(this)
mShouldUnbindNetwork = false
}
if (!isCameraPreviewVisible)
stopCameraPreview()
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposables.isDisposed) mDisposables.dispose()
}
override fun onServiceDisconnected(name: ComponentName?) { }
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as NetworkService.LocalBinder
mNetworkService = binder.service
}
}

View file

@ -0,0 +1,282 @@
package cy.agorise.bitsybitshareswallet.fragments
import android.content.*
import android.content.Context.CLIPBOARD_SERVICE
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.list.customListAdapter
import cy.agorise.bitsybitshareswallet.BuildConfig
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.adapters.FullNodesAdapter
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
import cy.agorise.graphenej.BrainKey
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.GetDynamicGlobalProperties
import cy.agorise.graphenej.models.DynamicGlobalProperties
import cy.agorise.graphenej.models.JsonRpcResponse
import cy.agorise.graphenej.network.FullNode
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.fragment_settings.*
import java.text.NumberFormat
class SettingsFragment : Fragment(), ServiceConnection {
private val TAG = this.javaClass.simpleName
private var mDisposables = CompositeDisposable()
/* Network service connection */
private var mNetworkService: NetworkService? = null
/** Flag used to keep track of the NetworkService binding state */
private var mShouldUnbindNetwork: Boolean = false
// Dialog displaying the list of nodes and their latencies
private var mNodesDialog: MaterialDialog? = null
/** Adapter that holds the FullNode list used in the Bitshares nodes modal */
private var nodesAdapter: FullNodesAdapter? = null
private val mHandler = Handler()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true)
return inflater.inflate(R.layout.fragment_settings, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initAutoCloseSwitch()
initNightModeSwitch()
btnViewBrainKey.setOnClickListener { getBrainkey(it) }
tvNetworkStatus.setOnClickListener { v ->
if (mNetworkService != null) {
// PublishSubject used to announce full node latencies updates
val fullNodePublishSubject = mNetworkService!!.nodeLatencyObservable
fullNodePublishSubject?.observeOn(AndroidSchedulers.mainThread())?.subscribe(nodeLatencyObserver)
val fullNodes = mNetworkService!!.nodes
nodesAdapter = FullNodesAdapter(v.context)
nodesAdapter!!.add(fullNodes)
mNodesDialog = MaterialDialog(v.context)
.title(text = String.format("%s v%s", getString(R.string.app_name), BuildConfig.VERSION_NAME))
.message(text = getString(R.string.title__bitshares_nodes_dialog, "-------"))
.customListAdapter(nodesAdapter as FullNodesAdapter)
.negativeButton(android.R.string.ok) {
mHandler.removeCallbacks(mRequestDynamicGlobalPropertiesTask)
}
mNodesDialog?.show()
// Registering a recurrent task used to poll for dynamic global properties requests
mHandler.post(mRequestDynamicGlobalPropertiesTask)
}
}
// Connect to the RxBus, which receives events from the NetworkService
mDisposables.add(
RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { handleIncomingMessage(it) }
)
}
/**
* Observer used to be notified about node latency measurement updates.
*/
private val nodeLatencyObserver = object : Observer<FullNode> {
override fun onSubscribe(d: Disposable) {
mDisposables.add(d)
}
override fun onNext(fullNode: FullNode) {
if (!fullNode.isRemoved)
nodesAdapter?.add(fullNode)
else
nodesAdapter?.remove(fullNode)
}
override fun onError(e: Throwable) {
Log.e(TAG, "nodeLatencyObserver.onError.Msg: " + e.message)
}
override fun onComplete() {}
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
if (message.result is DynamicGlobalProperties) {
val dynamicGlobalProperties = message.result as DynamicGlobalProperties
if (mNodesDialog != null && mNodesDialog?.isShowing == true) {
val blockNumber = NumberFormat.getInstance().format(dynamicGlobalProperties.head_block_number)
mNodesDialog?.message(text = getString(R.string.title__bitshares_nodes_dialog, blockNumber))
}
}
}
}
/**
* Task used to obtain frequent updates on the global dynamic properties object
*/
private val mRequestDynamicGlobalPropertiesTask = object : Runnable {
override fun run() {
if (mNetworkService != null) {
if (mNetworkService?.isConnected == true) {
mNetworkService?.sendMessage(GetDynamicGlobalProperties(), GetDynamicGlobalProperties.REQUIRED_API)
} else {
Log.d(TAG, "NetworkService exists but is not connected")
}
} else {
Log.d(TAG, "NetworkService reference is null")
}
mHandler.postDelayed(this, Constants.BLOCK_PERIOD)
}
}
/**
* Fetches the relevant preference from the SharedPreferences and configures the corresponding switch accordingly,
* and adds a listener to the said switch to store the preference in case the user changes it.
*/
private fun initAutoCloseSwitch() {
val autoCloseOn = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, true)
switchAutoClose.isChecked = autoCloseOn
switchAutoClose.setOnCheckedChangeListener { buttonView, isChecked ->
PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit()
.putBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, isChecked).apply()
}
}
/**
* Fetches the relevant preference from the SharedPreferences and configures the corresponding switch accordingly,
* and adds a listener to the said switch to store the preference in case the user changes it. Also makes a call to
* recreate the activity and apply the selected theme.
*/
private fun initNightModeSwitch() {
val nightModeOn = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false)
switchNightMode.isChecked = nightModeOn
switchNightMode.setOnCheckedChangeListener { buttonView, isChecked ->
PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit()
.putBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, isChecked).apply()
// Recreates the activity to apply the selected theme
activity?.recreate()
}
}
/**
* Obtains the brainKey from the authorities db table for the current user account and if it is not null it passes
* the brainKey to a method to show it in a nice MaterialDialog
*/
private fun getBrainkey(view: View) {
val userId = PreferenceManager.getDefaultSharedPreferences(view.context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") ?: ""
val authorityRepository = AuthorityRepository(view.context)
mDisposables.add(authorityRepository.get(userId)
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { authority ->
if (authority != null) {
val plainBrainKey = CryptoUtils.decrypt(view.context, authority.encryptedBrainKey)
val plainSequenceNumber = CryptoUtils.decrypt(view.context, authority.encryptedSequenceNumber)
val sequenceNumber = Integer.parseInt(plainSequenceNumber)
val brainKey = BrainKey(plainBrainKey, sequenceNumber)
showBrainKeyDialog(view, brainKey)
}
}
)
}
/**
* Shows the plain brainkey in a dialog so that the user can view and Copy it.
*/
private fun showBrainKeyDialog(view: View, brainKey: BrainKey) {
MaterialDialog(view.context).show {
title(text = "BrainKey")
message(text = brainKey.brainKey)
customView(R.layout.dialog_copy_brainkey)
cancelable(false)
positiveButton(android.R.string.copy) {
Toast.makeText(it.context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
val clipboard = it.context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("label", brainKey.brainKey)
clipboard.primaryClip = clip
it.dismiss()
}
}
}
override fun onResume() {
super.onResume()
val intent = Intent(context, NetworkService::class.java)
if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) {
mShouldUnbindNetwork = true
} else {
Log.e(TAG, "Binding to the network service failed.")
}
}
override fun onPause() {
super.onPause()
// Unbinding from network service
if (mShouldUnbindNetwork) {
context?.unbindService(this)
mShouldUnbindNetwork = false
}
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposables.isDisposed) mDisposables.dispose()
}
override fun onServiceDisconnected(name: ComponentName?) {
tvNetworkStatus.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null,
resources.getDrawable(R.drawable.ic_disconnected, null), null)
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as NetworkService.LocalBinder
mNetworkService = binder.service
tvNetworkStatus.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null,
resources.getDrawable(R.drawable.ic_connected, null), null)
}
}

View file

@ -1,34 +1,216 @@
package cy.agorise.bitsybitshareswallet.fragments
import androidx.lifecycle.ViewModelProviders
import android.graphics.Point
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.*
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import com.jakewharton.rxbinding3.appcompat.queryTextChangeEvents
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.viewmodels.TransactionsViewModel
import cy.agorise.bitsybitshareswallet.adapters.TransfersDetailsAdapter
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.utils.BounceTouchListener
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.viewmodels.TransferDetailViewModel
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.fragment_transactions.*
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
class TransactionsFragment : Fragment() {
class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSelectedListener {
companion object {
fun newInstance() = TransactionsFragment()
}
private lateinit var mTransferDetailViewModel: TransferDetailViewModel
private lateinit var viewModel: TransactionsViewModel
private lateinit var transfersDetailsAdapter: TransfersDetailsAdapter
private val transfersDetails = ArrayList<TransferDetail>()
private val filteredTransfersDetails = ArrayList<TransferDetail>()
/** Variables used to filter the transaction items */
private var filterQuery = ""
private var filterTransactionsDirection = 0
private var filterDateRangeAll = true
private var filterStartDate = 0L
private var filterEndDate = 0L
private var filterCryptocurrencyAll = true
private var filterCryptocurrency = "BTS"
private var filterFiatAmountAll = true
private var filterFromFiatAmount = 0L
private var filterToFiatAmount = 500L
private var mDisposables = CompositeDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_transactions, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(TransactionsViewModel::class.java)
// TODO: Use the ViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") ?: ""
transfersDetailsAdapter = TransfersDetailsAdapter(context!!)
rvTransactions.adapter = transfersDetailsAdapter
rvTransactions.layoutManager = LinearLayoutManager(context)
// Configure TransferDetailViewModel to fetch the transaction history
mTransferDetailViewModel = ViewModelProviders.of(this).get(TransferDetailViewModel::class.java)
mTransferDetailViewModel.getAll(userId).observe(this, Observer<List<TransferDetail>> { transfersDetails ->
this.transfersDetails.clear()
this.transfersDetails.addAll(transfersDetails)
applyFilterOptions(false)
})
// Set custom touch listener to handle bounce/stretch effect
val bounceTouchListener = BounceTouchListener(rvTransactions)
rvTransactions.setOnTouchListener(bounceTouchListener)
// Initialize filter options
val calendar = Calendar.getInstance()
filterEndDate = calendar.timeInMillis / 1000
calendar.add(Calendar.MONTH, -2)
filterStartDate = calendar.timeInMillis / 1000
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_transactions, menu)
// Adds listener for the SearchView
val searchItem = menu.findItem(R.id.menu_search)
val searchView = searchItem.actionView as SearchView
mDisposables.add(
searchView.queryTextChangeEvents()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.map { it.queryText.toString().toLowerCase() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
filterQuery = it
applyFilterOptions()
}
)
// Adjust SearchView width to avoid pushing other menu items out of the screen
searchView.maxWidth = getScreenWidth(activity) * 3 / 5
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
return when (item?.itemId) {
R.id.menu_filter -> {
val filterOptionsDialog = FilterOptionsDialog.newInstance(
filterTransactionsDirection, filterDateRangeAll, filterStartDate * 1000,
filterEndDate * 1000, filterCryptocurrencyAll, filterCryptocurrency,
filterFiatAmountAll, filterFromFiatAmount, filterToFiatAmount
)
filterOptionsDialog.show(childFragmentManager, "filter-options-tag")
true
}
R.id.menu_export -> {
// TODO add export options
true
}
else -> super.onOptionsItemSelected(item)
}
}
/**
* Returns the screen width in pixels for the given [FragmentActivity]
*/
private fun getScreenWidth(activity: FragmentActivity?): Int {
if (activity == null)
return 200
val size = Point()
activity.windowManager.defaultDisplay.getSize(size)
return size.x
}
/**
* Filters the TransferDetail list given the user selected filter options.
* TODO move this to a background thread
*/
private fun applyFilterOptions(scrollToTop: Boolean = true) {
// Clean the filtered list
filteredTransfersDetails.clear()
for (transferDetail in transfersDetails) {
// Filter by transfer direction
if (transferDetail.direction) { // Transfer sent
if (filterTransactionsDirection == 1)
// Looking for received transfers only
continue
} else { // Transfer received
if (filterTransactionsDirection == 2)
// Looking for sent transactions only
continue
}
// Filter by date range
if (!filterDateRangeAll && (transferDetail.date < filterStartDate || transferDetail.date > filterEndDate))
continue
// Filter by cryptocurrency
if (!filterCryptocurrencyAll && transferDetail.cryptoSymbol != filterCryptocurrency)
continue
// // Filter by fiat amount
// if (!filterFiatAmountAll && (transferDetail.fiatAmount < filterFromFiatAmount || transferDetail.fiatAmount > filterToFiatAmount))
// continue
// Filter by search query
val text = (transferDetail.from ?: "").toLowerCase() + (transferDetail.to ?: "").toLowerCase()
if (text.contains(filterQuery, ignoreCase = true)) {
filteredTransfersDetails.add(transferDetail)
}
}
// Replaces the list of TransferDetail items with the new filtered list
transfersDetailsAdapter.replaceAll(filteredTransfersDetails)
if (scrollToTop)
rvTransactions.scrollToPosition(0)
}
/**
*
*/
override fun onFilterOptionsSelected(
filterTransactionsDirection: Int,
filterDateRangeAll: Boolean,
filterStartDate: Long,
filterEndDate: Long,
filterCryptocurrencyAll: Boolean,
filterCryptocurrency: String,
filterFiatAmountAll: Boolean,
filterFromFiatAmount: Long,
filterToFiatAmount: Long
) {
this.filterTransactionsDirection = filterTransactionsDirection
this.filterDateRangeAll = filterDateRangeAll
this.filterStartDate = filterStartDate / 1000
this.filterEndDate = filterEndDate / 1000
this.filterCryptocurrencyAll = filterCryptocurrencyAll
this.filterCryptocurrency = filterCryptocurrency
this.filterFiatAmountAll = filterFiatAmountAll
this.filterFromFiatAmount = filterFromFiatAmount
this.filterToFiatAmount = filterToFiatAmount
applyFilterOptions(true)
}
override fun onDestroy() {
super.onDestroy()
if (!mDisposables.isDisposed) mDisposables.dispose()
}
}

View file

@ -0,0 +1,5 @@
package cy.agorise.bitsybitshareswallet.models
class Ambassador(var id: String?, var account: String?, var country: String?, var city: String?) {
var cityId: String? = null
}

View file

@ -0,0 +1,29 @@
package cy.agorise.bitsybitshareswallet.models
import androidx.annotation.NonNull
class AmbassadorLocation(
var id: String?,
var name: String?,
var country: String?
) : Comparable<AmbassadorLocation> {
override fun toString(): String {
return this.name!!
}
override fun equals(other: Any?): Boolean {
return other is AmbassadorLocation && id == other.id
}
override fun compareTo(@NonNull other: AmbassadorLocation): Int {
return name!!.compareTo(other.name!!)
}
override fun hashCode(): Int {
var result = id?.hashCode() ?: 0
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (country?.hashCode() ?: 0)
return result
}
}

View file

@ -0,0 +1,68 @@
package cy.agorise.bitsybitshareswallet.models
import cy.agorise.graphenej.AssetAmount
import cy.agorise.graphenej.models.OperationHistory
import cy.agorise.graphenej.operations.TransferOperation
/**
* This class is very similar to the OperationHistory, but while the later is used to deserialize
* the transfer information exactly as it comes from the 'get_relative_account_history' API call,
* this class is used to represent a single entry in the local database.
*
*
* Every entry in the transfers table needs a bit more information than what is provided by the
* HistoricalTransfer. We need to know the specific timestamp of a transaction for instance, instead
* of just a block number.
*
*
* There's also the data used for the equivalent fiat value.
*
*
* Created by nelson on 12/18/16.
*/
class HistoricalOperationEntry {
var historicalTransfer: OperationHistory? = null
var timestamp: Long = 0
var equivalentValue: AssetAmount? = null
override fun toString(): String {
if (historicalTransfer != null) {
// Since for now we know that all stored historical operations are 'transfers'
val op = historicalTransfer!!.operation as TransferOperation
var memo = "?"
if (op.memo != null && op.memo.plaintextMessage != null) {
memo = op.memo.plaintextMessage
}
return String.format(
"<%d, %s -> %s, %d of %s, memo: %s>",
timestamp,
op.from.objectId,
op.to.objectId,
op.assetAmount.amount.toLong(),
op.assetAmount.asset.objectId,
memo
)
} else {
return "<>"
}
}
override fun hashCode(): Int {
return historicalTransfer!!.objectId.hashCode()
}
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other == null) {
return false
}
return if (javaClass != other.javaClass) {
false
} else {
val otherEntry = other as HistoricalOperationEntry?
otherEntry!!.historicalTransfer!!.objectId == this.historicalTransfer!!.objectId
}
}
}

View file

@ -0,0 +1,37 @@
package cy.agorise.bitsybitshareswallet.models
import java.util.*
class Merchant {
var id: String? = null
var address: String? = null
var phone: String? = null
var name: String? = null
var lat: Float = 0F
var lon: Float = 0F
var city: String? = null
var country: String? = null
var createdAt: Date? = null
var updatedAt: Date? = null
var __v: Int = 0
constructor() {}
constructor(id: String) {
this.id = id
}
constructor(_id: String, address: String, phone: String, name: String, lat: Float, lon: Float, city: String, country: String, createdAt: Date, updatedAt: Date, __v: Int) {
this.id = _id
this.address = address
this.phone = phone
this.name = name
this.lat = lat
this.lon = lon
this.city = city
this.country = country
this.createdAt = createdAt
this.updatedAt = updatedAt
this.__v = __v
}
}

View file

@ -0,0 +1,21 @@
package cy.agorise.bitsybitshareswallet.network
import cy.agorise.bitsybitshareswallet.models.Ambassador
import cy.agorise.bitsybitshareswallet.models.AmbassadorLocation
import cy.agorise.bitsybitshareswallet.models.Merchant
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
interface AmbassadorService {
//https://ambpay.palmpay.io/api/v1/merchants?%24sort%5Baccount%5D=1&%24limit=50&%24skip=0
@get:GET("/api/v1/merchants?%24sort%5Baccount%5D=1&%24limit=50&%24skip=0")
val allMerchants: Call<FeathersResponse<Merchant>>
@GET("/api/v1/ambassadors")
fun getAmbassadors(@Query("cityId") cityId: String): Call<FeathersResponse<Ambassador>>
@GET("/api/v1/cities")
fun getAllCitiesSync(@Query("\$skip") skip: Long): Call<FeathersResponse<AmbassadorLocation>>
}

View file

@ -0,0 +1,19 @@
package cy.agorise.bitsybitshareswallet.network
class FeathersResponse<T>(private val error: Throwable?) {
var total: Long = 0
var limit: Long = 0
var skip: Long = 0
var data: List<T>? = null
val isSuccessful: Boolean
get() = error == null && data != null
fun message(): String {
return if (error != null) {
error.message!!
} else {
""
}
}
}

View file

@ -0,0 +1,330 @@
package cy.agorise.bitsybitshareswallet.processors
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.preference.PreferenceManager
import android.util.Log
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import cy.agorise.bitsybitshareswallet.models.HistoricalOperationEntry
import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository
import cy.agorise.bitsybitshareswallet.repositories.TransferRepository
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.CryptoUtils
import cy.agorise.graphenej.*
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.GetRelativeAccountHistory
import cy.agorise.graphenej.errors.ChecksumException
import cy.agorise.graphenej.models.JsonRpcResponse
import cy.agorise.graphenej.models.OperationHistory
import cy.agorise.graphenej.operations.TransferOperation
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.functions.Consumer
import io.reactivex.schedulers.Schedulers
import org.bitcoinj.core.DumpedPrivateKey
import org.bitcoinj.core.ECKey
import java.util.*
import javax.crypto.AEADBadTagException
/**
* This class is responsible for loading the local database with all past transfer operations of the
* currently selected account.
*
* The procedure used to load the database in 3 steps:
*
* 1- Load all transfer operations
* 2- Load all missing times
* 3- Load all missing equivalent times
*
* Since the 'get_relative_account_history' will not provide either timestamps nor equivalent values
* for every transfer, we must first load all historical transfer operations, and then proceed to
* handle those missing columns.
*/
class TransfersLoader(private var mContext: Context?): ServiceConnection {
private val TAG = this.javaClass.simpleName
/** Constant that specifies if we are on debug mode */
private val DEBUG = false
/* Constant used to fix the number of historical transfers to fetch from the network in one batch */
private val HISTORICAL_TRANSFER_BATCH_SIZE = 100
private val RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY = 0
private var mDisposables = CompositeDisposable()
/* Current user account */
private var mCurrentAccount: UserAccount? = null
/** Variable holding the current user's private key in the WIF format */
private var wifKey: String? = null
/** Repository to access and update Transfers */
private var transferRepository: TransferRepository? = null
/** Repository to access and update Authorities */
private var authorityRepository: AuthorityRepository? = null
/* Network service connection */
private var mNetworkService: NetworkService? = null
/* Counter used to keep track of the transfer history batch count */
private var historicalTransferCount = 0
// Used to keep track of the current state TODO this may not be needed
private var mState = State.IDLE
/**
* Flag used to keep track of the NetworkService binding state
*/
private var mShouldUnbindNetwork: Boolean = false
private var lastId: Long = 0
// Map used to keep track of request and response id pairs
private val responseMap = HashMap<Long, Int>()
/**
* Enum class used to keep track of the current state of the loader
*/
private enum class State {
IDLE,
LOADING_MISSING_TIMES,
LOADING_EQ_VALUES,
CANCELLED,
FINISHED
}
init {
transferRepository = TransferRepository(mContext!!)
authorityRepository = AuthorityRepository(mContext!!)
val pref = PreferenceManager.getDefaultSharedPreferences(mContext)
val userId = pref.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
if (userId != "") {
mCurrentAccount = UserAccount(userId)
mDisposables.add(authorityRepository!!.getWIF(userId!!, AuthorityType.MEMO.ordinal)
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { encryptedWIF ->
try {
wifKey = CryptoUtils.decrypt(mContext!!, encryptedWIF)
} catch (e: AEADBadTagException) {
Log.e(TAG, "AEADBadTagException. Class: " + e.javaClass + ", Msg: " + e.message)
}
}
)
mDisposables.add(RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(Consumer { message ->
if (mState == State.FINISHED) return@Consumer
if (message is JsonRpcResponse<*>) {
if (message.result is List<*>) {
if (responseMap.containsKey(message.id)) {
val responseType = responseMap[message.id]
when (responseType) {
RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY -> handleOperationList(message.result as List<OperationHistory>)
}
responseMap.remove(message.id)
}
}
} else if (message is ConnectionStatusUpdate) {
if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) {
// If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset
responseMap.clear()
}
}
})
)
} else {
// If there is no current user, we should not do anything
mState = State.CANCELLED
}
onStart()
}
override fun onServiceDisconnected(name: ComponentName?) {
mShouldUnbindNetwork = false
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as NetworkService.LocalBinder
mNetworkService = binder.service
// Start the transfers update
startTransfersUpdateProcedure()
}
private fun onStart() {
if (mState != State.CANCELLED) {
val intent = Intent(mContext, NetworkService::class.java)
if (mContext!!.bindService(intent, this, Context.BIND_AUTO_CREATE)) {
mShouldUnbindNetwork = true
} else {
Log.e(TAG, "Binding to the network service failed.")
}
}
}
/**
* Starts the procedure that will try to update the 'transfers' table
*/
private fun startTransfersUpdateProcedure() {
if (DEBUG) {
// If we are in debug mode, we first erase all entries in the 'transfer' table
transferRepository!!.deleteAll()
}
mDisposables.add(
transferRepository!!.getCount()
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { transferCount ->
if (transferCount > 0) {
// If we already have some transfers in the database, we might want to skip the request
// straight to the last batch
historicalTransferCount = Math.floor((transferCount /
HISTORICAL_TRANSFER_BATCH_SIZE).toDouble()).toInt()
}
// Retrieving account transactions
loadNextOperationsBatch()
}
)
}
/**
* Handles a freshly obtained list of OperationHistory instances. This is how the full node
* answers our 'get_relative_account_history' API call.
*
* This response however, has to be processed before being stored in the local database.
*
* @param operationHistoryList List of OperationHistory instances
*/
private fun handleOperationList(operationHistoryList: List<OperationHistory>) {
historicalTransferCount++
val insertedCount = transferRepository!!.insertAll(processOperationList(operationHistoryList))
// TODO return number of inserted rows
// Log.d(TAG, String.format("Inserted count: %d, list size: %d", insertedCount, operationHistoryList.size))
if (/* insertedCount == 0 && */ operationHistoryList.isEmpty()) {
onDestroy()
} else {
// If we inserted more than one operation, we cannot yet be sure we've reached the
// end of the operation list, so we issue another call to the 'get_relative_account_history'
// API call
loadNextOperationsBatch()
}
}
/**
* Method used to issue a new 'get_relative_account_history' API call. This is expected to retrieve
* at most HISTORICAL_TRANSFER_BATCH_SIZE operations.
*/
private fun loadNextOperationsBatch() {
val stop = historicalTransferCount * HISTORICAL_TRANSFER_BATCH_SIZE
val start = stop + HISTORICAL_TRANSFER_BATCH_SIZE
lastId = mNetworkService!!.sendMessage(
GetRelativeAccountHistory(
mCurrentAccount,
stop,
HISTORICAL_TRANSFER_BATCH_SIZE,
start
), GetRelativeAccountHistory.REQUIRED_API
)
responseMap[lastId] = RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY
}
/**
* Method that will transform a list of OperationHistory instances to a list of
* HistoricalOperationEntry.
*
* The HistoricalOperationEntry class is basically a wrapper around the OperationHistory class
* provided by the Graphenej library. It is used to better reflect what we store in the internal
* database for every transfer and expands the OperationHistory class basically adding
* two things:
*
* 1- A timestamp
* 2- An AssetAmount instance to represent the equivalent value in a fiat value
*
* @param operations List of OperationHistory instances
* @return List of HistoricalOperationEntry instances
*/
private fun processOperationList(operations: List<OperationHistory>): List<Transfer> {
val transfers = ArrayList<Transfer>()
if (wifKey == null) {
// In case of key storage corruption, we give up on processing this list of operations
return transfers
}
val memoKey = DumpedPrivateKey.fromBase58(null, wifKey!!).key
val publicKey = PublicKey(ECKey.fromPublicOnly(memoKey.pubKey))
val myAddress = Address(publicKey.key)
for (historicalOp in operations) {
if (historicalOp.operation == null || historicalOp.operation !is TransferOperation) {
// Some historical operations might not be transfer operations.
// As of right now non-transfer operations get deserialized as null
continue
}
val entry = HistoricalOperationEntry()
val op = historicalOp.operation as TransferOperation
val memo = op.memo
if (memo.byteMessage != null) {
val destinationAddress = memo.destination
try {
if (destinationAddress.toString() == myAddress.toString()) {
val decryptedMessage = Memo.decryptMessage(memoKey, memo.source, memo.nonce, memo.byteMessage)
memo.plaintextMessage = decryptedMessage
}
} catch (e: ChecksumException) {
Log.e(TAG, "ChecksumException. Msg: " + e.message)
} catch (e: NullPointerException) {
// This is expected in case the decryption fails, so no need to log this event.
Log.e(TAG, "NullPointerException. Msg: " + e.message)
} catch (e: Exception) {
Log.e(TAG, "Exception while decoding memo. Msg: " + e.message)
}
}
val transfer = Transfer(
historicalOp.objectId,
historicalOp.blockNum,
entry.timestamp,
op.fee.amount.toLong(),
op.fee.asset.objectId,
op.from.objectId,
op.to.objectId,
op.assetAmount.amount.toLong(),
op.assetAmount.asset.objectId,
memo.plaintextMessage
)
transfers.add(transfer)
}
return transfers
}
private fun onDestroy() {
Log.d(TAG, "Destroying TransfersLoader")
if (!mDisposables.isDisposed) mDisposables.dispose()
if (mShouldUnbindNetwork) {
mContext!!.unbindService(this)
mShouldUnbindNetwork = false
}
mContext = null
}
}

View file

@ -0,0 +1,35 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import android.os.AsyncTask
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.daos.AssetDao
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.entities.Asset
class AssetRepository internal constructor(context: Context) {
private val mAssetDao: AssetDao
init {
val db = BitsyDatabase.getDatabase(context)
mAssetDao = db!!.assetDao()
}
fun getAll(): LiveData<List<Asset>> {
return mAssetDao.getAll()
}
fun insertAll(assets: List<Asset>) {
insertAllAsyncTask(mAssetDao).execute(assets)
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: AssetDao) :
AsyncTask<List<Asset>, Void, Void>() {
override fun doInBackground(vararg assets: List<Asset>): Void? {
mAsyncTaskDao.insertAll(assets[0])
return null
}
}
}

View file

@ -0,0 +1,39 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import android.os.AsyncTask
import cy.agorise.bitsybitshareswallet.database.daos.AuthorityDao
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.entities.Authority
import io.reactivex.Single
class AuthorityRepository internal constructor(context: Context) {
private val mAuthorityDao: AuthorityDao
init {
val db = BitsyDatabase.getDatabase(context)
mAuthorityDao = db!!.authorityDao()
}
fun insert(authority: Authority) {
insertAsyncTask(mAuthorityDao).execute(authority)
}
fun get(userId: String): Single<Authority> {
return mAuthorityDao.get(userId)
}
fun getWIF(userId: String, authorityType: Int): Single<String> {
return mAuthorityDao.getWIF(userId, authorityType)
}
private class insertAsyncTask internal constructor(private val mAsyncTaskDao: AuthorityDao) :
AsyncTask<Authority, Void, Void>() {
override fun doInBackground(vararg authorities: Authority): Void? {
mAsyncTaskDao.insert(authorities[0])
return null
}
}
}

View file

@ -0,0 +1,21 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetailDao
class BalanceDetailRepository internal constructor(context: Context) {
private val mBalanceDetailDao: BalanceDetailDao
init {
val db = BitsyDatabase.getDatabase(context)
mBalanceDetailDao = db!!.balanceDetailDao()
}
fun getAll(): LiveData<List<BalanceDetail>> {
return mBalanceDetailDao.getAll()
}
}

View file

@ -0,0 +1,39 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import android.os.AsyncTask
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.daos.BalanceDao
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.entities.Balance
class BalanceRepository internal constructor(context: Context) {
private val mBalanceDao: BalanceDao
init {
val db = BitsyDatabase.getDatabase(context)
mBalanceDao = db!!.balanceDao()
}
fun insertAll(balances: List<Balance>) {
insertAllAsyncTask(mBalanceDao).execute(balances)
}
fun getAll(): LiveData<List<Balance>> {
return mBalanceDao.getAll()
}
fun getMissingAssetIds(): LiveData<List<String>> {
return mBalanceDao.getMissingAssetIds()
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: BalanceDao) :
AsyncTask<List<Balance>, Void, Void>() {
override fun doInBackground(vararg transfers: List<Balance>): Void? {
mAsyncTaskDao.insertAll(transfers[0])
return null
}
}
}

View file

@ -0,0 +1,22 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetailDao
class TransferDetailRepository internal constructor(context: Context) {
private val mTransferDetailDao: TransferDetailDao
init {
val db = BitsyDatabase.getDatabase(context)
mTransferDetailDao = db!!.transferDetailDao()
}
fun getAll(userId: String): LiveData<List<TransferDetail>> {
return mTransferDetailDao.getAll(userId)
}
}

View file

@ -0,0 +1,70 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import android.os.AsyncTask
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.daos.TransferDao
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import io.reactivex.Single
class TransferRepository internal constructor(context: Context) {
private val mTransferDao: TransferDao
init {
val db = BitsyDatabase.getDatabase(context)
mTransferDao = db!!.transferDao()
}
fun insertAll(transfers: List<Transfer>) {
insertAllAsyncTask(mTransferDao).execute(transfers)
}
fun setBlockTime(blockNumber: Long, timestamp: Long) {
setBlockTimeAsyncTask(mTransferDao).execute(Pair(blockNumber, timestamp))
}
fun getAll(): LiveData<List<Transfer>> {
return mTransferDao.getAll()
}
fun getCount(): Single<Int> {
return mTransferDao.getCount()
}
fun getTransferBlockNumberWithMissingTime(): LiveData<Long> {
return mTransferDao.getTransferBlockNumberWithMissingTime()
}
fun deleteAll() {
deleteAllAsyncTask(mTransferDao).execute()
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) :
AsyncTask<List<Transfer>, Void, Void>() {
override fun doInBackground(vararg transfers: List<Transfer>): Void? {
mAsyncTaskDao.insertAll(transfers[0])
return null
}
}
private class setBlockTimeAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) :
AsyncTask<Pair<Long, Long>, Void, Void>() {
override fun doInBackground(vararg pair: Pair<Long, Long>): Void? {
mAsyncTaskDao.setBlockTime(pair[0].first, pair[0].second)
return null
}
}
private class deleteAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) :
AsyncTask<Void, Void, Void>() {
override fun doInBackground(vararg params: Void?): Void? {
mAsyncTaskDao.deleteAll()
return null
}
}
}

View file

@ -0,0 +1,52 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import android.os.AsyncTask
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.daos.UserAccountDao
import cy.agorise.bitsybitshareswallet.database.entities.UserAccount
class UserAccountRepository internal constructor(context: Context) {
private val mUserAccountDao: UserAccountDao
init {
val db = BitsyDatabase.getDatabase(context)
mUserAccountDao = db!!.userAccountDao()
}
fun insert(userAccount: UserAccount) {
insertAsyncTask(mUserAccountDao).execute(userAccount)
}
fun insertAll(userAccounts: List<UserAccount>) {
insertAllAsyncTask(mUserAccountDao).execute(userAccounts)
}
fun getUserAccount(id: String): LiveData<UserAccount> {
return mUserAccountDao.getUserAccount(id)
}
fun getMissingUserAccountIds(): LiveData<List<String>> {
return mUserAccountDao.getMissingAccountIds()
}
private class insertAsyncTask internal constructor(private val mAsyncTaskDao: UserAccountDao) :
AsyncTask<UserAccount, Void, Void>() {
override fun doInBackground(vararg userAccounts: UserAccount): Void? {
mAsyncTaskDao.insert(userAccounts[0])
return null
}
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: UserAccountDao) :
AsyncTask<List<UserAccount>, Void, Void>() {
override fun doInBackground(vararg userAccounts: List<UserAccount>): Void? {
mAsyncTaskDao.insertAll(userAccounts[0])
return null
}
}
}

View file

@ -0,0 +1,52 @@
package cy.agorise.bitsybitshareswallet.utils
import android.app.Application
import cy.agorise.graphenej.api.ApiAccess
import cy.agorise.graphenej.api.android.NetworkServiceManager
class BitsyApplication : Application() {
private val BITSHARES_NODE_URLS = arrayOf(
// PP private nodes
"wss://nl.palmpay.io/ws",
// Other public nodes
"wss://bitshares.nu/ws", // Stockholm, Sweden
"wss://bitshares.openledger.info/ws", // Openledger node
"wss://dallas.bitshares.apasia.tech/ws", // Dallas, USA
"wss://atlanta.bitshares.apasia.tech/ws", // Atlanta, USA
"wss://dex.rnglab.org", // Amsterdam, Netherlands
"wss://citadel.li/node"
)
override fun onCreate() {
super.onCreate()
// Specifying some important information regarding the connection, such as the
// credentials and the requested API accesses
val requestedApis = ApiAccess.API_DATABASE or ApiAccess.API_HISTORY or ApiAccess.API_NETWORK_BROADCAST
val networkManager = NetworkServiceManager.Builder()
.setUserName("")
.setPassword("")
.setRequestedApis(requestedApis)
.setCustomNodeUrls(setupNodes())
.setAutoConnect(true)
.setNodeLatencyVerification(true)
.build(this)
/*
* Registering this class as a listener to all activity's callback cycle events, in order to
* better estimate when the user has left the app and it is safe to disconnect the websocket connection
*/
registerActivityLifecycleCallbacks(networkManager)
}
private fun setupNodes(): String {
val stringBuilder = StringBuilder()
for (url in BITSHARES_NODE_URLS) {
stringBuilder.append(url).append(",")
}
stringBuilder.replace(stringBuilder.length - 1, stringBuilder.length, "")
return stringBuilder.toString()
}
}

View file

@ -0,0 +1,213 @@
package cy.agorise.bitsybitshareswallet.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.core.view.MotionEventCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
class BounceTouchListener(private val mRecyclerView: RecyclerView) : View.OnTouchListener {
private var downCalled = false
private var mDownY: Float = 0.toFloat()
private var mSwipingDown: Boolean = false
private var mSwipingUp: Boolean = false
private val mInterpolator = DecelerateInterpolator(3f)
private var mActivePointerId = -99
private var mLastTouchY = -99f
private var mMaxAbsTranslation = -99
init {
mRecyclerView.pivotY = 0F
}
override fun onTouch(view: View, motionEvent: MotionEvent): Boolean {
val action = MotionEventCompat.getActionMasked(motionEvent)
when (action) {
MotionEvent.ACTION_DOWN -> {
onDownMotionEvent(motionEvent)
view.onTouchEvent(motionEvent)
downCalled = true
if (this.mRecyclerView.translationY == 0f) {
return false
}
}
MotionEvent.ACTION_MOVE -> {
if (mActivePointerId == -99) {
onDownMotionEvent(motionEvent)
downCalled = true
}
val pointerIndex = MotionEventCompat.findPointerIndex(motionEvent, mActivePointerId)
val y = MotionEventCompat.getY(motionEvent, pointerIndex)
if (!hasHitTop() && !hasHitBottom() || !downCalled) {
if (!downCalled) {
downCalled = true
}
mDownY = y
view.onTouchEvent(motionEvent)
return false
}
val deltaY = y - mDownY
if (Math.abs(deltaY) > 0 && hasHitTop() && deltaY > 0) {
mSwipingDown = true
sendCancelEventToView(view, motionEvent)
}
if (Math.abs(deltaY) > 0 && hasHitBottom() && deltaY < 0) {
mSwipingUp = true
sendCancelEventToView(view, motionEvent)
}
if (mSwipingDown || mSwipingUp) {
if (deltaY <= 0 && mSwipingDown || deltaY >= 0 && mSwipingUp) {
mDownY = 0f
mSwipingDown = false
mSwipingUp = false
downCalled = false
val downEvent = MotionEvent.obtain(motionEvent)
downEvent.action = MotionEvent.ACTION_DOWN or
(MotionEventCompat.getActionIndex(motionEvent) shl MotionEventCompat.ACTION_POINTER_INDEX_SHIFT)
view.onTouchEvent(downEvent)
}
var translation = (deltaY / Math.abs(deltaY) * Math.pow(Math.abs(deltaY).toDouble(), .8)).toInt()
if (mMaxAbsTranslation > 0) {
if (translation < 0) {
translation = Math.max(-mMaxAbsTranslation, translation)
} else {
translation = Math.min(mMaxAbsTranslation, translation)
}
}
mRecyclerView.translationY = translation.toFloat()
translate(mRecyclerView.translationY)
return true
}
}
MotionEvent.ACTION_UP -> {
mActivePointerId = -99
// cancel
mRecyclerView.animate()
.setInterpolator(mInterpolator)
.translationY(0f)
.setDuration(600L)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
(animation as ValueAnimator).addUpdateListener {
translate(mRecyclerView.translationY)
}
super.onAnimationStart(animation)
}
})
mDownY = 0f
mSwipingDown = false
mSwipingUp = false
downCalled = false
}
MotionEvent.ACTION_CANCEL -> {
mActivePointerId = -99
}
MotionEvent.ACTION_POINTER_UP -> {
val pointerIndex = MotionEventCompat.getActionIndex(motionEvent)
val pointerId = MotionEventCompat.getPointerId(motionEvent, pointerIndex)
if (pointerId == mActivePointerId) {
val newPointerIndex = if (pointerIndex == 0) 1 else 0
mLastTouchY = MotionEventCompat.getY(motionEvent, newPointerIndex)
mActivePointerId = MotionEventCompat.getPointerId(motionEvent, newPointerIndex)
if (this.mRecyclerView.translationY > 0) {
mDownY = mLastTouchY - Math.pow(this.mRecyclerView.translationY.toDouble(), (10f / 8f).toDouble()).toInt()
this.mRecyclerView.animate().cancel()
} else if (this.mRecyclerView.translationY < 0) {
mDownY = mLastTouchY +
Math.pow((-this.mRecyclerView.translationY).toDouble(), (10f / 8f).toDouble()).toInt()
this.mRecyclerView.animate().cancel()
}
}
}
}
return false
}
private fun translate(translation: Float) {
val scale = 2 * translation / mRecyclerView.measuredHeight + 1
mRecyclerView.scaleY = Math.pow(scale.toDouble(), .6).toFloat()
}
private fun sendCancelEventToView(view: View, motionEvent: MotionEvent) {
(view as ViewGroup).requestDisallowInterceptTouchEvent(true)
val cancelEvent = MotionEvent.obtain(motionEvent)
cancelEvent.action = MotionEvent.ACTION_CANCEL or
(MotionEventCompat.getActionIndex(motionEvent) shl MotionEventCompat.ACTION_POINTER_INDEX_SHIFT)
view.onTouchEvent(cancelEvent)
}
private fun onDownMotionEvent(motionEvent: MotionEvent) {
val pointerIndex = MotionEventCompat.getActionIndex(motionEvent)
mLastTouchY = MotionEventCompat.getY(motionEvent, pointerIndex)
mActivePointerId = MotionEventCompat.getPointerId(motionEvent, 0)
if (this.mRecyclerView.translationY > 0) {
mDownY = mLastTouchY - Math.pow(this.mRecyclerView.translationY.toDouble(), (10f / 8f).toDouble()).toInt()
this.mRecyclerView.animate().cancel()
} else if (this.mRecyclerView.translationY < 0) {
mDownY = mLastTouchY + Math.pow((-this.mRecyclerView.translationY).toDouble(), (10f / 8f).toDouble()).toInt()
this.mRecyclerView.animate().cancel()
} else {
mDownY = mLastTouchY
}
}
private fun hasHitBottom(): Boolean {
val recyclerView = this.mRecyclerView
if (recyclerView.adapter != null && recyclerView.layoutManager != null) {
val adapter = recyclerView.adapter
if (adapter!!.itemCount > 0) {
val layoutManager = recyclerView.layoutManager
if (layoutManager is LinearLayoutManager) {
val linearLayoutManager = layoutManager as LinearLayoutManager?
return linearLayoutManager!!.findLastCompletelyVisibleItemPosition() == adapter.itemCount - 1
} else if (layoutManager is StaggeredGridLayoutManager) {
val staggeredGridLayoutManager = layoutManager as StaggeredGridLayoutManager?
val checks = staggeredGridLayoutManager!!.findLastCompletelyVisibleItemPositions(null)
for (check in checks) {
if (check == adapter.itemCount - 1)
return true
}
}
}
}
return false
}
private fun hasHitTop(): Boolean {
val recyclerView = this.mRecyclerView
if (recyclerView.adapter != null && recyclerView.layoutManager != null) {
val adapter = recyclerView.adapter
if (adapter!!.itemCount > 0) {
val layoutManager = recyclerView.layoutManager
if (layoutManager is LinearLayoutManager) {
val linearLayoutManager = layoutManager as LinearLayoutManager?
return linearLayoutManager!!.findFirstCompletelyVisibleItemPosition() == 0
} else if (layoutManager is StaggeredGridLayoutManager) {
val staggeredGridLayoutManager = layoutManager as StaggeredGridLayoutManager?
val checks = staggeredGridLayoutManager!!.findFirstCompletelyVisibleItemPositions(null)
for (check in checks) {
if (check == 0)
return true
}
}
}
}
return false
}
}

View file

@ -1,10 +1,51 @@
package cy.agorise.bitsybitshareswallet.utils
object Constants {
/** Key used to store the number of the last agreed License version */
const val KEY_LAST_AGREED_LICENSE_VERSION = "key_last_agreed_license_version"
/** Version of the currently used license */
const val CURRENT_LICENSE_VERSION = 1
/** Key used to store if the initial setup is already done or not */
const val KEY_INITIAL_SETUP_DONE = "key_initial_setup_done"
/** Key used to store the id value of the currently active account in the shared preferences */
const val KEY_CURRENT_ACCOUNT_ID = "key_current_account_id"
/** The minimum required length for a PIN number */
const val MIN_PIN_LENGTH = 6
/** The user selected encrypted PIN */
const val KEY_ENCRYPTED_PIN = "key_encrypted_pin"
/**
* Key used to store the night mode setting into the shared preferences
* LTM accounts come with an expiration date expressed as this string.
* This is used to recognize such accounts from regular ones.
*/
val KEY_NIGHT_MODE_ACTIVATED = "key_night_mode_activated"
const val LIFETIME_EXPIRATION_DATE = "1969-12-31T23:59:59"
/**
* Time period between two consecutive requests to the full node performed whenever we have
* open payment requests as a matter of redundancy.
*/
const val MISSING_PAYMENT_CHECK_PERIOD: Long = 5000
/** Time period to wait to send a request to the NetworkService, and retry in case it is still not connected */
const val NETWORK_SERVICE_RETRY_PERIOD: Long = 5000
/** Bitshares block period */
const val BLOCK_PERIOD: Long = 3000
/** Key used to store the number of operations that the currently selected account had last time we checked */
const val KEY_ACCOUNT_OPERATION_COUNT = "key_account_operation_count"
/** Key used to store the auto close app if no user activity setting into the shared preferences */
const val KEY_AUTO_CLOSE_ACTIVATED = "key_auto_close_activated"
/** Key used to store the night mode setting into the shared preferences */
const val KEY_NIGHT_MODE_ACTIVATED = "key_night_mode_activated"
const val MERCHANTS_WEBSERVICE_URL = "https://websvc.palmpay.io/"
}

View file

@ -0,0 +1,72 @@
package cy.agorise.bitsybitshareswallet.utils
import android.content.Context
import android.preference.PreferenceManager
import com.moldedbits.r2d2.R2d2
import javax.crypto.AEADBadTagException
/**
* Class that provides encryption/decryption support by using the key management framework provided
* by the KeyStore system.
*
* The implemented scheme was taken from [this](https://medium.com/@ericfu/securely-storing-secrets-in-an-android-application-501f030ae5a3)> blog post.
*
* @see [Android Keystore System](https://developer.android.com/training/articles/keystore.html)
*/
object CryptoUtils {
/**
* Encrypts and stores a key-value pair in the shared preferences
* @param context The application context
* @param key The key to be used to reference the data
* @param value The actual value to be stored
*/
fun put(context: Context, key: String, value: String) {
val r2d2 = R2d2(context)
val encrypted = r2d2.encryptData(value)
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putString(key, encrypted)
.apply()
}
/**
* Retrieves and decrypts an encrypted value from the shared preferences
* @param context The application context
* @param key The key used to reference the data
* @return The plaintext version of the encrypted data
*/
operator fun get(context: Context, key: String): String {
val r2d2 = R2d2(context)
val encrypted = PreferenceManager.getDefaultSharedPreferences(context).getString(key, null)
return r2d2.decryptData(encrypted)
}
/**
* Encrypts some data
* @param context The application context
* @param plaintext The plaintext version of the data
* @return Encrypted data
*/
fun encrypt(context: Context, plaintext: String): String {
val r2d2 = R2d2(context)
return r2d2.encryptData(plaintext)
}
/**
* Decrypts some data
* @param context The application context
* @param ciphertext The ciphertext version of the data
* @return Decrypted data
*/
@Throws(AEADBadTagException::class)
fun decrypt(context: Context, ciphertext: String): String {
val r2d2 = R2d2(context)
return r2d2.decryptData(ciphertext)
}
}

View file

@ -0,0 +1,60 @@
package cy.agorise.bitsybitshareswallet.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.util.Log
import android.view.View
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
/**
* Contains methods that are helpful in different parts of the app
*/
class Helper {
companion object {
private val TAG = "Helper"
/**
* Creates and returns a Bitmap from the contents of a View, does not matter
* if it is a simple view or a ViewGroup like a ConstraintLayout or a LinearLayout.
*
* @param view The view that is gonna be pictured.
* @return The generated image from the given view.
*/
fun loadBitmapFromView(view: View): Bitmap {
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
view.draw(canvas)
return bitmap
}
fun saveTemporalBitmap(context: Context, bitmap: Bitmap): Uri {
// save bitmap to cache directory
try {
val cachePath = File(context.cacheDir, "images")
if (!cachePath.mkdirs())
// don't forget to make the directory
Log.d(TAG, "shareBitmapImage creating cache images folder")
val stream = FileOutputStream(cachePath.toString() + "/image.png") // overwrites this image every time
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
} catch (e: IOException) {
Log.d(TAG, "shareBitmapImage error: " + e.message)
}
// Send intent to share image+text
val imagePath = File(context.cacheDir, "images")
val newFile = File(imagePath, "image.png")
// Create and return image uri
return FileProvider.getUriForFile(context, "cy.agorise.bitsybitshareswallet.FileProvider", newFile)
}
}
}

View file

@ -1,30 +0,0 @@
package cy.agorise.bitsybitshareswallet.utils
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.Nullable
import androidx.appcompat.widget.AppCompatImageView
/**
* Created by xd on 1/24/18.
* ImageView which adjusts its size to always create a square
*/
class SquaredImageView : AppCompatImageView {
constructor(context: Context) : super(context)
constructor(context: Context, @Nullable attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, @Nullable attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val size = Math.min(measuredWidth, measuredHeight)
setMeasuredDimension(size, size)
}
}

View file

@ -0,0 +1,15 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.entities.Asset
import cy.agorise.bitsybitshareswallet.repositories.AssetRepository
class AssetViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = AssetRepository(application)
internal fun getAll(): LiveData<List<Asset>> {
return mRepository.getAll()
}
}

View file

@ -0,0 +1,16 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
import cy.agorise.bitsybitshareswallet.repositories.BalanceDetailRepository
class BalanceDetailViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = BalanceDetailRepository(application)
internal fun getAll(): LiveData<List<BalanceDetail>> {
return mRepository.getAll()
}
}

View file

@ -0,0 +1,19 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.entities.Balance
import cy.agorise.bitsybitshareswallet.repositories.BalanceRepository
class BalanceViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = BalanceRepository(application)
internal fun getMissingAssetIds(): LiveData<List<String>> {
return mRepository.getMissingAssetIds()
}
fun insertAll(balances: List<Balance>) {
mRepository.insertAll(balances)
}
}

View file

@ -1,7 +0,0 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import androidx.lifecycle.ViewModel
class BalancesViewModel : ViewModel() {
// TODO: Implement the ViewModel
}

View file

@ -1,7 +0,0 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import androidx.lifecycle.ViewModel;
class MerchantsViewModel : ViewModel() {
// TODO: Implement the ViewModel
}

View file

@ -1,7 +0,0 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import androidx.lifecycle.ViewModel;
class TransactionsViewModel : ViewModel() {
// TODO: Implement the ViewModel
}

View file

@ -0,0 +1,15 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.repositories.TransferDetailRepository
class TransferDetailViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = TransferDetailRepository(application)
internal fun getAll(userId: String): LiveData<List<TransferDetail>> {
return mRepository.getAll(userId)
}
}

View file

@ -0,0 +1,18 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.repositories.TransferRepository
class TransferViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = TransferRepository(application)
internal fun setBlockTime(blockNumber: Long, timestamp: Long) {
mRepository.setBlockTime(blockNumber, timestamp)
}
internal fun getTransferBlockNumberWithMissingTime(): LiveData<Long> {
return mRepository.getTransferBlockNumberWithMissingTime()
}
}

View file

@ -0,0 +1,27 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.entities.UserAccount
import cy.agorise.bitsybitshareswallet.repositories.UserAccountRepository
class UserAccountViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = UserAccountRepository(application)
internal fun getUserAccount(id: String): LiveData<UserAccount> {
return mRepository.getUserAccount(id)
}
internal fun getMissingUserAccountIds(): LiveData<List<String>> {
return mRepository.getMissingUserAccountIds()
}
// fun insert(userAccount: UserAccount) {
// mRepository.insert(userAccount)
// }
fun insertAll(userAccounts: List<UserAccount>) {
mRepository.insertAll(userAccounts)
}
}

View file

@ -0,0 +1,45 @@
package cy.agorise.bitsybitshareswallet.views
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.textfield.TextInputLayout
import android.view.inputmethod.InputConnection
import android.view.inputmethod.EditorInfo
import androidx.appcompat.widget.AppCompatAutoCompleteTextView
/**
* Custom AutoCompleteTextView to be used inside a TextInputLayout, so that they can share their hint
* From https://stackoverflow.com/a/41864063/5428997
*/
class MyTextInputAutoCompleteTextView : AppCompatAutoCompleteTextView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(outAttrs)
if (ic != null && outAttrs.hintText == null) {
// If we don't have a hint and our parent is a TextInputLayout, use it's hint for the
// EditorInfo. This allows us to display a hint in 'extract mode'.
val parent = parent
if (parent is TextInputLayout) {
outAttrs.hintText = parent.hint
}
}
// An EditText that lets you use actions ("Done", "Go", etc.) on multi-line edits.
val imeActions = outAttrs.imeOptions and EditorInfo.IME_MASK_ACTION
if (imeActions and EditorInfo.IME_ACTION_DONE != 0) {
// clear the existing action
outAttrs.imeOptions = outAttrs.imeOptions xor imeActions
// set the DONE action
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_ACTION_DONE
}
if (outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION != 0) {
outAttrs.imeOptions = outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION.inv()
}
return ic
}
}

View file

@ -0,0 +1,43 @@
package cy.agorise.bitsybitshareswallet.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import com.google.android.material.textfield.TextInputEditText;
// An EditText that lets you use actions ("Done", "Go", etc.) on multi-line edits.
public class MyTextInputEditText extends TextInputEditText
{
public MyTextInputEditText(Context context)
{
super(context);
}
public MyTextInputEditText(Context context, AttributeSet attrs)
{
super(context, attrs);
}
public MyTextInputEditText(Context context, AttributeSet attrs, int defStyle)
{
super(context, attrs, defStyle);
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
InputConnection connection = super.onCreateInputConnection(outAttrs);
int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION;
if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) {
// clear the existing action
outAttrs.imeOptions ^= imeActions;
// set the DONE action
outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE;
}
if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) {
outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
return connection;
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="700">
<translate
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:fromYDelta="50%p"
android:toYDelta="0"/>
<alpha
android:fromAlpha="0"
android:toAlpha="1"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"/>
</set>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation
xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/item_animation_from_bottom"
android:delay="15%"
android:animationOrder="normal"/>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="-100%" android:toXDelta="0%"
android:fromYDelta="0%" android:toYDelta="0%"
android:duration="700"/>
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="100%" android:toXDelta="0%"
android:fromYDelta="0%" android:toYDelta="0%"
android:duration="700"/>
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0%" android:toXDelta="-100%"
android:fromYDelta="0%" android:toYDelta="0%"
android:duration="700"/>
</set>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0%" android:toXDelta="100%"
android:fromYDelta="0%" android:toYDelta="0%"
android:duration="700"/>
</set>

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Pressed state -->
<item
android:state_enabled="true"
android:state_pressed="true">
<set>
<objectAnimator
android:duration="100"
android:propertyName="translationZ"
android:valueTo="2dp"
android:valueType="floatType" />
<objectAnimator
android:duration="0"
android:propertyName="elevation"
android:valueTo="6dp"
android:valueType="floatType" />
</set>
</item>
<!-- Hover state. This is triggered via mouse. -->
<item
android:state_enabled="true"
android:state_hovered="true">
<set>
<objectAnimator
android:duration="100"
android:propertyName="translationZ"
android:valueTo="2dp"
android:valueType="floatType" />
<objectAnimator
android:duration="0"
android:propertyName="elevation"
android:valueTo="6dp"
android:valueType="floatType" />
</set>
</item>
<!-- Focused state. This is triggered via keyboard. -->
<item
android:state_enabled="true"
android:state_focused="true">
<set>
<objectAnimator
android:duration="100"
android:propertyName="translationZ"
android:valueTo="2dp"
android:valueType="floatType" />
<objectAnimator
android:duration="0"
android:propertyName="elevation"
android:valueTo="6dp"
android:valueType="floatType" />
</set>
</item>
<!-- Base state (enabled, not pressed) -->
<item android:state_enabled="true">
<set>
<objectAnimator
android:duration="100"
android:propertyName="translationZ"
android:startDelay="100"
android:valueTo="0dp"
android:valueType="floatType"
tools:ignore="UnusedAttribute" />
<objectAnimator
android:duration="0"
android:propertyName="elevation"
android:valueTo="6dp"
android:valueType="floatType" />
</set>
</item>
<!-- Disabled state -->
<item>
<set>
<objectAnimator
android:duration="0"
android:propertyName="translationZ"
android:valueTo="0dp"
android:valueType="floatType" />
<objectAnimator
android:duration="0"
android:propertyName="elevation"
android:valueTo="0dp"
android:valueType="floatType" />
</set>
</item>
</selector>

View file

@ -1,5 +0,0 @@
<vector android:height="255dp" android:viewportHeight="255" android:viewportWidth="255" android:width="255dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FEFEFE" android:pathData="M128 0c70 0 127 57 127 127 0 71-57 128-127 128C57 255 0 198 0 127 0 57 57 0 128 0z"/>
<path android:fillColor="#2A8336" android:pathData="M178 73c-15-16-36-22-57-21l12 10c31 3 56 29 56 61 0 34-27 61-61 61s-62-27-62-61c0-13 4-25 11-35V73c-28 28-28 73 0 101l51 51 50-51c28-28 28-73 0-101z"/>
<path android:fillColor="#009FE3" android:pathData="M99 155c7 6 15 10 25 11v-27c-2 0-4-1-6-2l-19 18zm31-16v27c9-1 18-5 24-11l-19-18c-1 1-3 2-5 2zm-18-12c0 2 1 4 2 6l-19 19c-6-7-9-16-10-25h27zm57 0c-1 9-5 17-10 24l-19-19c1-1 1-3 2-5h27zm-57-6H85V86l29 29c-1 2-2 4-2 6zm56 0h-26c-1-2-1-4-3-6l19-19c6 7 10 16 10 25zm-14-29c-6-5-15-9-24-10v27c2 0 4 1 5 2l19-19zM85 77c7 8 30 30 34 34 1-1 3-2 5-2V65L85 30v47z"/>
</vector>

View file

@ -1,19 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="255dp"
android:height="255dp"
android:viewportWidth="255"
android:viewportHeight="255" >
<path
android:fillColor="#BBFEFEFE"
android:pathData="M128 0c70 0 127 57 127 127 0 71-57 128-127 128C57 255 0 198 0 127 0 57 57 0 128 0z"/>
<path
android:fillColor="#2A8336"
android:pathData="M178 73c-15-16-36-22-57-21l12 10c31 3 56 29 56 61 0 34-27 61-61 61s-62-27-62-61c0-13 4-25 11-35V73c-28 28-28 73 0 101l51 51 50-51c28-28 28-73 0-101z"/>
<path
android:fillColor="#009FE3"
android:pathData="M99 155c7 6 15 10 25 11v-27c-2 0-4-1-6-2l-19 18zm31-16v27c9-1 18-5 24-11l-19-18c-1 1-3 2-5 2zm-18-12c0 2 1 4 2 6l-19 19c-6-7-9-16-10-25h27zm57 0c-1 9-5 17-10 24l-19-19c1-1 1-3 2-5h27zm-57-6H85V86l29 29c-1 2-2 4-2 6zm56 0h-26c-1-2-1-4-3-6l19-19c6 7 10 16 10 25zm-14-29c-6-5-15-9-24-10v27c2 0 4 1 5 2l19-19zM85 77c7 8 30 30 34 34 1-1 3-2 5-2V65L85 30v47z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >
<corners android:radius="10dp" />
<solid android:color="#111" />
</shape>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorReceive"
android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorSend"
android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M21,18v1c0,1.1 -0.9,2 -2,2L5,21c-1.11,0 -2,-0.9 -2,-2L3,5c0,-1.1 0.89,-2 2,-2h14c1.1,0 2,0.9 2,2v1h-9c-1.11,0 -2,0.9 -2,2v8c0,1.1 0.89,2 2,2h9zM12,16h10L22,8L12,8v8zM16,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:viewportHeight="499563" android:viewportWidth="500000" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#009FE3" android:pathData="M250000 0c138022 0 250000 111545 250000 249564 0 138022-111981 249999-250000 249999C111978 499563 0 387583 0 249564 0 111543 111981 0 250000 0z"/>
<path android:fillColor="#FEFEFE" android:pathData="M348958 142794c-30382-30381-71614-43836-111545-40365l22135 19532c61632 4775 110243 56423 110243 119791 0 65973-53384 119357-119791 119357-65972 0-120227-53384-120227-119357 0-25606 7812-49478 21701-69010v-29948h-434c-55123 55122-55123 143665-436 198351l99394 98958 98958-99393c54687-54687 55121-143229 0-197916h2z"/>
<path android:fillColor="#FEFEFE" android:pathData="M194446 304254c13022 11717 29514 19097 48177 20398v-52517c-3906-867-7812-2604-10851-4775l-37326 36891v3zm59462-32119v52517c18663-1301 35590-8681 48177-20398l-36891-36892c-3472 2169-7380 3906-11286 4775v-2zm-34723-23872c435 4340 2170 7811 4341 11285l-36892 37326c-11717-13454-19097-29947-19964-48611h52518-3zm111111 0c-1302 18230-8247 34723-19532 47742l-37326-37327c2168-3038 3471-6510 4340-10415h52518zm-111111-11283h-52517v-68141l57290 56858c-2603 3471-4340 7379-4775 11285l2-2zm110676 0h-52083c-867-3906-2604-7812-4775-11286l36892-36891c11717 13021 19097 29946 19964 48177h2zm-27776-55991c-12587-11286-29514-19098-48177-19964v52517c3906 867 7812 2170 11286 4340l36891-36891v-2zm-135417-29514c13888 14325 58161 58593 65536 65973 3472-1736 6511-3471 10417-3906v-86371l-76388-67709s435 89845 435 92013z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="29dp"
android:height="40dp"
android:viewportWidth="294778"
android:viewportHeight="400026">
<path
android:pathData="M251503,87576c-31929,-31929 -75261,-46069 -117225,-42422l23262,20527c64770,5019 115859,59297 115859,125894 0,69331 -56104,125435 -125893,125435l0,0 0,0c-69333,0 -126350,-56104 -126350,-125435 0,-26911 8209,-52000 22806,-72526l0,-31473 -457,0c-57930,57929 -57930,150981 -457,208453l104454,103998 103999,-104455c57472,-57472 57929,-150524 0,-207996l0,0 2,0z"
android:fillColor="#FEFEFE"/>
<path
android:pathData="M89122,257258c13686,12315 31018,20069 50631,21438l0,-55193c-4105,-911 -8210,-2736 -11403,-5019l-39228,38771 0,3zM151613,223503l0,55193c19612,-1369 37402,-9123 50631,-21438l-38771,-38770c-3647,2279 -7756,4104 -11860,5019l0,-4zM115121,198415c458,4562 2280,8210 4562,11860l-38771,39228c-12314,-14140 -20069,-31472 -20980,-51088l55192,0 -3,0zM231891,198415c-1368,19159 -8667,36491 -20527,50174l-39227,-39228c2280,-3193 3648,-6840 4562,-10946l55192,0zM115121,186558l-55191,0c0,-20069 0,-41053 0,-71611l60208,59753c-2737,3649 -4562,7756 -5019,11860l2,-2zM231434,186558l-54735,0c-912,-4104 -2737,-8209 -5019,-11860l38771,-38771c12314,13685 20070,31472 20980,50631l3,0zM202244,127715c-13229,-11860 -31019,-20069 -50631,-20981l0,55193c4104,911 8209,2280 11860,4562l38771,-38771 0,-3zM59930,96700c14595,15053 61122,61576 68874,69332 3648,-1825 6842,-3648 10946,-4105 0,-15508 0,-90770 0,-90770l-80278,-71157c0,0 458,94420 458,96700z"
android:fillColor="#FEFEFE"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View file

@ -5,6 +5,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorPrimary"
android:fillColor="@color/ppGreen"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM7,9c0,-2.76 2.24,-5 5,-5s5,2.24 5,5c0,2.88 -2.88,7.19 -5,9.88C9.92,16.21 7,11.85 7,9z"/>
<path
android:fillColor="#FFF"
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M11,2v20c-5.07,-0.5 -9,-4.79 -9,-10s3.93,-9.5 9,-10zM13.03,2v8.99L22,10.99c-0.47,-4.74 -4.24,-8.52 -8.97,-8.99zM13.03,13.01L13.03,22c4.74,-0.47 8.5,-4.25 8.97,-8.99h-8.97z"/>
</vector>

View file

@ -0,0 +1,23 @@
<vector android:height="51dp"
android:viewportHeight="225.302"
android:viewportWidth="154.618"
android:width="31dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#0E9257" android:pathData="M151.705 76.396C151.705 35.31 118.397 2 77.31 2 36.219 2 2.913 35.31 2.913 76.396c0 20.073 7.961 38.277 20.889 51.66l-0.09 0.969s43.451 42.428 51.781 93.092h4.121c8.332-50.664 51.537-93.092 51.537-93.092l-0.352-0.457c12.936-13.388 20.906-32.087 20.906-52.172z" android:strokeColor="#FFFFFF" android:strokeWidth="5"/>
<path android:fillColor="#FFFFFF" android:pathData="M14.478 77.906c0-34.705 28.131-62.835 62.832-62.835 34.706 0 62.836 28.13 62.836 62.835 0 34.7-28.13 62.837-62.836 62.837-34.702 0-62.832-28.137-62.832-62.837z" android:strokeColor="#A5D5A7" android:strokeWidth="2.5162"/>
<path android:fillColor="#0E9257" android:fillType="evenOdd" android:pathData="M37.714 47.167c-0.072-2.064 0.691-3.015 2.888-3.608 5.333 0 10.67 0.003 16.006 0.002 9.134-0.003 18.271-0.014 27.403-0.014 7.197 0 14.396 0.009 21.592 0.012 2.804 0.002 5.603-0.004 8.404-0.008 1.083-0.146 1.854 0.426 2.502 1.176 0.646 0.742 0.76 1.646 0.593 2.599-0.518 1.547-1.835 2.489-3.451 2.45-0.554-0.011-1.112-0.013-1.669-0.013-23.728-0.002-47.457 0.002-71.185-0.002-1.291-0.142-2.334-0.67-2.938-1.886-0.108-0.221-0.243-0.443-0.145-0.708z"/>
<path android:fillColor="#0E9257" android:pathData="M118.797 82.431c-0.017 0.003-0.034 0.008-0.054 0.011-0.264 0.375-0.681 0.282-1.048 0.347l-1.943 0.369c-0.32 0.112-0.662-0.031-0.981 0.094-0.476-0.004-0.95-0.009-1.428-0.011-0.95 0.15-1.86-0.058-2.759-0.294v18.662H45.259V82.548c-1.265 0.451-2.589 0.714-3.988 0.751-0.155 0.005-0.305 0.034-0.451 0.077-1.104 0.032-2.187-0.046-3.236-0.265v21.727c3.944 4.054 8.478 7.523 13.471 10.257h54.286c4.983-2.732 9.518-6.2 13.457-10.251V82.431zm4.879-11.674c-0.106-0.602-0.237-1.201-0.354-1.8-0.612-2.371-1.12-4.767-1.619-7.161l-1.238-5.481c0.037-0.621-0.127-1.214-0.254-1.81-0.077-0.277-0.105-0.564-0.176-0.843-0.312-1.247-0.862-1.687-2.153-1.688-1.547-0.002-3.095-0.015-4.639 0.007-2.347-0.037-4.688-0.03-7.033-0.004-1.455-0.018-2.907-0.026-4.359 0.001-1.195 0.023-1.821 0.683-1.847 1.88-0.007 0.154-0.011 0.307 0.002 0.462 0.379 2.316 0.647 4.649 0.972 6.974 0.014 0.074 0.05 0.146 0.056 0.223 0.253 2.459 0.637 4.899 0.946 7.351 0.042 0.303 0.108 0.602 0.13 0.912 0.162 2.496 1.099 4.607 3.018 6.228 0.033 0.028 0.062 0.06 0.097 0.089 0.326 0.234 0.631 0.502 0.948 0.748 1.269 0.893 2.675 1.493 4.16 1.893 1.011 0.272 2.032 0.559 3.103 0.387l1.431 0.011c0.317-0.125 0.661 0.018 0.981-0.094l1.942-0.367c0.365-0.065 0.784 0.029 1.05-0.346l0.05-0.013c0.647-0.138 1.255-0.345 1.688-0.892 0.556-0.399 1.074-0.844 1.566-1.318 1.252-1.568 1.896-3.315 1.532-5.349zM52.445 51.958c-1.316 0.073-2.641 0.009-3.964 0.019-2.177-0.039-4.354-0.009-6.531-0.014-0.236-0.002-0.454 0.06-0.658 0.171-1.591 0.005-3.177 0.006-4.77 0.02-0.827 0.006-1.365 0.447-1.566 1.246-0.081 0.315-0.166 0.631-0.248 0.946-0.117 0.219-0.228 0.439-0.291 0.685-0.142 0.581-0.155 1.179-0.261 1.762-0.349 1.579-0.702 3.158-1.054 4.736-0.037 0.188-0.065 0.378-0.11 0.564-0.533 2.328-1.066 4.656-1.601 6.984-0.114 0.817-0.401 1.604-0.446 2.435-0.09 1.766 0.554 3.27 1.643 4.609 0.522 0.514 1.087 0.977 1.686 1.396 0.045 0.19 0.198 0.274 0.351 0.356 0.982 0.54 2.002 0.902 3.057 1.123 1.049 0.219 2.128 0.296 3.231 0.263 0.151-0.041 0.297-0.071 0.454-0.076 2.618-0.068 4.989-0.909 7.164-2.347 0.29-0.202 0.566-0.417 0.838-0.643 0.073-0.061 0.143-0.125 0.21-0.186 1.499-1.289 2.529-2.861 2.845-4.878 0.154-0.989 0.219-1.988 0.409-2.971 0.306-2.086 0.571-4.179 0.85-6.271 0.366-2.477 0.642-4.967 1.017-7.444 0.342-1.673-0.671-2.572-2.255-2.485zm46.477 9.917c-0.006-0.148-0.015-0.297-0.021-0.447 0.009-0.956-0.047-1.91-0.172-2.857-0.187-1.422-0.195-2.865-0.536-4.268 0.136-1.003-0.186-1.714-0.895-1.958-0.339-0.153-0.688-0.199-1.058-0.197-1.49 0.002-2.98-0.01-4.472-0.019-0.228-0.184-0.503-0.168-0.768-0.168-1.89-0.003-3.78-0.001-5.669-0.002-0.291 0-0.572 0.023-0.832 0.176-1.467 0.005-2.934 0.01-4.399 0.017-0.969 0.005-1.406 0.329-1.722 1.269-0.041 0.752-0.106 1.501-0.149 2.256 3.21-0.751 7.827-0.979 14.478-0.127l2.44 4.187-4.77-2.002 1.825 2.152-4.978 2.152s-3.053-3.073-4.676-3.285c0 0 2.033 4.521 1.154 4.14 0 0-1.986-1.227-5.39-1.749 0.013 0.327 0.021 0.654 0.03 0.984 1.441 0.392 2.902 1.049 4.074 2.146 0 0 6.576 5.317 9.561 14.262l-0.406 0.438c0.104-0.013 0.209-0.021 0.313-0.038 2.218-0.386 4.217-1.237 5.883-2.797 0.061-0.041 0.123-0.085 0.184-0.13 0.363-0.269 0.688-0.572 0.791-1.056 0.029-0.092 0.035-0.2 0.153-0.231 0.458-0.888 0.842-1.799 0.934-2.813 0.08-0.874-0.106-1.729-0.128-2.593-0.258-2.481-0.52-4.962-0.779-7.442z"/>
<path android:fillColor="#0E9257" android:pathData="M88.538 73.529l-1.164 1.506-2.915-4.533-0.714 0.79s-4.243-1.543-4.465-5.193l-0.941 1.673c0.004 0.35-0.002 0.699 0.004 1.048 0.031 0.65 0.156 1.295 0.132 1.95-0.054 1.713 0.56 3.189 1.646 4.48 0.226 0.265 0.406 0.583 0.714 0.757 0.075 0.042 0.157 0.08 0.251 0.102 1.066 0.893 2.252 1.587 3.527 2.13 1.947 0.673 3.93 0.955 5.951 0.831-0.218-0.426-0.63-1.06-1.412-1.969-0.001 0.001-1.663-1.567-0.614-3.572zm-17.553-11.03s-0.049-0.415 0.068-1.045l0.005-0.002c0.006-0.025 0.006-0.053 0.014-0.078 0.028-0.073 0.002-0.052 0.078-0.07 0.102-0.022 0.204 0.003 0.301-0.046-0.034 0.008-0.068 0.007-0.105 0.009-0.088 0.015-0.178 0.029-0.266 0.045 0.3-1.406 1.417-3.73 5.407-5.131 0.011-0.562 0.018-1.125 0.009-1.687-0.005-0.077 0.008-0.155 0.008-0.231 0.022-1.646-0.599-2.289-2.232-2.294-1.113-0.002-2.225 0-3.334-0.001-0.291-0.001-0.577 0.012-0.835 0.164l-7.139-0.004c-1.556 0.009-3.107 0.011-4.663 0.024-0.964 0.008-1.538 0.527-1.661 1.484-0.036 0.284-0.079 0.569-0.116 0.854-0.132 0.104-0.162 0.252-0.18 0.407-0.135 1.062-0.255 2.126-0.34 3.194-0.059 0.753-0.165 1.513-0.088 2.277-0.021 0.594-0.071 1.184-0.207 1.763l-0.708 6.7c-0.014 0.249-0.014 0.5-0.057 0.745-0.333 1.865-0.124 3.637 0.882 5.275 0.17 0.493 0.405 0.935 0.916 1.153 0.002 0 0.002 0.002 0.005 0.004 0.83 0.799 1.759 1.457 2.804 1.952 0.203 0.096 0.414 0.175 0.624 0.256 0.929-2.816 3.475-8.805 9.553-14.586 0 0 1.345 1.415-0.254 2.75 0 0-5.172 6.826-6.514 12.597l0.146 0.028c2.385 0.261 4.715 0.019 6.983-0.798 1.242-0.536 2.395-1.217 3.441-2.083 0.08-0.037 0.158-0.075 0.235-0.117 0.123-0.068 0.241-0.147 0.337-0.258 1.016-1.145 1.766-2.415 2.024-3.955-0.724-1.412-1.596-2.757-2.408-4.107 0 0-0.448 0.115-1.048-0.318-0.78-0.425-1.106-1.189-1.416-2.02l-1.133-2.192c2.329-0.202 3.687 0.845 4.917 1.969 0.165 0.151 0.326 0.321 0.48 0.502 0.268 0.286 0.523 0.606 0.761 0.962 0.01-0.383 0.024-0.768 0.037-1.153-1.129-1.099-2.838-2.281-5.331-2.938zm-3.712 2.199l-4.204-0.359s2.349-3.566 6.402-1.527l-2.198 1.886zm2.638-2.36c-2.869-0.969-2.47-2.525-1.874-3.466 0.021-0.035 0.037-0.072 0.061-0.104 0.017-0.021 0.035-0.037 0.047-0.059 0.168-0.235 0.335-0.421 0.441-0.53 0.011-0.009 0.019-0.02 0.03-0.029 0.052-0.053 0.087-0.083 0.087-0.083 0.072-0.066 0.15-0.125 0.228-0.186 0.381-0.315 0.801-0.563 1.254-0.756l0.174-0.075c0.06-0.021 0.119-0.047 0.176-0.069 1.554-0.594 3.046-0.558 3.046-0.558-1.56 0.978-2.454 2.153-2.969 3.2-0.325 0.801-0.543 1.706-0.701 2.715z"/>
<path android:fillColor="#0E9257" android:pathData="M76.377 60.942c-1.377-0.076-2.92-0.022-4.609 0.255-0.121 0.02-0.231 0.039-0.344 0.056 0.005 0.002 0.022 0.002 0.026 0.005l0.004-0.001c-0.051 0.021-0.061 0.057-0.078 0.098-0.015 0.029-0.019 0.044-0.023 0.055l0.031-0.004 1.106 0.234s1.727-0.195 3.889 0.092c-0.002-0.032 0-0.063 0-0.094-0.004-0.232 0-0.464-0.002-0.696z"/>
<path android:fillColor="#F7F8F9" android:pathData="M77.698 76.82c0.63-1.794 0.581-4.177 0.058-6.393 0.302 1.703 0.332 3.802-0.058 6.393zm-1.554-5.131c-0.005 0.038-0.013 0.072-0.02 0.106 0.798 1.559 1.417 3.201 1.573 5.025 0 0-0.055-2.078-1.549-5.174 0.001 0.016 0.001 0.029-0.004 0.043zm-4.891-6.338c0.31 0.83 0.636 1.594 1.416 2.019-0.43-0.309-0.937-0.897-1.416-2.019zm4.265 0.278c-0.154-0.181-0.316-0.351-0.48-0.502-1.231-1.125-2.588-2.171-4.917-1.969-0.001 0 3.09-0.006 5.397 2.471z"/>
<path android:fillColor="#F7F8F9" android:pathData="M76.149 71.646c-0.573-1.189-1.35-2.525-2.432-3.958 0.812 1.35 1.684 2.695 2.408 4.107 0.006-0.034 0.014-0.068 0.02-0.106 0.004-0.014 0.004-0.027 0.004-0.043zm-7.563-13.467l0.03-0.029c-0.011 0.01-0.018 0.02-0.03 0.029zm-0.488 0.589c-0.024 0.032-0.041 0.069-0.061 0.104 0.036-0.057 0.073-0.112 0.108-0.163-0.012 0.021-0.03 0.037-0.047 0.059zm2.262-1.718l0.176-0.069c-0.057 0.022-0.116 0.048-0.176 0.069zm-0.449 5.288c0.158-1.009 0.376-1.914 0.704-2.715-0.737 1.49-0.704 2.715-0.704 2.715zm-0.979-4.457c0.395-0.314 0.824-0.562 1.254-0.756-0.454 0.192-0.873 0.44-1.254 0.756zM59.7 79.872l2.96 2.071c-0.062-0.923 0.059-1.927 0.297-2.964-0.948-0.188-1.884-0.411-2.785-0.761-0.344 1.041-0.472 1.654-0.472 1.654zm10.837-22.891c0.928-0.327 1.968-0.467 3.046-0.558 0 0-1.493-0.036-3.046 0.558z"/>
<path android:fillColor="#F7F8F9" android:pathData="M73.583 56.423c-1.503 0.733-2.402 1.807-2.969 3.2 0.515-1.047 1.409-2.222 2.969-3.2zm-5.437 2.286c0.138-0.191 0.287-0.37 0.441-0.53-0.106 0.109-0.274 0.295-0.441 0.53zm2.04-1.584c0.061-0.024 0.114-0.052 0.174-0.075l-0.174 0.075zm-1.482 0.942s-0.035 0.03-0.087 0.083c0.1-0.099 0.208-0.181 0.315-0.269-0.078 0.061-0.157 0.12-0.228 0.186z"/>
<path android:fillAlpha="0.3" android:fillColor="#F7F8F9" android:pathData="M77.756 70.428c-0.288-1.609-0.822-2.86-1.477-3.837-0.002 0.031-0.002 0.063-0.002 0.094 0.646 1.064 1.155 2.37 1.479 3.743z" android:strokeAlpha="0.3"/>
<path android:fillColor="#0E9257" android:pathData="M73.717 67.688c-0.428-0.049-0.765-0.163-1.048-0.318 0.6 0.433 1.048 0.318 1.048 0.318z"/>
<path android:fillAlpha="0.3" android:fillColor="#F7F8F9" android:pathData="M73.717 67.688c-0.428-0.049-0.765-0.163-1.048-0.318 0.6 0.433 1.048 0.318 1.048 0.318z" android:strokeAlpha="0.3"/>
<path android:fillColor="#0E9257" android:pathData="M71.253 65.35c-0.279-0.751-0.545-1.553-1.133-2.192l1.133 2.192z"/>
<path android:fillAlpha="0.3" android:fillColor="#F7F8F9" android:pathData="M71.253 65.35c-0.279-0.751-0.545-1.553-1.133-2.192l1.133 2.192z" android:strokeAlpha="0.3"/>
<path android:fillColor="#0E9257" android:pathData="M76.276 66.685c0-0.031 0-0.062 0.002-0.094-0.238-0.355-0.493-0.676-0.761-0.962 0.271 0.312 0.524 0.669 0.759 1.056z"/>
<path android:fillAlpha="0.3" android:fillColor="#F7F8F9" android:pathData="M76.276 66.685c0-0.031 0-0.062 0.002-0.094-0.238-0.355-0.493-0.676-0.761-0.962 0.271 0.312 0.524 0.669 0.759 1.056z" android:strokeAlpha="0.3"/>
<path android:fillColor="#F7F8F9" android:pathData="M76.267 68.808c-0.055 0.945 0.021 1.893-0.118 2.839 1.494 3.096 1.549 5.174 1.549 5.174 0.391-2.592 0.36-4.69 0.058-6.393-0.324-1.373-0.833-2.679-1.479-3.743-0.02 0.707-0.032 1.415-0.01 2.123zm0.11-7.866c0.689 0.038 1.335 0.106 1.938 0.2-0.06-1.619-0.151-3.238-0.119-4.858 0.002-0.203 0.023-0.406 0.034-0.609-0.636 0.149-1.215 0.32-1.745 0.506-0.033 1.586-0.113 3.173-0.108 4.761zm1.968 1.184c-0.671-0.185-1.336-0.31-1.966-0.394 0.032 1.237-0.017 2.473-0.064 3.706 1.377 1.342 1.896 2.564 1.896 2.564l0.127-0.229c-0.003-1.042-0.011-2.085 0-3.127 0.034-0.841 0.028-1.681 0.007-2.52zm12.449 17.688l0.779-0.841c-0.337 0.044-0.676 0.076-1.01 0.098 0.274 0.54 0.231 0.743 0.231 0.743z"/>
</vector>

View file

@ -0,0 +1,7 @@
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="90"
android:toDegrees="90"
android:pivotX="50%"
android:pivotY="50%"
android:drawable="@drawable/ic_send">
</rotate>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFF" android:pathData="M12,8V4l8,8 -8,8v-4H4V8z"/>
</vector>

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