Merge branch 'feat_equiv_values' into develop

This commit is contained in:
Severiano Jaramillo 2019-02-05 21:12:03 -06:00
commit 577e3ae01f
20 changed files with 519 additions and 34 deletions

View file

@ -118,8 +118,9 @@ dependencies {
// Core library // Core library
androidTestImplementation 'androidx.test:core:1.1.0' androidTestImplementation 'androidx.test:core:1.1.0'
// testImplementation "androidx.arch.core:core-testing:$lifecycle_version" androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version"
androidTestImplementation "androidx.room:room-testing:$room_version" androidTestImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.0' androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
androidTestImplementation 'com.jraska.livedata:testing-ktx:1.0.0'
} }

View file

@ -0,0 +1,42 @@
package cy.agorise.bitsybitshareswallet
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
object LiveDataTestUtil {
fun <T> getValue(liveData: LiveData<T>): T {
val data = arrayOfNulls<Any>(1)
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data[0] = o
latch.countDown()
liveData.removeObserver(this)
}
}
liveData.observeForever(observer)
latch.await(2, TimeUnit.SECONDS)
@Suppress("UNCHECKED_CAST")
return data[0] as T
}
}

View file

@ -1,20 +1,19 @@
package cy.agorise.bitsybitshareswallet; package cy.agorise.bitsybitshareswallet;
import android.content.Context import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.runner.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.entities.Merchant import cy.agorise.bitsybitshareswallet.database.entities.Merchant
import org.junit.After import org.junit.*
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.IOException import java.io.IOException
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
public class MerchantQueryTest { class MerchantQueryTest {
@get:Rule val testRule = InstantTaskExecutorRule()
private lateinit var db: BitsyDatabase private lateinit var db: BitsyDatabase
@Before @Before

View file

@ -0,0 +1,121 @@
package cy.agorise.bitsybitshareswallet
import android.content.Context
import android.util.Log
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.jraska.livedata.test
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.entities.EquivalentValue
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import org.junit.*
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class TransfersTests {
val TAG = "TransfersTests"
@get:Rule val testRule = InstantTaskExecutorRule()
private lateinit var db: BitsyDatabase
private val context = ApplicationProvider.getApplicationContext<Context>()
@Before
fun createDb() {
Log.d(TAG,"createDb")
db = Room.inMemoryDatabaseBuilder(context, BitsyDatabase::class.java).build()
}
@After
@Throws(IOException::class)
fun closeDb(){
Log.d(TAG,"closeDB")
db.close()
}
/**
* Prepares the database to the testGetTransfersMissingEquivalentValues and testGetTransfersMissingEquivalentValues2
* tests.
*/
private fun prepareMissingEquivalentValues(){
// We create 2 transfers for the 'transfers' table, but only one of them will have an equivalent value entry
val t1 = Transfer("1.11.702181910", 34118155, 1485018549, 264174, "1.3.0", "1.2.32567","1.2.139293",15869682,"1.3.0","")
val t2 = Transfer("1.11.684483739", 33890367, 1547171166, 11030, "1.3.0", "1.2.139293","1.2.1029856",98,"1.3.120","")
db.transferDao().insert(t1)
db.transferDao().insert(t2)
// Here's the equivalent value for the first transaction inserted (t1)
val equivalentValue = EquivalentValue("1.11.702181910", 0, "usd")
db.equivalentValueDao().insert(equivalentValue)
}
/**
* This test makes use of the LiveData Testing library and its objective is to prove that
* the TransferDao#getTransfersWithMissingValueIn(symbol: String) will return only the
* second 'transfer' entry.
* <p>
* @see cy.agorise.bitsybitshareswallet.database.daos.TransferDao.getTransfersWithMissingValueIn
* @see cy.agorise.bitsybitshareswallet.LiveDataTestUtil
*/
@Test
fun testGetTransfersMissingEquivalentValues(){
prepareMissingEquivalentValues()
db.transferDao()
.getTransfersWithMissingValueIn("usd")
.test()
.awaitValue()
.assertHasValue()
.assertValue { transfers -> transfers.size == 1 }
.assertValue { transfers -> transfers[0].id == "1.11.684483739"}
.assertValue { transfers -> transfers[0].blockNumber == 33890367L}
}
/**
* This test makes use of the simple LiveDataTestUtil class and its objective is to prove that
* the TransferDao#getTransfersWithMissingValueIn(symbol: String) will return only the
* second 'transfer' entry.
* <p>
* @see cy.agorise.bitsybitshareswallet.LiveDataTestUtil
*/
@Test
fun testGetTransfersMissingEquivalentValues2(){
prepareMissingEquivalentValues()
val transfers: List<Transfer> = LiveDataTestUtil.getValue(db.transferDao().getTransfersWithMissingValueIn("usd"))
Assert.assertNotNull(transfers)
Assert.assertEquals(1, transfers.size)
Assert.assertEquals("1.11.684483739", transfers[0].id)
Assert.assertEquals(33890367, transfers[0].blockNumber)
Log.d(TAG, "transfer ${transfers[0]}");
}
@Test
fun testGetTransfersWithMissingBtsValue(){
val t1 = Transfer("1.11.702181910",
34118155,
1485018549,
264174,
"1.3.0",
"1.2.32567",
"1.2.139293",
15869682,
"1.3.0","")
val t2 = Transfer("1.11.684483739",
33890367,
1547171166,
11030,
"1.3.0",
"1.2.139293",
"1.2.1029856",
98,
"1.3.120",
"",
1000)
db.transferDao().insert(t1)
db.transferDao().insert(t2)
db.transferDao().getTransfersWithMissingBtsValue()
.test()
.assertHasValue()
.assertValue { transfer -> transfer.id == "1.11.702181910" }
}
}

View file

@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.AsyncTask
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -17,29 +18,32 @@ import com.crashlytics.android.Crashlytics
import com.crashlytics.android.core.CrashlyticsCore import com.crashlytics.android.core.CrashlyticsCore
import cy.agorise.bitsybitshareswallet.BuildConfig import cy.agorise.bitsybitshareswallet.BuildConfig
import cy.agorise.bitsybitshareswallet.database.entities.Balance import cy.agorise.bitsybitshareswallet.database.entities.Balance
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import cy.agorise.bitsybitshareswallet.processors.TransfersLoader import cy.agorise.bitsybitshareswallet.processors.TransfersLoader
import cy.agorise.bitsybitshareswallet.repositories.AssetRepository import cy.agorise.bitsybitshareswallet.repositories.AssetRepository
import cy.agorise.bitsybitshareswallet.utils.Constants import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.viewmodels.BalanceViewModel import cy.agorise.bitsybitshareswallet.viewmodels.BalanceViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.ConnectedActivityViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.TransferViewModel import cy.agorise.bitsybitshareswallet.viewmodels.TransferViewModel
import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel
import cy.agorise.graphenej.Asset import cy.agorise.graphenej.Asset
import cy.agorise.graphenej.AssetAmount import cy.agorise.graphenej.AssetAmount
import cy.agorise.graphenej.UserAccount import cy.agorise.graphenej.UserAccount
import cy.agorise.graphenej.api.ApiAccess
import cy.agorise.graphenej.api.ConnectionStatusUpdate import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.android.NetworkService import cy.agorise.graphenej.api.android.NetworkService
import cy.agorise.graphenej.api.android.RxBus import cy.agorise.graphenej.api.android.RxBus
import cy.agorise.graphenej.api.calls.* import cy.agorise.graphenej.api.calls.*
import cy.agorise.graphenej.models.AccountProperties import cy.agorise.graphenej.models.*
import cy.agorise.graphenej.models.BlockHeader
import cy.agorise.graphenej.models.FullAccountDetails
import cy.agorise.graphenej.models.JsonRpcResponse
import io.fabric.sdk.android.Fabric import io.fabric.sdk.android.Fabric
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.collections.HashMap import kotlin.collections.HashMap
@ -58,11 +62,13 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
private const val RESPONSE_GET_ACCOUNT_BALANCES = 3 private const val RESPONSE_GET_ACCOUNT_BALANCES = 3
private const val RESPONSE_GET_ASSETS = 4 private const val RESPONSE_GET_ASSETS = 4
private const val RESPONSE_GET_BLOCK_HEADER = 5 private const val RESPONSE_GET_BLOCK_HEADER = 5
private const val RESPONSE_GET_MARKET_HISTORY = 6
} }
private lateinit var mUserAccountViewModel: UserAccountViewModel private lateinit var mUserAccountViewModel: UserAccountViewModel
private lateinit var mBalanceViewModel: BalanceViewModel private lateinit var mBalanceViewModel: BalanceViewModel
private lateinit var mTransferViewModel: TransferViewModel private lateinit var mTransferViewModel: TransferViewModel
private lateinit var mConnectedActivityViewModel: ConnectedActivityViewModel
private lateinit var mAssetRepository: AssetRepository private lateinit var mAssetRepository: AssetRepository
@ -71,8 +77,8 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
private val mHandler = Handler() private val mHandler = Handler()
// Disposable returned at the bus subscription // Composite disposable used to clear all disposables once the activity is destroyed
private var mDisposable: Disposable? = null private val mCompositeDisposable = CompositeDisposable()
private var storedOpCount: Long = -1 private var storedOpCount: Long = -1
@ -90,6 +96,10 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
private var blockNumberWithMissingTime = 0L private var blockNumberWithMissingTime = 0L
// Variable used to hold a reference to the specific Transfer instance which we're currently trying
// to resolve an equivalent BTS value
var transfer: Transfer? = null
/** /**
* Flag used to keep track of the NetworkService binding state * Flag used to keep track of the NetworkService binding state
*/ */
@ -107,6 +117,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
mAssetRepository = AssetRepository(this) mAssetRepository = AssetRepository(this)
// Configure ConnectedActivityViewModel to obtain missing equivalent values
mConnectedActivityViewModel = ViewModelProviders.of(this).get(ConnectedActivityViewModel::class.java)
mConnectedActivityViewModel.observeMissingEquivalentValuesIn("usd") //TODO: Obtain this from shared preferences?
// Configure UserAccountViewModel to obtain the missing account ids // Configure UserAccountViewModel to obtain the missing account ids
mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java) mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java)
@ -139,12 +154,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer<Long>{ blockNumber -> mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer<Long>{ blockNumber ->
if (blockNumber != null && blockNumber != blockNumberWithMissingTime) { if (blockNumber != null && blockNumber != blockNumberWithMissingTime) {
blockNumberWithMissingTime = blockNumber blockNumberWithMissingTime = blockNumber
Log.d(TAG, "Block number: $blockNumber, Time: ${System.currentTimeMillis()}")
mHandler.post(mRequestBlockMissingTimeTask) mHandler.post(mRequestBlockMissingTimeTask)
} }
}) })
mDisposable = RxBus.getBusInstance() val disposable = RxBus.getBusInstance()
.asFlowable() .asFlowable()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ .subscribe({
@ -152,6 +166,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
}, { }, {
this.handleError(it) this.handleError(it)
}) })
mCompositeDisposable.add(disposable)
} }
/** /**
@ -202,6 +217,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
handleBlockHeader(message.result as BlockHeader, blockNumber) handleBlockHeader(message.result as BlockHeader, blockNumber)
requestIdToBlockNumberMap.remove(message.id) requestIdToBlockNumberMap.remove(message.id)
} }
RESPONSE_GET_MARKET_HISTORY -> handleMarketData(message.result as List<BucketObject>)
} }
responseMap.remove(message.id) responseMap.remove(message.id)
} }
@ -223,9 +239,34 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
// If we got a disconnection notification, we should clear our response map, since // If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset // all its stored request ids will now be reset
responseMap.clear() responseMap.clear()
} else if (message.updateCode == ConnectionStatusUpdate.API_UPDATE) {
// If we got an API update
if(message.api == ApiAccess.API_HISTORY) {
// Starts the procedure that will obtain the missing equivalent values
mTransferViewModel
.getTransfersWithMissingBtsValue().observe(this, Observer<Transfer> {
if(it != null) handleTransfersWithMissingBtsValue(it)
})
} }
} }
} }
}
/**
* Method called whenever we get a list of transfers with their bts value missing.
*/
private fun handleTransfersWithMissingBtsValue(transfer: Transfer) {
if(mNetworkService?.isConnected == true){
val base = Asset(transfer.transferAssetId)
val quote = Asset("1.3.0")
val bucket: Long = TimeUnit.SECONDS.convert(1, TimeUnit.DAYS)
val end: Long = transfer.timestamp * 1000L
val start: Long = (transfer.timestamp - bucket) * 1000L
val id = mNetworkService!!.sendMessage(GetMarketHistory(base, quote, bucket, start, end), GetMarketHistory.REQUIRED_API)
responseMap[id] = RESPONSE_GET_MARKET_HISTORY
this.transfer = transfer
}
}
/** /**
* Method called whenever a response to the 'get_full_accounts' API call has been detected. * Method called whenever a response to the 'get_full_accounts' API call has been detected.
@ -275,7 +316,6 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
} }
private fun handleBalanceUpdate(assetAmountList: List<AssetAmount>) { private fun handleBalanceUpdate(assetAmountList: List<AssetAmount>) {
Log.d(TAG, "handleBalanceUpdate")
val now = System.currentTimeMillis() / 1000 val now = System.currentTimeMillis() / 1000
val balances = ArrayList<Balance>() val balances = ArrayList<Balance>()
for (assetAmount in assetAmountList) { for (assetAmount in assetAmountList) {
@ -330,6 +370,25 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
} }
} }
private fun handleMarketData(buckets: List<BucketObject>) {
if(buckets.isNotEmpty()){
Log.d(TAG,"handleMarketData. Bucket is not empty")
val bucket = buckets[0]
val pair = Pair(transfer, bucket)
val disposable = Observable.just(pair)
.subscribeOn(Schedulers.computation())
.map { mTransferViewModel.updateBtsValue(it.first!!, it.second) }
.subscribe({},{
Log.e(TAG,"Error at updateBtsValue. Msg: ${it.message}")
for(line in it.stackTrace) Log.e(TAG, "${line.className}#${line.methodName}:${line.lineNumber}")
})
mCompositeDisposable.add(disposable)
}else{
Log.i(TAG,"handleMarketData. Bucket IS empty")
AsyncTask.execute { mTransferViewModel.updateBtsValue(transfer!!, Transfer.ERROR) }
}
}
private fun updateBalances() { private fun updateBalances() {
if (mNetworkService?.isConnected == true) { if (mNetworkService?.isConnected == true) {
val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()), val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()),
@ -444,6 +503,6 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (!mDisposable!!.isDisposed) mDisposable!!.dispose() if(!mCompositeDisposable.isDisposed) mCompositeDisposable.dispose()
} }
} }

View file

@ -17,4 +17,7 @@ interface AssetDao {
@Query("SELECT id, symbol, precision, description, issuer FROM assets INNER JOIN balances WHERE assets.id = balances.asset_id AND balances.asset_amount > 0") @Query("SELECT id, symbol, precision, description, issuer FROM assets INNER JOIN balances WHERE assets.id = balances.asset_id AND balances.asset_amount > 0")
fun getAllNonZero(): LiveData<List<Asset>> fun getAllNonZero(): LiveData<List<Asset>>
@Query("SELECT * FROM assets WHERE id = :assetId")
fun getAssetDetails(assetId: String): Asset
} }

View file

@ -1,18 +1,19 @@
package cy.agorise.bitsybitshareswallet.database.daos package cy.agorise.bitsybitshareswallet.database.daos
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.Dao import androidx.room.*
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cy.agorise.bitsybitshareswallet.database.entities.Transfer import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import io.reactivex.Observable
import io.reactivex.Single import io.reactivex.Single
@Dao @Dao
interface TransferDao { interface TransferDao {
@Insert @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(transfer: Transfer) fun insert(transfer: Transfer)
@Update()
fun update(transfer: Transfer)
// TODO find a way to return number of added rows // TODO find a way to return number of added rows
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertAll(transfers: List<Transfer>) fun insertAll(transfers: List<Transfer>)
@ -29,6 +30,12 @@ interface TransferDao {
@Query("SELECT block_number FROM transfers WHERE timestamp='0' LIMIT 1") @Query("SELECT block_number FROM transfers WHERE timestamp='0' LIMIT 1")
fun getTransferBlockNumberWithMissingTime(): LiveData<Long> fun getTransferBlockNumberWithMissingTime(): LiveData<Long>
@Query("SELECT * FROM transfers WHERE timestamp != 0 AND bts_value = -1 AND transfer_asset_id != '1.3.0' LIMIT 1")
fun getTransfersWithMissingBtsValue(): LiveData<Transfer>
@Query("SELECT * FROM transfers WHERE id NOT IN (SELECT transfer_id FROM equivalent_values WHERE symbol = :symbol) AND bts_value >= 0 LIMIT 1")
fun getTransfersWithMissingValueIn(symbol: String): Observable<Transfer>
@Query("DELETE FROM transfers") @Query("DELETE FROM transfers")
fun deleteAll() fun deleteAll()
} }

View file

@ -17,5 +17,18 @@ data class Transfer (
@ColumnInfo(name = "transfer_amount") val transferAmount: Long, @ColumnInfo(name = "transfer_amount") val transferAmount: Long,
@ColumnInfo(name = "transfer_asset_id") val transferAssetId: String, // TODO should be foreign key to Asset @ColumnInfo(name = "transfer_asset_id") val transferAssetId: String, // TODO should be foreign key to Asset
@ColumnInfo(name = "memo") val memo: String, @ColumnInfo(name = "memo") val memo: String,
@ColumnInfo(name = "bts_value") val btsValue: Long? = -1 @ColumnInfo(name = "bts_value") var btsValue: Long? = Transfer.NOT_CALCULATED
) ){
companion object {
// Constant used to specify an uninitialized BTS equivalent value
val NOT_CALCULATED: Long? = -1L
// Constant used to specify a BTS equivalent value whose calculation returned an error
val ERROR: Long? = -2L
}
init {
if(transferAssetId.equals("1.3.0")){
// If the transferred asset is BTS, we can fill the btsValue field immediately
btsValue = transferAmount
}
}
}

View file

@ -0,0 +1,5 @@
package cy.agorise.bitsybitshareswallet.models.coingecko
data class HistoricalPrice(val id: String,
val symbol: String,
val market_data: MarketData)

View file

@ -0,0 +1,3 @@
package cy.agorise.bitsybitshareswallet.models.coingecko
data class MarketData(var current_price: HashMap<String, Double>)

View file

@ -0,0 +1,22 @@
package cy.agorise.bitsybitshareswallet.models.coingecko
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import junit.framework.Assert
import java.lang.reflect.Type
class MarketDataDeserializer : JsonDeserializer<MarketData> {
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): MarketData {
val hashMap = HashMap<String, Double>()
val obj = json?.asJsonObject?.get("current_price")?.asJsonObject
if(obj != null){
val keySet = obj.asJsonObject.keySet()
for(key in keySet){
println("$key -> : ${obj[key].asDouble}")
hashMap[key] = obj[key].asDouble
}
}
return MarketData(hashMap)
}
}

View file

@ -0,0 +1,16 @@
package cy.agorise.bitsybitshareswallet.network
import retrofit2.Call
import cy.agorise.bitsybitshareswallet.models.coingecko.HistoricalPrice
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface CoingeckoService {
@Headers("Content-Type: application/json")
@GET("/api/v3/coins/bitshares/history")
fun getHistoricalValueSync(@Query("id") id: String,
@Query("date") date: String,
@Query("localization") localization: Boolean): Call<HistoricalPrice>
}

View file

@ -3,6 +3,8 @@ package cy.agorise.bitsybitshareswallet.network;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
import cy.agorise.bitsybitshareswallet.models.coingecko.MarketData;
import cy.agorise.bitsybitshareswallet.models.coingecko.MarketDataDeserializer;
import okhttp3.Interceptor; import okhttp3.Interceptor;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor; import okhttp3.logging.HttpLoggingInterceptor;
@ -22,23 +24,26 @@ public class ServiceGenerator{
private static HashMap<Class<?>, Object> Services; private static HashMap<Class<?>, Object> Services;
public ServiceGenerator(String apiBaseUrl) { public ServiceGenerator(String apiBaseUrl, Gson gson) {
API_BASE_URL= apiBaseUrl; API_BASE_URL= apiBaseUrl;
logging = new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY); logging = new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY);
httpClient = new OkHttpClient.Builder().addInterceptor(logging); httpClient = new OkHttpClient.Builder().addInterceptor(logging);
builder = new Retrofit.Builder() builder = new Retrofit.Builder()
.baseUrl(API_BASE_URL) .baseUrl(API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create(getGson())) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create()); .addCallAdapterFactory(RxJava2CallAdapterFactory.create());
Services = new HashMap<Class<?>, Object>(); Services = new HashMap<Class<?>, Object>();
} }
public ServiceGenerator(String apiBaseUrl){
this(apiBaseUrl, new Gson());
}
/** /**
* Customizes the Gson instance with specific de-serialization logic * Customizes the Gson instance with specific de-serialization logic
*/ */
private Gson getGson(){ private Gson getGson(){
GsonBuilder builder = new GsonBuilder(); GsonBuilder builder = new GsonBuilder();
return builder.create(); return builder.create();
} }
@ -76,6 +81,11 @@ public class ServiceGenerator{
httpClient.readTimeout(5, TimeUnit.MINUTES); httpClient.readTimeout(5, TimeUnit.MINUTES);
httpClient.connectTimeout(5, TimeUnit.MINUTES); httpClient.connectTimeout(5, TimeUnit.MINUTES);
OkHttpClient client = httpClient.build(); OkHttpClient client = httpClient.build();
if(serviceClass == CoingeckoService.class){
// The MarketData class needs a custom de-serializer
Gson gson = new GsonBuilder().registerTypeAdapter(MarketData.class, new MarketDataDeserializer()).create();
builder.addConverterFactory(GsonConverterFactory.create(gson));
}
Retrofit retrofit = builder.client(client).build(); Retrofit retrofit = builder.client(client).build();
return retrofit.create(serviceClass); return retrofit.create(serviceClass);
} }

View file

@ -3,8 +3,8 @@ package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context import android.content.Context
import android.os.AsyncTask import android.os.AsyncTask
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.daos.AssetDao
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.daos.AssetDao
import cy.agorise.bitsybitshareswallet.database.entities.Asset import cy.agorise.bitsybitshareswallet.database.entities.Asset
class AssetRepository internal constructor(context: Context) { class AssetRepository internal constructor(context: Context) {
@ -24,6 +24,10 @@ class AssetRepository internal constructor(context: Context) {
insertAllAsyncTask(mAssetDao).execute(assets) insertAllAsyncTask(mAssetDao).execute(assets)
} }
fun getAssetDetails(assetId: String): Asset {
return mAssetDao.getAssetDetails(assetId)
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: AssetDao) : private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: AssetDao) :
AsyncTask<List<Asset>, Void, Void>() { AsyncTask<List<Asset>, Void, Void>() {

View file

@ -0,0 +1,18 @@
package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.daos.EquivalentValueDao
import cy.agorise.bitsybitshareswallet.database.daos.TransferDao
class EquivalentValuesRepository(context: Context) {
private val mEquivalentValuesDao: EquivalentValueDao?
private val mTransfersDao: TransferDao?
init {
val db = BitsyDatabase.getDatabase(context)
mEquivalentValuesDao = db?.equivalentValueDao()
mTransfersDao = db?.transferDao()
}
}

View file

@ -2,25 +2,42 @@ package cy.agorise.bitsybitshareswallet.repositories
import android.content.Context import android.content.Context
import android.os.AsyncTask import android.os.AsyncTask
import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import cy.agorise.bitsybitshareswallet.database.BitsyDatabase import cy.agorise.bitsybitshareswallet.database.BitsyDatabase
import cy.agorise.bitsybitshareswallet.database.daos.EquivalentValueDao
import cy.agorise.bitsybitshareswallet.database.daos.TransferDao import cy.agorise.bitsybitshareswallet.database.daos.TransferDao
import cy.agorise.bitsybitshareswallet.database.entities.EquivalentValue
import cy.agorise.bitsybitshareswallet.database.entities.Transfer import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import cy.agorise.bitsybitshareswallet.network.CoingeckoService
import cy.agorise.bitsybitshareswallet.network.ServiceGenerator
import cy.agorise.bitsybitshareswallet.utils.Constants
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import java.text.SimpleDateFormat
import java.util.*
class TransferRepository internal constructor(context: Context) { class TransferRepository internal constructor(context: Context) {
private val TAG = "TransferRepository"
private val mTransferDao: TransferDao private val mTransferDao: TransferDao
private val mEquivalentValuesDao: EquivalentValueDao
private val compositeDisposable = CompositeDisposable()
init { init {
val db = BitsyDatabase.getDatabase(context) val db = BitsyDatabase.getDatabase(context)
mTransferDao = db!!.transferDao() mTransferDao = db!!.transferDao()
mEquivalentValuesDao = db!!.equivalentValueDao()
} }
fun insertAll(transfers: List<Transfer>) { fun insertAll(transfers: List<Transfer>) {
insertAllAsyncTask(mTransferDao).execute(transfers) insertAllAsyncTask(mTransferDao).execute(transfers)
} }
fun update(transfer: Transfer){
mTransferDao.insert(transfer)
}
fun setBlockTime(blockNumber: Long, timestamp: Long) { fun setBlockTime(blockNumber: Long, timestamp: Long) {
setBlockTimeAsyncTask(mTransferDao).execute(Pair(blockNumber, timestamp)) setBlockTimeAsyncTask(mTransferDao).execute(Pair(blockNumber, timestamp))
} }
@ -37,10 +54,71 @@ class TransferRepository internal constructor(context: Context) {
return mTransferDao.getTransferBlockNumberWithMissingTime() return mTransferDao.getTransferBlockNumberWithMissingTime()
} }
fun getTransfersWithMissingBtsValue(): LiveData<Transfer> {
return mTransferDao.getTransfersWithMissingBtsValue()
}
fun deleteAll() { fun deleteAll() {
deleteAllAsyncTask(mTransferDao).execute() deleteAllAsyncTask(mTransferDao).execute()
} }
/**
* Creates a subscription to the transfers table which will listen & process equivalent values.
*
* This function will create a subscription that will listen for missing equivalent values. This will
* automatically trigger a procedure designed to calculate the fiat equivalent value of any entry
* of the 'transactions' table that stil doesn't have a corresponding entry in the 'equivalent_values'
* table for that specific fiat currency.
*
* @param symbol The 3 letters symbol of the desired fiat currency.
*/
fun observeMissingEquivalentValuesIn(symbol: String) {
compositeDisposable.add(mTransferDao.getTransfersWithMissingValueIn(symbol)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.map { transfer -> obtainFiatValue(transfer, symbol) }
.subscribe({
Log.d(TAG,"Got equivalent value: $it")
mEquivalentValuesDao.insert(it)
},{
Log.e(TAG,"Error while trying to create a new equivalent value. Msg: ${it.message}")
for(element in it.stackTrace){
Log.e(TAG,"${element.className}#${element.methodName}:${element.lineNumber}")
}
}))
}
/**
* Creates an equivalent value for a given transaction.
*
* Function used to perform a request to the Coingecko's price API trying to obtain the
* equivalent value of a specific [Transaction].
*
* @param transfer The transfer whose equivalent value we want to obtain
* @param symbol The symbol of the fiat that the equivalent value should be calculated in
* @return An instance of the [EquivalentValue] class, ready to be inserted into the database.
*/
fun obtainFiatValue(transfer: Transfer, symbol: String): EquivalentValue {
val sg = ServiceGenerator(Constants.COINGECKO_URL)
val dateFormat = SimpleDateFormat("dd-MM-yyyy", Locale.ROOT)
val date = Date(transfer.timestamp * 1000)
val response = sg.getService(CoingeckoService::class.java)
.getHistoricalValueSync("bitshares", dateFormat.format(date), false)
.execute()
var equivalentFiatValue = -1L
if(response.isSuccessful){
val price: Double = response.body()?.market_data?.current_price?.get(symbol) ?: -1.0
// The equivalent value is obtained by:
// 1- Dividing the base value by 100000 (BTS native precision)
// 2- Multiplying that BTS value by the unit price in the chosen fiat
// 3- Multiplying the resulting value by 100 in order to express it in cents
equivalentFiatValue = Math.round(transfer.btsValue?.div(1e5)?.times(price)?.times(100) ?: -1.0)
}else{
Log.w(TAG,"Request was not successful. code: ${response.code()}")
}
return EquivalentValue(transfer.id, equivalentFiatValue, symbol)
}
private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) : private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) :
AsyncTask<List<Transfer>, Void, Void>() { AsyncTask<List<Transfer>, Void, Void>() {
@ -67,4 +145,16 @@ class TransferRepository internal constructor(context: Context) {
return null return null
} }
} }
/**
* Called whenever the disposables have to be cleared.
*
* Since this repository manages a subscription it is necessary to clear the disposable after we're done with it.
* The parent ViewModel will let us know when that subscription is no longer necessary and the resources can
* be cleared.
*/
fun onCleared() {
if(!compositeDisposable.isDisposed)
compositeDisposable.clear()
}
} }

View file

@ -22,6 +22,9 @@ object Constants {
/** Faucet URL used to create new accounts */ /** Faucet URL used to create new accounts */
const val FAUCET_URL = "https://faucet.palmpay.io" const val FAUCET_URL = "https://faucet.palmpay.io"
/** Coingecko's API URL */
const val COINGECKO_URL = "https://api.coingecko.com"
/** The user selected encrypted PIN */ /** The user selected encrypted PIN */
const val KEY_ENCRYPTED_PIN = "key_encrypted_pin" const val KEY_ENCRYPTED_PIN = "key_encrypted_pin"

View file

@ -0,0 +1,18 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import cy.agorise.bitsybitshareswallet.repositories.TransferRepository
class ConnectedActivityViewModel(application: Application) : AndroidViewModel(application) {
private var mTransfersRepository = TransferRepository(application)
fun observeMissingEquivalentValuesIn(symbol: String) {
mTransfersRepository.observeMissingEquivalentValuesIn(symbol)
}
override fun onCleared() {
super.onCleared()
mTransfersRepository.onCleared()
}
}

View file

@ -3,16 +3,48 @@ package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.google.common.primitives.UnsignedLong
import cy.agorise.bitsybitshareswallet.database.entities.Transfer
import cy.agorise.bitsybitshareswallet.repositories.AssetRepository
import cy.agorise.bitsybitshareswallet.repositories.TransferRepository import cy.agorise.bitsybitshareswallet.repositories.TransferRepository
import cy.agorise.graphenej.Asset
import cy.agorise.graphenej.AssetAmount
import cy.agorise.graphenej.Converter
import cy.agorise.graphenej.models.BucketObject
class TransferViewModel(application: Application) : AndroidViewModel(application) { class TransferViewModel(application: Application) : AndroidViewModel(application) {
private var mRepository = TransferRepository(application) private val TAG = "TransferViewModel"
private var mTransferRepository = TransferRepository(application)
private var mAssetRepository = AssetRepository(application)
internal fun setBlockTime(blockNumber: Long, timestamp: Long) { internal fun setBlockTime(blockNumber: Long, timestamp: Long) {
mRepository.setBlockTime(blockNumber, timestamp) mTransferRepository.setBlockTime(blockNumber, timestamp)
} }
internal fun getTransferBlockNumberWithMissingTime(): LiveData<Long> { internal fun getTransferBlockNumberWithMissingTime(): LiveData<Long> {
return mRepository.getTransferBlockNumberWithMissingTime() return mTransferRepository.getTransferBlockNumberWithMissingTime()
}
fun getTransfersWithMissingBtsValue() : LiveData<Transfer> {
return mTransferRepository.getTransfersWithMissingBtsValue()
}
fun updateBtsValue(transfer: Transfer, bucket: BucketObject) {
val base = mAssetRepository.getAssetDetails(bucket.key.base.objectId) // Always BTS ?
val quote = mAssetRepository.getAssetDetails(bucket.key.quote.objectId) // Any asset other than BTS
val converter = Converter(Asset(base.id, base.symbol, base.precision), Asset(quote.id, quote.symbol, quote.precision), bucket)
// The "base" amount is always the amount we have, and the quote would be the amount we want to obtain.
// It can be strange that the second argument of the AssetAmount constructor here we pass the quote.id, quote.symbol and quote.precision
// when building the "base" amount instance. But this is just because the full node will apparently always treat BTS as the base.
val baseAmount = AssetAmount(UnsignedLong.valueOf(transfer.transferAmount), Asset(quote.id, quote.symbol, quote.precision))
val quoteAmount = converter.convert(baseAmount, Converter.OPEN_VALUE)
transfer.btsValue = quoteAmount
mTransferRepository.update(transfer)
}
fun updateBtsValue(transfer: Transfer, value: Long?) {
transfer.btsValue = value
mTransferRepository.update(transfer)
} }
} }

View file

@ -0,0 +1,19 @@
package cy.agorise.bitsybitshareswallet
import com.google.gson.GsonBuilder
import cy.agorise.bitsybitshareswallet.models.coingecko.MarketData
import cy.agorise.bitsybitshareswallet.models.coingecko.MarketDataDeserializer
import org.junit.Assert
import org.junit.Test
class MarketDataDeserializerTest {
@Test
fun marketDataDeserializationTest(){
val str = "{\"current_price\": {\"aed\": 0.14139359620401012,\"ars\": 1.476552955052185,\"aud\": 0.05410080634896981,\"bch\": 0.0003021370317928406,\"bdt\": 3.2298217535732276,\"bhd\": 0.01451147244444769,\"bmd\": 0.03849350092032233,\"bnb\": 0.007113127493734956,\"brl\": 0.15000509277539803,\"btc\": 0.00001043269732289735,\"cad\": 0.051866143140042266,\"chf\": 0.03825734329217614,\"clp\": 26.587581916766037,\"cny\": 0.2652895096426772,\"czk\": 0.8706365729081245,\"dkk\": 0.25236393094264586,\"eos\": 0.01566778197589746,\"eth\": 0.0003870069548974383,\"eur\": 0.033804376612212375,\"gbp\": 0.030484350651335475,\"hkd\": 0.3012660745118239,\"huf\": 10.909058160819312,\"idr\": 558.1942568455942,\"ils\": 0.14452962323048843,\"inr\": 2.721290348862006,\"jpy\": 4.327150672205728,\"krw\": 43.47379006939362,\"kwd\": 0.011703102097803801,\"lkr\": 6.939897047172613,\"ltc\": 0.0013225337650442446,\"mmk\": 60.56217136246436,\"mxn\": 0.7738105980956592,\"myr\": 0.1608450935955668,\"nok\": 0.335428517669597,\"nzd\": 0.056803550529088344,\"php\": 2.046274976098886,\"pkr\": 5.3730315641051885,\"pln\": 0.1449376543402434,\"rub\": 2.596498268228413,\"sar\": 0.1444545609036934,\"sek\": 0.3498212376637053,\"sgd\": 0.05281188996415366,\"thb\": 1.2598922851221481,\"try\": 0.20393883733037357,\"twd\": 1.1869880579631216,\"usd\": 0.03849350092032233,\"vef\": 9565.159285292651,\"xag\": 0.002632124388265174,\"xau\": 0.00003094261577979185,\"xdr\": 0.02769368731511483,\"xlm\": 0.3411570542267162,\"xrp\": 0.11074614753363282,\"zar\": 0.5534635980499906}}"
val gson = GsonBuilder().registerTypeAdapter(MarketData::class.java, MarketDataDeserializer())
.create()
val marketData = gson.fromJson<MarketData>(str, MarketData::class.java)
Assert.assertEquals(0.03849350092032233, marketData.current_price["usd"])
Assert.assertEquals(0.033804376612212375, marketData.current_price["eur"])
}
}