bitsy-wallet/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt

537 lines
22 KiB
Kotlin

package cy.agorise.bitsybitshareswallet.activities
import android.content.pm.PackageManager
import android.os.AsyncTask
import android.os.Bundle
import android.os.Handler
import android.preference.PreferenceManager
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.firebase.crashlytics.FirebaseCrashlytics
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.utils.Helper
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.BuildConfig
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.*
import cy.agorise.graphenej.network.FullNode
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
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
import kotlin.concurrent.thread
/**
* The app uses the single Activity methodology, but this activity was created so that MainActivity can extend from it.
* This class manages everything related to keeping the information in the database updated using graphenej's
* NetworkService, leaving to MainActivity only the Navigation work and some other UI features.
*/
abstract class ConnectedActivity : AppCompatActivity() {
companion object {
private const val TAG = "ConnectedActivity"
// Delay between best node connection verifications
private const val NODE_CHECK_DELAY = 60000L // 60 seconds
// Worst acceptable position of the node the app is currently connected to
private const val BEST_NODE_THRESHOLD = 1
private const val RESPONSE_GET_FULL_ACCOUNTS = 1
private const val RESPONSE_GET_ACCOUNTS = 2
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
/* Current user account */
protected var mCurrentAccount: UserAccount? = null
private val mHandler = Handler()
// Composite disposable used to clear all disposables once the activity is destroyed
private val mCompositeDisposable = CompositeDisposable()
private var storedOpCount: Long = -1
private var missingUserAccounts = ArrayList<UserAccount>()
private var missingAssets = ArrayList<Asset>()
/* Network service connection */
protected var mNetworkService: NetworkService? = NetworkService.getInstance()
// Map used to keep track of request and response id pairs
private val responseMap = HashMap<Long, Int>()
/** Map used to keep track of request id and block number pairs */
private val requestIdToBlockNumberMap = HashMap<Long, Long>()
private var blockNumberWithMissingTime = 0L
// 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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
getUserAccount()
mAssetRepository = AssetRepository(this)
// Configure ConnectedActivityViewModel to obtain missing equivalent values
mConnectedActivityViewModel = ViewModelProviders.of(this).get(ConnectedActivityViewModel::class.java)
val currencyCode = Helper.getCoingeckoSupportedCurrency(Locale.getDefault())
Log.d(TAG, "Using currency: ${currencyCode.toUpperCase(Locale.ROOT)}")
mConnectedActivityViewModel.observeMissingEquivalentValuesIn(currencyCode)
// Configure UserAccountViewModel to obtain the missing account ids
mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java)
mUserAccountViewModel.getMissingUserAccountIds().observe(this, Observer<List<String>>{ userAccountIds ->
if (userAccountIds.isNotEmpty()) {
missingUserAccounts.clear()
for (userAccountId in userAccountIds)
missingUserAccounts.add(UserAccount(userAccountId))
mHandler.postDelayed(mRequestMissingUserAccountsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
})
// Configure UserAccountViewModel to obtain the missing account ids
mBalanceViewModel = ViewModelProviders.of(this).get(BalanceViewModel::class.java)
mBalanceViewModel.getMissingAssetIds().observe(this, Observer<List<String>>{ assetIds ->
if (assetIds.isNotEmpty()) {
missingAssets.clear()
for (assetId in assetIds)
missingAssets.add(Asset(assetId))
mHandler.postDelayed(mRequestMissingAssetsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
})
//Configure TransferViewModel to obtain the Transfer's block numbers with missing time information, one by one
mTransferViewModel = ViewModelProviders.of(this).get(TransferViewModel::class.java)
mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer<Long>{ blockNumber ->
if (blockNumber != null && blockNumber != blockNumberWithMissingTime) {
blockNumberWithMissingTime = blockNumber
mHandler.post(mRequestBlockMissingTimeTask)
}
})
val disposable = RxBus.getBusInstance()
.asFlowable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
this.handleIncomingMessage(it)
}, {
this.handleError(it)
})
mCompositeDisposable.add(disposable)
val info = this.packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES)
val versionCode = PackageInfoCompat.getLongVersionCode(info)
val hasPurgedEquivalentValues = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(Constants.KEY_HAS_PURGED_EQUIVALENT_VALUES, false)
if(versionCode > 11 && !hasPurgedEquivalentValues) {
thread {
mConnectedActivityViewModel.purgeEquivalentValues()
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putBoolean(Constants.KEY_HAS_PURGED_EQUIVALENT_VALUES, true)
.apply()
}
}
}
/**
* Obtains the userId from the shared preferences and creates a [UserAccount] instance.
* Created as a public function, so that it can be called from its Fragments.
*/
fun getUserAccount() {
val userId = PreferenceManager.getDefaultSharedPreferences(this)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") ?: ""
if (userId != "")
mCurrentAccount = UserAccount(userId)
// Make sure crashlytics reports contains the account ID
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.setCustomKey(Constants.CRASHLYTICS_KEY_ACCOUNT_ID, userId)
}
/**
* Error consumer used to handle potential errors caused by the NetworkService while processing
* incoming data.
*/
private fun handleError(throwable: Throwable){
Log.e(TAG, "Error while processing received message. Msg: " + throwable.message)
val stack = throwable.stackTrace
for (e in stack) {
Log.e(TAG, String.format("%s#%s:%d", e.className, e.methodName, e.lineNumber))
}
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.log("E/$TAG: ConnectedActivity reporting error. Msg: ${throwable.message}")
}
private fun handleIncomingMessage(message: Any?) {
if (message is JsonRpcResponse<*>) {
if (message.error == null) {
if (responseMap.containsKey(message.id)) {
when (responseMap[message.id]) {
RESPONSE_GET_FULL_ACCOUNTS ->
handleAccountDetails((message.result as List<*>)[0] as FullAccountDetails)
RESPONSE_GET_ACCOUNTS ->
handleAccountProperties(message.result as List<AccountProperties>)
RESPONSE_GET_ACCOUNT_BALANCES ->
handleBalanceUpdate(message.result as List<AssetAmount>)
RESPONSE_GET_ASSETS ->
handleAssets(message.result as List<Asset>)
RESPONSE_GET_BLOCK_HEADER -> {
val blockNumber = requestIdToBlockNumberMap[message.id] ?: 0L
handleBlockHeader(message.result as BlockHeader, blockNumber)
requestIdToBlockNumberMap.remove(message.id)
}
RESPONSE_GET_MARKET_HISTORY -> handleMarketData(message.result as List<BucketObject>)
}
responseMap.remove(message.id)
}
} else {
// In case of error
Log.e(TAG, "Got error message from full node. Msg: " + message.error.message)
Toast.makeText(
this@ConnectedActivity,
String.format("Error from full node. Msg: %s", message.error.message),
Toast.LENGTH_LONG
).show()
}
} else if (message is ConnectionStatusUpdate) {
if (message.updateCode == ConnectionStatusUpdate.CONNECTED) {
// Make sure the Crashlytics report contains the currently selected node
val selectedNodeUrl = mNetworkService?.selectedNode?.url ?: ""
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.setCustomKey(Constants.CRASHLYTICS_KEY_CURRENT_NODE, selectedNodeUrl)
} else if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) {
// If we got a disconnection notification, we should clear our response map, since
// all its stored request ids will now be reset
responseMap.clear()
} else if (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.
* @param accountDetails De-serialized account details object
*/
private fun handleAccountDetails(accountDetails: FullAccountDetails) {
val latestOpCount = accountDetails.statistics.total_ops
Log.d(TAG, "handleAccountDetails. prev count: $storedOpCount, current count: $latestOpCount")
if (latestOpCount == 0L) {
Log.d(TAG, "The node returned 0 total_ops for current account and may not have installed the history plugin. " +
"\nAsk the NetworkService to remove the node from the list and connect to another one.")
mNetworkService?.reconnectNode()
} else if (storedOpCount == -1L) {
// Initial case when the app starts
storedOpCount = latestOpCount
PreferenceManager.getDefaultSharedPreferences(this)
.edit().putLong(Constants.KEY_ACCOUNT_OPERATION_COUNT, latestOpCount).apply()
TransfersLoader(this)
updateBalances()
} else if (latestOpCount > storedOpCount) {
storedOpCount = latestOpCount
TransfersLoader(this)
updateBalances()
}
}
/**
* Receives a list of missing [AccountProperties] from which it extracts the required information to
* create a list of BiTSy's UserAccount objects and stores them into the database
*/
private fun handleAccountProperties(accountPropertiesList: List<AccountProperties>) {
val userAccounts = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.UserAccount>()
for (accountProperties in accountPropertiesList) {
val userAccount = cy.agorise.bitsybitshareswallet.database.entities.UserAccount(
accountProperties.id,
accountProperties.name,
accountProperties.membership_expiration_date == Constants.LIFETIME_EXPIRATION_DATE
)
userAccounts.add(userAccount)
}
mUserAccountViewModel.insertAll(userAccounts)
missingUserAccounts.clear()
}
private fun handleBalanceUpdate(assetAmountList: List<AssetAmount>) {
val now = System.currentTimeMillis() / 1000
val balances = ArrayList<Balance>()
for (assetAmount in assetAmountList) {
val balance = Balance(
assetAmount.asset.objectId,
assetAmount.amount.toLong(),
now
)
balances.add(balance)
}
mBalanceViewModel.insertAll(balances)
}
/**
* Receives a list of missing [Asset] from which it extracts the required information to
* create a list of BiTSy's Asset objects and stores them into the database
*/
private fun handleAssets(_assets: List<Asset>) {
val assets = ArrayList<cy.agorise.bitsybitshareswallet.database.entities.Asset>()
for (_asset in _assets) {
val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset(
_asset.objectId,
_asset.symbol,
_asset.precision,
_asset.description ?: "",
_asset.issuer ?: ""
)
assets.add(asset)
}
mAssetRepository.insertAll(assets)
missingAssets.clear()
}
/**
* Receives the [BlockHeader] related to a Transfer's missing time and saves it into the database.
*/
private fun handleBlockHeader(blockHeader: BlockHeader, blockNumber: Long) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("GMT")
try {
val date = dateFormat.parse(blockHeader.timestamp)
mTransferViewModel.setBlockTime(blockNumber, date.time / 1000)
} catch (e: ParseException) {
Log.e(TAG, "ParseException. Msg: " + e.message)
}
}
private fun 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() {
if (mNetworkService?.isConnected == true) {
val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()),
GetAccountBalances.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ACCOUNT_BALANCES
}
}
/**
* Task used to verify that the app is currently connected to one of the best nodes,
* and ask for a reconnection if it is not the case.
*/
private val verifyConnectionToSuitableNodeTask = object : Runnable {
override fun run() {
Log.d(TAG, "Verifying app is connected to one of the best nodes")
mNetworkService?.nodes?.let { nodes ->
for ((counter, node) in nodes.withIndex()) {
if (counter >= BEST_NODE_THRESHOLD) {
// Forcing reconnection to a better node
mNetworkService?.reconnectNode()
break
}
if (node.isConnected) {
// App is connected to one of the best nodes
break
}
}
}
mHandler.postDelayed(this, NODE_CHECK_DELAY)
}
}
/**
* Task used to obtain the missing UserAccounts from Graphenej's NetworkService.
*/
private val mRequestMissingUserAccountsTask = object : Runnable {
override fun run() {
if (mNetworkService?.isConnected == true) {
val id = mNetworkService!!.sendMessage(GetAccounts(missingUserAccounts), GetAccounts.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ACCOUNTS
} else if (missingUserAccounts.isNotEmpty()){
mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
}
}
/**
* Task used to obtain the missing Assets from Graphenej's NetworkService.
*/
private val mRequestMissingAssetsTask = object : Runnable {
override fun run() {
if (mNetworkService?.isConnected == true) {
val id = mNetworkService!!.sendMessage(GetAssets(missingAssets), GetAssets.REQUIRED_API)
responseMap[id] = RESPONSE_GET_ASSETS
} else if (missingAssets.isNotEmpty()){
mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD)
}
}
}
/**
* Task used to perform a redundant payment check.
*/
private val mCheckMissingPaymentsTask = object : Runnable {
override fun run() {
if (mNetworkService?.isConnected == true) {
if (mCurrentAccount != null) {
val userAccounts = ArrayList<String>()
userAccounts.add(mCurrentAccount!!.objectId)
val id = mNetworkService!!.sendMessage(GetFullAccounts(userAccounts, false),
GetFullAccounts.REQUIRED_API)
responseMap[id] = RESPONSE_GET_FULL_ACCOUNTS
}
} else {
Log.w(TAG, "NetworkService is null or is not connected. mNetworkService: $mNetworkService")
}
mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD)
}
}
/**
* Task used to obtain the missing time from a block from Graphenej's NetworkService.
*/
private val mRequestBlockMissingTimeTask = object : Runnable {
override fun run() {
if (mNetworkService?.isConnected == true) {
val id = mNetworkService!!.sendMessage(GetBlockHeader(blockNumberWithMissingTime),
GetBlockHeader.REQUIRED_API)
responseMap[id] = RESPONSE_GET_BLOCK_HEADER
requestIdToBlockNumberMap[id] = blockNumberWithMissingTime
} else {
mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD)
}
}
}
override fun onResume() {
super.onResume()
mHandler.postDelayed(mCheckMissingPaymentsTask, Constants.MISSING_PAYMENT_CHECK_PERIOD)
mHandler.postDelayed(verifyConnectionToSuitableNodeTask, NODE_CHECK_DELAY)
}
override fun onPause() {
super.onPause()
mNetworkService?.nodeLatencyVerifier?.nodeList?.let { nodes ->
mConnectedActivityViewModel.updateNodeLatencies(nodes as List<FullNode>)
}
mHandler.removeCallbacks(mCheckMissingPaymentsTask)
mHandler.removeCallbacks(mRequestMissingUserAccountsTask)
mHandler.removeCallbacks(mRequestMissingAssetsTask)
mHandler.removeCallbacks(mRequestBlockMissingTimeTask)
}
override fun onDestroy() {
super.onDestroy()
if(!mCompositeDisposable.isDisposed) mCompositeDisposable.dispose()
}
}