diff --git a/app/build.gradle b/app/build.gradle index a46d1f1..0e17e8d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -118,8 +118,9 @@ dependencies { // Core library 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.test.ext:junit:1.1.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + androidTestImplementation 'com.jraska.livedata:testing-ktx:1.0.0' } diff --git a/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/LiveDataTestUtil.kt b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/LiveDataTestUtil.kt new file mode 100644 index 0000000..d906a53 --- /dev/null +++ b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/LiveDataTestUtil.kt @@ -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 getValue(liveData: LiveData): T { + val data = arrayOfNulls(1) + val latch = CountDownLatch(1) + val observer = object : Observer { + 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 + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/MerchantQueryTest.kt b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/MerchantQueryTest.kt index 17b2e04..4529bfa 100644 --- a/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/MerchantQueryTest.kt +++ b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/MerchantQueryTest.kt @@ -1,20 +1,19 @@ package cy.agorise.bitsybitshareswallet; import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room 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.entities.Merchant -import org.junit.After -import org.junit.Assert -import org.junit.Before -import org.junit.Test +import org.junit.* import org.junit.runner.RunWith import java.io.IOException @RunWith(AndroidJUnit4::class) -public class MerchantQueryTest { +class MerchantQueryTest { + @get:Rule val testRule = InstantTaskExecutorRule() private lateinit var db: BitsyDatabase @Before diff --git a/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/TransfersTests.kt b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/TransfersTests.kt new file mode 100644 index 0000000..607f93a --- /dev/null +++ b/app/src/androidTest/java/cy/agorise/bitsybitshareswallet/TransfersTests.kt @@ -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() + + @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. + *

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

+ * @see cy.agorise.bitsybitshareswallet.LiveDataTestUtil + */ + @Test + fun testGetTransfersMissingEquivalentValues2(){ + prepareMissingEquivalentValues() + val transfers: List = 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" } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt index 2230daa..2078a07 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt @@ -4,6 +4,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.os.AsyncTask import android.os.Bundle import android.os.Handler import android.os.IBinder @@ -17,29 +18,32 @@ import com.crashlytics.android.Crashlytics import com.crashlytics.android.core.CrashlyticsCore import cy.agorise.bitsybitshareswallet.BuildConfig import cy.agorise.bitsybitshareswallet.database.entities.Balance +import cy.agorise.bitsybitshareswallet.database.entities.Transfer 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.ConnectedActivityViewModel 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.ApiAccess 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 cy.agorise.graphenej.models.* import io.fabric.sdk.android.Fabric +import io.reactivex.Observable 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.SimpleDateFormat import java.util.* +import java.util.concurrent.TimeUnit import kotlin.collections.ArrayList 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_ASSETS = 4 private const val RESPONSE_GET_BLOCK_HEADER = 5 + private const val RESPONSE_GET_MARKET_HISTORY = 6 } private lateinit var mUserAccountViewModel: UserAccountViewModel private lateinit var mBalanceViewModel: BalanceViewModel private lateinit var mTransferViewModel: TransferViewModel + private lateinit var mConnectedActivityViewModel: ConnectedActivityViewModel private lateinit var mAssetRepository: AssetRepository @@ -71,8 +77,8 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { private val mHandler = Handler() - // Disposable returned at the bus subscription - private var mDisposable: Disposable? = null + // Composite disposable used to clear all disposables once the activity is destroyed + private val mCompositeDisposable = CompositeDisposable() private var storedOpCount: Long = -1 @@ -90,6 +96,10 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { 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 */ @@ -107,6 +117,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { 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 mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java) @@ -139,12 +154,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer{ blockNumber -> if (blockNumber != null && blockNumber != blockNumberWithMissingTime) { blockNumberWithMissingTime = blockNumber - Log.d(TAG, "Block number: $blockNumber, Time: ${System.currentTimeMillis()}") mHandler.post(mRequestBlockMissingTimeTask) } }) - mDisposable = RxBus.getBusInstance() + val disposable = RxBus.getBusInstance() .asFlowable() .observeOn(AndroidSchedulers.mainThread()) .subscribe({ @@ -152,6 +166,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { }, { this.handleError(it) }) + mCompositeDisposable.add(disposable) } /** @@ -202,6 +217,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { handleBlockHeader(message.result as BlockHeader, blockNumber) requestIdToBlockNumberMap.remove(message.id) } + RESPONSE_GET_MARKET_HISTORY -> handleMarketData(message.result as List) } responseMap.remove(message.id) } @@ -223,10 +239,35 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { // 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 (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 { + 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. * @param accountDetails De-serialized account details object @@ -275,7 +316,6 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { } private fun handleBalanceUpdate(assetAmountList: List) { - Log.d(TAG, "handleBalanceUpdate") val now = System.currentTimeMillis() / 1000 val balances = ArrayList() for (assetAmount in assetAmountList) { @@ -330,6 +370,25 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { } } + private fun handleMarketData(buckets: List) { + 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() { if (mNetworkService?.isConnected == true) { val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()), @@ -444,6 +503,6 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { override fun onDestroy() { super.onDestroy() - if (!mDisposable!!.isDisposed) mDisposable!!.dispose() + if(!mCompositeDisposable.isDisposed) mCompositeDisposable.dispose() } } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt index 275f90f..6cc90c1 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt @@ -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") fun getAllNonZero(): LiveData> + + @Query("SELECT * FROM assets WHERE id = :assetId") + fun getAssetDetails(assetId: String): Asset } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt index 63aeaee..306e849 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt @@ -1,18 +1,19 @@ 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 androidx.room.* import cy.agorise.bitsybitshareswallet.database.entities.Transfer +import io.reactivex.Observable import io.reactivex.Single @Dao interface TransferDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(transfer: Transfer) + @Update() + fun update(transfer: Transfer) + // TODO find a way to return number of added rows @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertAll(transfers: List) @@ -29,6 +30,12 @@ interface TransferDao { @Query("SELECT block_number FROM transfers WHERE timestamp='0' LIMIT 1") fun getTransferBlockNumberWithMissingTime(): LiveData + @Query("SELECT * FROM transfers WHERE timestamp != 0 AND bts_value = -1 AND transfer_asset_id != '1.3.0' LIMIT 1") + fun getTransfersWithMissingBtsValue(): LiveData + + @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 + @Query("DELETE FROM transfers") fun deleteAll() } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt index cce1ae6..1505a16 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt @@ -17,5 +17,18 @@ data class Transfer ( @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, - @ColumnInfo(name = "bts_value") val btsValue: Long? = -1 - ) \ No newline at end of file + @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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/HistoricalPrice.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/HistoricalPrice.kt new file mode 100644 index 0000000..e612543 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/HistoricalPrice.kt @@ -0,0 +1,5 @@ +package cy.agorise.bitsybitshareswallet.models.coingecko + +data class HistoricalPrice(val id: String, + val symbol: String, + val market_data: MarketData) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketData.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketData.kt new file mode 100644 index 0000000..9cad020 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketData.kt @@ -0,0 +1,3 @@ +package cy.agorise.bitsybitshareswallet.models.coingecko + +data class MarketData(var current_price: HashMap) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketDataDeserializer.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketDataDeserializer.kt new file mode 100644 index 0000000..c751cd2 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/coingecko/MarketDataDeserializer.kt @@ -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 { + override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): MarketData { + val hashMap = HashMap() + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/CoingeckoService.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/CoingeckoService.kt new file mode 100644 index 0000000..0d951ec --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/CoingeckoService.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/ServiceGenerator.java b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/ServiceGenerator.java index 6554677..76279ef 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/ServiceGenerator.java +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/ServiceGenerator.java @@ -3,6 +3,8 @@ package cy.agorise.bitsybitshareswallet.network; import com.google.gson.Gson; import com.google.gson.GsonBuilder; 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.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; @@ -22,23 +24,26 @@ public class ServiceGenerator{ private static HashMap, Object> Services; - public ServiceGenerator(String apiBaseUrl) { + public ServiceGenerator(String apiBaseUrl, Gson gson) { API_BASE_URL= apiBaseUrl; logging = new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY); httpClient = new OkHttpClient.Builder().addInterceptor(logging); builder = new Retrofit.Builder() .baseUrl(API_BASE_URL) - .addConverterFactory(GsonConverterFactory.create(getGson())) + .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()); Services = new HashMap, Object>(); } + public ServiceGenerator(String apiBaseUrl){ + this(apiBaseUrl, new Gson()); + } + /** * Customizes the Gson instance with specific de-serialization logic */ private Gson getGson(){ GsonBuilder builder = new GsonBuilder(); - return builder.create(); } @@ -76,6 +81,11 @@ public class ServiceGenerator{ httpClient.readTimeout(5, TimeUnit.MINUTES); httpClient.connectTimeout(5, TimeUnit.MINUTES); 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(); return retrofit.create(serviceClass); } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt index 8ddd294..09f8624 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt @@ -3,8 +3,8 @@ 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.daos.AssetDao import cy.agorise.bitsybitshareswallet.database.entities.Asset class AssetRepository internal constructor(context: Context) { @@ -24,6 +24,10 @@ class AssetRepository internal constructor(context: Context) { insertAllAsyncTask(mAssetDao).execute(assets) } + fun getAssetDetails(assetId: String): Asset { + return mAssetDao.getAssetDetails(assetId) + } + private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: AssetDao) : AsyncTask, Void, Void>() { diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/EquivalentValuesRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/EquivalentValuesRepository.kt new file mode 100644 index 0000000..dbedc1d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/EquivalentValuesRepository.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt index 7f328d5..55b029d 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt @@ -2,25 +2,42 @@ package cy.agorise.bitsybitshareswallet.repositories import android.content.Context import android.os.AsyncTask +import android.util.Log import androidx.lifecycle.LiveData 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.entities.EquivalentValue 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.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import java.text.SimpleDateFormat +import java.util.* class TransferRepository internal constructor(context: Context) { - + private val TAG = "TransferRepository" private val mTransferDao: TransferDao + private val mEquivalentValuesDao: EquivalentValueDao + private val compositeDisposable = CompositeDisposable() init { val db = BitsyDatabase.getDatabase(context) mTransferDao = db!!.transferDao() + mEquivalentValuesDao = db!!.equivalentValueDao() } fun insertAll(transfers: List) { insertAllAsyncTask(mTransferDao).execute(transfers) } + fun update(transfer: Transfer){ + mTransferDao.insert(transfer) + } + fun setBlockTime(blockNumber: Long, timestamp: Long) { setBlockTimeAsyncTask(mTransferDao).execute(Pair(blockNumber, timestamp)) } @@ -37,10 +54,71 @@ class TransferRepository internal constructor(context: Context) { return mTransferDao.getTransferBlockNumberWithMissingTime() } + fun getTransfersWithMissingBtsValue(): LiveData { + return mTransferDao.getTransfersWithMissingBtsValue() + } + fun deleteAll() { 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) : AsyncTask, Void, Void>() { @@ -67,4 +145,16 @@ class TransferRepository internal constructor(context: Context) { 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() + } } \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt index 5f93084..9de202d 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt @@ -22,6 +22,9 @@ object Constants { /** Faucet URL used to create new accounts */ 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 */ const val KEY_ENCRYPTED_PIN = "key_encrypted_pin" diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt new file mode 100644 index 0000000..6d3e601 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/ConnectedActivityViewModel.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt index 484d72c..3d5ce22 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt @@ -3,16 +3,48 @@ package cy.agorise.bitsybitshareswallet.viewmodels import android.app.Application import androidx.lifecycle.AndroidViewModel 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.graphenej.Asset +import cy.agorise.graphenej.AssetAmount +import cy.agorise.graphenej.Converter +import cy.agorise.graphenej.models.BucketObject + 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) { - mRepository.setBlockTime(blockNumber, timestamp) + mTransferRepository.setBlockTime(blockNumber, timestamp) } internal fun getTransferBlockNumberWithMissingTime(): LiveData { - return mRepository.getTransferBlockNumberWithMissingTime() + return mTransferRepository.getTransferBlockNumberWithMissingTime() } -} \ No newline at end of file + + fun getTransfersWithMissingBtsValue() : LiveData { + 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) + } +} diff --git a/app/src/test/java/cy/agorise/bitsybitshareswallet/MarketDataDeserializerTest.kt b/app/src/test/java/cy/agorise/bitsybitshareswallet/MarketDataDeserializerTest.kt new file mode 100644 index 0000000..6c2a422 --- /dev/null +++ b/app/src/test/java/cy/agorise/bitsybitshareswallet/MarketDataDeserializerTest.kt @@ -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(str, MarketData::class.java) + Assert.assertEquals(0.03849350092032233, marketData.current_price["usd"]) + Assert.assertEquals(0.033804376612212375, marketData.current_price["eur"]) + } +} \ No newline at end of file