package cy.agorise.bitsybitshareswallet.activities import android.content.pm.PackageManager import android.os.AsyncTask import android.os.Bundle import android.os.Handler import android.util.Log import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import androidx.core.content.pm.PackageInfoCompat import androidx.preference.PreferenceManager 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 } // TODO consolidate ViewModels private val userAccountViewModel: UserAccountViewModel by viewModels() private val balanceViewModel: BalanceViewModel by viewModels() private val transferViewModel: TransferViewModel by viewModels() private val connectedActivityViewModel: ConnectedActivityViewModel by viewModels() 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() private var missingAssets = ArrayList() /* Network service connection */ protected var mNetworkService: NetworkService? = NetworkService.getInstance() // Map used to keep track of request and response id pairs private val responseMap = HashMap() /** Map used to keep track of request id and block number pairs */ private val requestIdToBlockNumberMap = HashMap() 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 val currencyCode = Helper.getCoingeckoSupportedCurrency(Locale.getDefault()) Log.d(TAG, "Using currency: ${currencyCode.toUpperCase(Locale.ROOT)}") connectedActivityViewModel.observeMissingEquivalentValuesIn(currencyCode) // Configure UserAccountViewModel to obtain the missing account ids userAccountViewModel.getMissingUserAccountIds().observe(this, { 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 balanceViewModel.getMissingAssetIds().observe(this, { 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 transferViewModel.getTransferBlockNumberWithMissingTime().observe(this, { 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 { connectedActivityViewModel.purgeEquivalentValues() PreferenceManager.getDefaultSharedPreferences(this).edit { putBoolean(Constants.KEY_HAS_PURGED_EQUIVALENT_VALUES, true) } } } } /** * 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) RESPONSE_GET_ACCOUNT_BALANCES -> handleBalanceUpdate(message.result as List) RESPONSE_GET_ASSETS -> handleAssets(message.result as List) 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) } 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 transferViewModel .getTransfersWithMissingBtsValue().observe(this, { 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) { val userAccounts = ArrayList() 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) } userAccountViewModel.insertAll(userAccounts) missingUserAccounts.clear() } private fun handleBalanceUpdate(assetAmountList: List) { val now = System.currentTimeMillis() / 1000 val balances = ArrayList() for (assetAmount in assetAmountList) { val balance = Balance( assetAmount.asset.objectId, assetAmount.amount.toLong(), now ) balances.add(balance) } balanceViewModel.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) { val assets = ArrayList() 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 { dateFormat.parse(blockHeader.timestamp)?.let { date -> transferViewModel.setBlockTime(blockNumber, date.time / 1000) } } catch (e: ParseException) { Log.e(TAG, "ParseException. Msg: " + e.message) } } 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 { transferViewModel.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 { transferViewModel.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() userAccounts.add(mCurrentAccount!!.objectId) val id = mNetworkService!!.sendMessage( GetFullAccounts(userAccounts, false), GetFullAccounts.REQUIRED_API ) responseMap[id] = RESPONSE_GET_FULL_ACCOUNTS } } else { val msg = "NetworkService is null or is not connected. " + "mNetworkService: $mNetworkService" Log.w(TAG, msg) } 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 -> connectedActivityViewModel.updateNodeLatencies(nodes as List) } mHandler.removeCallbacks(mCheckMissingPaymentsTask) mHandler.removeCallbacks(mRequestMissingUserAccountsTask) mHandler.removeCallbacks(mRequestMissingAssetsTask) mHandler.removeCallbacks(mRequestBlockMissingTimeTask) } override fun onDestroy() { super.onDestroy() if (!mCompositeDisposable.isDisposed) mCompositeDisposable.dispose() } }