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 5d79aaf..2078a07 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt @@ -23,6 +23,7 @@ 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 @@ -67,6 +68,7 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { 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 @@ -115,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) diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt index 56ca0bd..314c18a 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt @@ -5,7 +5,6 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import cy.agorise.bitsybitshareswallet.database.entities.EquivalentValue -import cy.agorise.bitsybitshareswallet.database.entities.Transfer @Dao interface EquivalentValueDao { 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 2ef9c7e..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 @@ -3,6 +3,7 @@ package cy.agorise.bitsybitshareswallet.database.daos import androidx.lifecycle.LiveData import androidx.room.* import cy.agorise.bitsybitshareswallet.database.entities.Transfer +import io.reactivex.Observable import io.reactivex.Single @Dao @@ -32,8 +33,8 @@ interface TransferDao { @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)") - fun getTransfersWithMissingValueIn(symbol: String): 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() 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/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 a0918e6..b31e5b3 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt @@ -2,19 +2,32 @@ 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) { @@ -41,10 +54,6 @@ class TransferRepository internal constructor(context: Context) { return mTransferDao.getTransferBlockNumberWithMissingTime() } - fun getTransfersWithMissingValueIn(symbol: String): LiveData> { - return mTransferDao.getTransfersWithMissingValueIn(symbol) - } - fun getTransfersWithMissingBtsValue(): LiveData { return mTransferDao.getTransfersWithMissingBtsValue() } @@ -53,6 +62,63 @@ class TransferRepository internal constructor(context: Context) { 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>() { @@ -79,4 +145,15 @@ 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 + */ + 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 592b280..9e035f7 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 07d90f8..3d5ce22 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt @@ -1,7 +1,6 @@ package cy.agorise.bitsybitshareswallet.viewmodels import android.app.Application -import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import com.google.common.primitives.UnsignedLong @@ -27,10 +26,6 @@ class TransferViewModel(application: Application) : AndroidViewModel(application return mTransferRepository.getTransferBlockNumberWithMissingTime() } - fun getTransfersWithMissingValueIn(symbol: String) : LiveData>{ - return mTransferRepository.getTransfersWithMissingValueIn(symbol) - } - fun getTransfersWithMissingBtsValue() : LiveData { return mTransferRepository.getTransfersWithMissingBtsValue() } 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