Skip to content

Commit

Permalink
Reduce the amount of calls to exchange APIs to cut down on rate limit…
Browse files Browse the repository at this point in the history
…ing.

Add a new indicator for when an exchange has rate limited a widget.
Space out updates for widgets when refreshing to avoid rate limiting.
Renamed BitClude to Egera.
Fixed a bug where widget prices were not updating in certain scenarios.
Stop using Coingecko as the default for newly created widgets.
  • Loading branch information
hwki committed Jan 14, 2024
1 parent b3490ba commit f13d15e
Show file tree
Hide file tree
Showing 29 changed files with 248 additions and 190 deletions.
2 changes: 1 addition & 1 deletion .idea/kotlinc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions bitcoin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
id 'kotlin-android'
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.20'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22'
}

android {
Expand All @@ -13,8 +13,8 @@ android {
applicationId "com.brentpanther.bitcoinwidget"
minSdk 23
targetSdk 34
versionCode 324
versionName "8.4.5"
versionCode 325
versionName "8.5.0"

}

Expand Down Expand Up @@ -65,17 +65,17 @@ dependencies {
implementation platform('androidx.compose:compose-bom:2023.10.01')

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.1"
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.activity:activity-ktx:1.8.1'
implementation 'androidx.activity:activity-compose:1.8.1'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-tooling-preview"
implementation 'androidx.compose.material:material'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
implementation 'androidx.navigation:navigation-compose:2.7.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
implementation 'androidx.navigation:navigation-compose:2.7.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'io.coil-kt:coil-compose:2.5.0'
implementation 'androidx.core:core-ktx:1.12.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ enum class NightMode {
}

enum class WidgetState {
DRAFT, CURRENT, STALE, ERROR
DRAFT, CURRENT, STALE, RATE_LIMITED, ERROR
}

enum class WidgetType(@StringRes val widgetName: Int, @StringRes val widgetSummary: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,25 +90,25 @@ object Repository {
}
}

fun getExchangeData(widget: Widget): ExchangeData {
fun getExchangeData(coin: Coin, coinName: String?): ExchangeData {
val context = WidgetApplication.instance
return try {
if (widget.coin == Coin.CUSTOM) {
CustomExchangeData(widget.coinName(), widget.coin, getJson(context))
if (coin == Coin.CUSTOM) {
CustomExchangeData(coinName ?: coin.coinName, coin, getJson(context))
} else {
val data = ExchangeData(widget.coin, getJson(context))
val data = ExchangeData(coin, getJson(context))
if (data.numberExchanges == 0) {
throw SerializationException("No exchanges found.")
}
data
}
} catch(e: SerializationException) {
Log.e("SettingsViewModel", "Error parsing JSON file, falling back to original.", e)
Log.e(TAG, "Error parsing JSON file, falling back to original.", e)
context.deleteFile(CURRENCY_FILE_NAME)
PreferenceManager.getDefaultSharedPreferences(context).edit {
remove(LAST_MODIFIED)
}
ExchangeData(widget.coin, getJson(context))
ExchangeData(coin, getJson(context))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class WidgetApplication : Application() {
super.onCreate()
instance = this
registerReceiver(WidgetBroadcastReceiver(), IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED))
WidgetUpdater.updateDisplays(this)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,9 @@ open class WidgetProvider : AppWidgetProvider() {
}
}

override fun onEnabled(context: Context) {
refreshWidgets(context)
}

override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, widgetIds: IntArray) {
refreshWidgets(context)
}

override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager,
appWidgetId: Int, newOptions: Bundle) {
refreshWidgets(context)
refreshWidgets(context, appWidgetId)
}

override fun onDeleted(context: Context, widgetIds: IntArray) {
Expand All @@ -53,7 +45,7 @@ open class WidgetProvider : AppWidgetProvider() {
workManager.cancelUniqueWork(ONETIMEWORKNAME)
cancelWork(workManager)
} else if (widgetDao.configWithSizes().consistentSize) {
refreshWidgets(context)
WidgetUpdater.updateDisplays(context)
}
}
}
Expand All @@ -63,37 +55,42 @@ open class WidgetProvider : AppWidgetProvider() {
private const val WORKNAME = "widgetRefresh"
const val ONETIMEWORKNAME = "115575872"

fun refreshWidgets(context: Context, restart: Boolean = false) = CoroutineScope(Dispatchers.IO).launch {
fun refreshWidgets(context: Context, widgetId: Int) = refreshWidgets(context, intArrayOf(widgetId))

fun refreshWidgets(context: Context, widgetIds: IntArray? = null, restart: Boolean = false) = CoroutineScope(Dispatchers.IO).launch {
val dao = WidgetDatabase.getInstance(context).widgetDao()
val widgetIds = dao.getAll().map { it.widgetId }.toIntArray()
if (widgetIds.isEmpty()) return@launch
val widgetIdsToRefresh = widgetIds ?: dao.getAll().map { it.widgetId }.toIntArray()
if (widgetIdsToRefresh.isEmpty()) return@launch

WidgetUpdater.update(context, widgetIds, false)
WidgetUpdater.update(context, widgetIdsToRefresh, false)
val workManager = WorkManager.getInstance(context)
val refresh = dao.configWithSizes().refresh

// https://issuetracker.google.com/issues/115575872
val immediateWork = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().setInitialDelay(3650L, TimeUnit.DAYS).build()
val immediateWork = OneTimeWorkRequestBuilder<WidgetUpdateWorker>()
.setInitialDelay(3650L, TimeUnit.DAYS).build()
workManager.enqueueUniqueWork(ONETIMEWORKNAME, ExistingWorkPolicy.KEEP, immediateWork)

val workPolicy = if (restart) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.KEEP
if (restart) {
workManager.cancelAllWorkByTag(WORKNAME)
}
when (refresh) {
5 -> (0..10 step 5).forEachIndexed { i, it -> scheduleWork(workManager, 15, it, i, workPolicy) }
10 -> (0..10 step 10).forEachIndexed { i, it -> scheduleWork(workManager, 20, it, i, workPolicy) }
else -> scheduleWork(workManager, refresh, 0, 0, workPolicy)
5 -> (5..15 step 5).forEachIndexed { i, it -> scheduleWork(workManager, 15, it, i) }
10 -> (10..20 step 10).forEachIndexed { i, it -> scheduleWork(workManager, 20, it, i) }
else -> scheduleWork(workManager, refresh, refresh, 0)
}
}

fun cancelWork(workManager: WorkManager) = workManager.cancelAllWorkByTag(WORKNAME)

private fun scheduleWork(workManager: WorkManager, refresh: Int, delay: Int, index: Int, policy: ExistingPeriodicWorkPolicy) {
private fun scheduleWork(workManager: WorkManager, refresh: Int, delay: Int, index: Int) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val work = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(refresh.toLong(), TimeUnit.MINUTES)
.setConstraints(constraints)
.addTag(WORKNAME)
.setInitialDelay(delay.toLong(), TimeUnit.MINUTES)
.build()
workManager.enqueueUniquePeriodicWork("$WORKNAME$index", policy, work)
workManager.enqueueUniquePeriodicWork("$WORKNAME$index", ExistingPeriodicWorkPolicy.KEEP, work)
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,56 @@
package com.brentpanther.bitcoinwidget

import android.content.Context
import com.brentpanther.bitcoinwidget.db.WidgetDatabase
import com.brentpanther.bitcoinwidget.strategy.data.WidgetDataStrategy
import com.brentpanther.bitcoinwidget.strategy.display.WidgetDisplayStrategy
import com.brentpanther.bitcoinwidget.strategy.presenter.RemoteWidgetPresenter
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds

object WidgetUpdater {

suspend fun update(context: Context, widgetIds: IntArray, manual: Boolean) = coroutineScope {

fun update(context: Context, widgetIds: IntArray, manual: Boolean) = CoroutineScope(Dispatchers.IO).launch {
val dataStrategies = widgetIds.map { WidgetDataStrategy.getStrategy(it) }

// update display immediately to avoid looking bad
for (strategy in dataStrategies) {
val widget = strategy.widget ?: continue
dataStrategies.forEachIndexed { index, strategy ->
val widget = strategy.widget ?: return@forEachIndexed
val widgetPresenter = RemoteWidgetPresenter(context, widget)
val displayStrategy = WidgetDisplayStrategy.getStrategy(context, widget, widgetPresenter)
displayStrategy.refresh()
}

// update data
for (strategy in dataStrategies) {
strategy.loadData(manual)
val success = strategy.loadData(manual)
strategy.save()
if (success) {
val displayStrategy = WidgetDisplayStrategy.getStrategy(context, widget, widgetPresenter)
displayStrategy.refresh()
displayStrategy.save()
// wait before refreshing the next widget to avoid rate limiting issues
if (index != dataStrategies.lastIndex) {
delay(20.seconds)
}
}
}

// data may cause display to need refreshed
for (strategy in dataStrategies) {
val widget = strategy.widget ?: continue
val widgetPresenter = RemoteWidgetPresenter(context, widget)
val displayStrategy = WidgetDisplayStrategy.getStrategy(context, widget, widgetPresenter)
displayStrategy.refresh()
displayStrategy.save()
updateDisplays(context)
}

fun updateDisplays(context: Context) = CoroutineScope(Dispatchers.IO).launch {
// change of data for different widgets may cause us to need to refresh all widgets
val dao = WidgetDatabase.getInstance(context).widgetDao()
val widgetIds = dao.getAll().map { it.widgetId }.toIntArray()
val dataStrategies = widgetIds.map { WidgetDataStrategy.getStrategy(it) }
(1..2).forEachIndexed { _, _ ->
// refresh twice in case the size changed on the last widget
dataStrategies.forEach { strategy ->
val widget = strategy.widget ?: return@forEach
val widgetPresenter = RemoteWidgetPresenter(context, widget)
val displayStrategy = WidgetDisplayStrategy.getStrategy(context, widget, widgetPresenter)
displayStrategy.refresh()
displayStrategy.save()
}
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ object DataMigration {
migrateOkexToOkx(db)
migrateBithumbProToBitGlobal(db)
migrateKaspaToKas(db)
migrateBitcludeToEgera(db)
fixRemovedExchanges(db)
}

Expand All @@ -32,6 +33,10 @@ object DataMigration {
db.execSQL("UPDATE Widget SET exchange = 'ZONDA' WHERE exchange = 'BITBAY'")
}

private fun migrateBitcludeToEgera(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE Widget SET exchange = 'EGERA' WHERE exchange = 'BITCLUDE'")
}

private fun fixRemovedExchanges(db: SupportSQLiteDatabase) {
val cursor = db.query("SELECT id, exchange FROM Widget ORDER BY id")
val allExchanges = Exchange.entries.map { it.name }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,6 @@ enum class Exchange(val exchangeName: String, shortName: String? = null) {
return getJsonObject(url)["data"]?.jsonObject?.get("last").asString
}
},
BITCLUDE("BitClude") {

override fun getValue(coin: String, currency: String): String? {
val url = "https://api.bitclude.com/stats/ticker.json"
val obj = getJsonObject(url)
return obj["${coin.lowercase()}_${currency.lowercase()}"]?.jsonObject?.get("last").asString
}
},
BITCOINDE("Bitcoin.de") {

override fun getValue(coin: String, currency: String): String? {
Expand Down Expand Up @@ -310,6 +302,13 @@ enum class Exchange(val exchangeName: String, shortName: String? = null) {
return getJsonObject(url)["ticker"]?.jsonArray?.get(0)?.jsonObject?.get("last").asString
}
},
EGERA("Egera") {
override fun getValue(coin: String, currency: String): String? {
val url = "https://api.egera.com/stats/ticker.json"
val obj = getJsonObject(url)
return obj["${coin.lowercase()}_${currency.lowercase()}"]?.jsonObject?.get("last").asString
}
},
EXMO("Exmo") {

override fun getValue(coin: String, currency: String): String? {
Expand Down Expand Up @@ -433,19 +432,6 @@ enum class Exchange(val exchangeName: String, shortName: String? = null) {
?.jsonObject?.get("latest").asString
}
},
LIQUID("Liquid") {
override fun getValue(coin: String, currency: String): String? {
val array = getJsonArray("https://api.liquid.com/products")
val pair = "$coin$currency"
for (jsonElement in array) {
val obj = jsonElement as JsonObject
if (pair == obj["currency_pair_code"].asString) {
return obj["last_traded_price"].asString
}
}
return null
}
},
LUNO("Luno") {

override fun getValue(coin: String, currency: String): String? {
Expand Down Expand Up @@ -525,18 +511,6 @@ enum class Exchange(val exchangeName: String, shortName: String? = null) {
return (value.toDouble() / 10.0.pow(8)).toString()
}
},
POCKETBITS("Pocketbits") {

override fun getValue(coin: String, currency: String): String? {
val url = "https://ticker.pocketbits.in/api/v1/ticker"
val obj = getJsonArray(url).firstOrNull {
it.jsonObject["symbol"].asString == "$coin$currency"
}?.jsonObject ?: return null
val buy = obj["buy"].asString?.toDoubleOrNull() ?: return null
val sell = obj["sell"].asString?.toDoubleOrNull() ?: return null
return ((buy + sell) / 2).toString()
}
},
POLONIEX("Poloniex") {

override fun getValue(coin: String, currency: String): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import java.io.InputStream
import java.util.*
import java.util.Currency


open class ExchangeData(val coin: Coin, json: InputStream) {
Expand Down Expand Up @@ -93,10 +93,7 @@ open class ExchangeData(val coin: Coin, json: InputStream) {
//TODO: change to use exchange
fun getDefaultExchange(currency: String): String {
val exchanges = currencyExchange[currency]
exchanges?.let {
if (!exchanges.contains(Exchange.COINGECKO.name)) return exchanges[0]
}
return Exchange.COINGECKO.name
return exchanges?.get(0) ?: Exchange.COINGECKO.name
}

open fun getExchangeCoinName(exchange: String): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ object ExchangeHelper {
builder = builder.headers(it)
}
val request = builder.build()
client.newCall(request).execute()
val response = client.newCall(request).execute()
if (response.code == 429) {
throw RateLimitedException()
}
return response
} catch (e: IllegalArgumentException) {
null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.brentpanther.bitcoinwidget.exchange

class RateLimitedException : Exception() {
}
Loading

0 comments on commit f13d15e

Please sign in to comment.