diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5438220..bd2bb7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.example.mempal" minSdk = 24 targetSdk = 34 - versionCode = 9 - versionName = "1.4.1" + versionCode = 10 + versionName = "1.4.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true } @@ -101,6 +101,7 @@ dependencies { implementation(libs.tor.android) implementation(libs.jtorctl) + implementation(libs.androidx.localbroadcastmanager) // WorkManager implementation("androidx.work:work-runtime-ktx:2.9.0") diff --git a/app/src/main/java/com/example/mempal/api/NetworkClient.kt b/app/src/main/java/com/example/mempal/api/NetworkClient.kt index f1245cc..81a316f 100644 --- a/app/src/main/java/com/example/mempal/api/NetworkClient.kt +++ b/app/src/main/java/com/example/mempal/api/NetworkClient.kt @@ -20,9 +20,9 @@ import java.lang.ref.WeakReference import java.util.concurrent.TimeUnit object NetworkClient { - private const val TIMEOUT_SECONDS = 25L - private const val TEST_TIMEOUT_SECONDS = 15L - private const val ONION_TEST_TIMEOUT_SECONDS = 45L + private const val TIMEOUT_SECONDS = 30L + private const val TEST_TIMEOUT_SECONDS = 20L + private const val ONION_TEST_TIMEOUT_SECONDS = 60L private var retrofit: Retrofit? = null private var contextRef: WeakReference? = null private val _isInitialized = MutableStateFlow(false) @@ -31,15 +31,33 @@ object NetworkClient { private var connectivityManager: ConnectivityManager? = null private val _isNetworkAvailable = MutableStateFlow(false) val isNetworkAvailable: StateFlow = _isNetworkAvailable + private var lastInitAttempt = 0L + private val minInitRetryDelay = 2000L private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) coroutineScope?.launch { _isNetworkAvailable.value = true - // Always try to reinitialize when network becomes available - setupRetrofit(TorManager.getInstance().torStatus.value == TorStatus.CONNECTED) - _isInitialized.value = true + + // Add delay before reinitializing to prevent rapid retries + val now = System.currentTimeMillis() + if (now - lastInitAttempt < minInitRetryDelay) { + delay(minInitRetryDelay) + } + lastInitAttempt = now + + // Check Tor status before initializing + val torManager = TorManager.getInstance() + if (torManager.isTorEnabled()) { + if (torManager.torStatus.value == TorStatus.CONNECTED) { + setupRetrofit(true) + _isInitialized.value = true + } + } else { + setupRetrofit(false) + _isInitialized.value = true + } } } @@ -72,6 +90,7 @@ object NetworkClient { // Check initial network state _isNetworkAvailable.value = isNetworkCurrentlyAvailable() + lastInitAttempt = System.currentTimeMillis() // Check if current API URL is an onion address and manage Tor accordingly val settingsRepository = SettingsRepository.getInstance(context) @@ -92,11 +111,17 @@ object NetworkClient { torManager.torStatus.collect { status -> println("Tor status changed: $status") if (status == TorStatus.CONNECTED || status == TorStatus.DISCONNECTED) { - println("Setting up Retrofit with useProxy=${status == TorStatus.CONNECTED}") + // Add delay before reinitializing to prevent rapid retries + val now = System.currentTimeMillis() + if (now - lastInitAttempt < minInitRetryDelay) { + delay(minInitRetryDelay) + } + lastInitAttempt = now + if (isNetworkCurrentlyAvailable()) { + println("Setting up Retrofit with useProxy=${status == TorStatus.CONNECTED}") setupRetrofit(status == TorStatus.CONNECTED) _isInitialized.value = true - println("NetworkClient initialization complete") } else { _isInitialized.value = false } @@ -139,6 +164,16 @@ object NetworkClient { .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .retryOnConnectionFailure(true) + .addInterceptor { chain -> + var attempt = 0 + var response = chain.proceed(chain.request()) + while (!response.isSuccessful && attempt < 3) { + attempt++ + response.close() + response = chain.proceed(chain.request()) + } + response + } if (useProxy && baseUrl.contains(".onion")) { println("Setting up Tor proxy") diff --git a/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt b/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt index 7a9f5b1..d9a7873 100644 --- a/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt +++ b/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt @@ -1,62 +1,93 @@ -package com.example.mempal.api - -import android.content.Context -import com.example.mempal.repository.SettingsRepository -import com.google.gson.GsonBuilder -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit - -object WidgetNetworkClient { - private const val TIMEOUT_SECONDS = 10L - private const val DEFAULT_API_URL = "https://mempool.space" - - private val loggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - fun getMempoolApi(context: Context): MempoolApi { - val settingsRepository = SettingsRepository.getInstance(context) - val userApiUrl = settingsRepository.getApiUrl() - - // If the user's custom server is a .onion address, use the default mempool.space - val baseUrl = if (userApiUrl.contains(".onion")) { - DEFAULT_API_URL - } else { - userApiUrl - }.let { url -> - if (!url.endsWith("/")) "$url/" else url - } - - val clientBuilder = OkHttpClient.Builder() - .addInterceptor(loggingInterceptor) - .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) - .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .addInterceptor { chain -> - var attempt = 0 - var response = chain.proceed(chain.request()) - while (!response.isSuccessful && attempt < 2) { - attempt++ - response.close() - response = chain.proceed(chain.request()) - } - response - } - - val gson = GsonBuilder() - .setLenient() - .create() - - val retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .client(clientBuilder.build()) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - - return retrofit.create(MempoolApi::class.java) - } +package com.example.mempal.api + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.example.mempal.repository.SettingsRepository +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object WidgetNetworkClient { + private const val TIMEOUT_SECONDS = 10L + private const val DEFAULT_API_URL = "https://mempool.space" + private var retrofit: Retrofit? = null + private var mempoolApi: MempoolApi? = null + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + fun getMempoolApi(context: Context): MempoolApi { + // Check if we have a valid API instance + mempoolApi?.let { api -> + if (!hasUrlChanged(context)) { + return api + } + } + + // Create new API instance + return createMempoolApi(context).also { + mempoolApi = it + } + } + + private fun hasUrlChanged(context: Context): Boolean { + val settingsRepository = SettingsRepository.getInstance(context) + val currentUrl = settingsRepository.getApiUrl() + return retrofit?.baseUrl()?.toString()?.contains(currentUrl) != true + } + + private fun isNetworkAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + private fun createMempoolApi(context: Context): MempoolApi { + val settingsRepository = SettingsRepository.getInstance(context) + val userApiUrl = settingsRepository.getApiUrl() + + // If the user's custom server is a .onion address, use the default mempool.space + val baseUrl = if (userApiUrl.contains(".onion")) { + DEFAULT_API_URL + } else { + userApiUrl + }.let { url -> + if (!url.endsWith("/")) "$url/" else url + } + + val clientBuilder = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .addInterceptor { chain -> + var attempt = 0 + var response = chain.proceed(chain.request()) + while (!response.isSuccessful && attempt < 2) { + attempt++ + response.close() + response = chain.proceed(chain.request()) + } + response + } + + val gson = GsonBuilder() + .setLenient() + .create() + + retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(clientBuilder.build()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + return retrofit!!.create(MempoolApi::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/tor/TorManager.kt b/app/src/main/java/com/example/mempal/tor/TorManager.kt index f991e50..ac19e8c 100644 --- a/app/src/main/java/com/example/mempal/tor/TorManager.kt +++ b/app/src/main/java/com/example/mempal/tor/TorManager.kt @@ -34,11 +34,15 @@ class TorManager private constructor() { private val _torConnectionEvent = MutableSharedFlow() val torConnectionEvent: SharedFlow = _torConnectionEvent private var lastConnectionAttempt = 0L + private var lastFailureMessage = 0L private var connectionAttempts = 0 - private val maxConnectionAttempts = 5 - private val minRetryDelay = 2000L // 2 seconds - private val maxRetryDelay = 30000L // 30 seconds - private val initialConnectionTimeout = 5100L // Initial connection timeout + private val maxConnectionAttempts = 20 + private val minRetryDelay = 1500L + private val maxRetryDelay = 20000L + private val initialConnectionTimeout = 7000L + private val minTimeBetweenFailures = 30000L + private val maxInitialAttempts = 5 + private var isInInitialConnection = true companion object { @Volatile @@ -89,6 +93,9 @@ class TorManager private constructor() { try { shouldBeTorEnabled = true _torStatus.value = TorStatus.CONNECTING + connectionAttempts = 0 + lastFailureMessage = 0L + isInInitialConnection = true val intent = Intent(context, TorService::class.java).apply { action = ACTION_START @@ -99,44 +106,57 @@ class TorManager private constructor() { connectionJob?.cancel() connectionJob = scope?.launch { var currentDelay = minRetryDelay - connectionAttempts = 0 + var initialAttempts = 0 while (isActive) { + delay(if (connectionAttempts == 0) initialConnectionTimeout else currentDelay) + if (connectionAttempts > 0) { - delay(currentDelay) - // Exponential backoff with max delay cap - currentDelay = (currentDelay * 1.5).toLong().coerceAtMost(maxRetryDelay) - } else { - delay(initialConnectionTimeout) + currentDelay = (currentDelay * 1.2).toLong().coerceAtMost(maxRetryDelay) } try { withContext(Dispatchers.IO) { val socket = java.net.Socket() try { - socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 2000) + socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 5000) socket.close() _torStatus.value = TorStatus.CONNECTED _proxyReady.value = true emitConnectionEvent(true) - connectionAttempts = 0 // Reset attempts on success + connectionAttempts = 0 + initialAttempts = 0 lastConnectionAttempt = System.currentTimeMillis() + lastFailureMessage = 0L + isInInitialConnection = false return@withContext } catch (e: Exception) { socket.close() throw e } } - break // Connection successful + break } catch (_: Exception) { connectionAttempts++ - if (connectionAttempts >= maxConnectionAttempts) { - // Instead of moving to ERROR state, stay in CONNECTING - // This allows the UI to keep showing "Reconnecting to Tor network..." - connectionAttempts = 0 // Reset attempts to allow continuous retry - currentDelay = maxRetryDelay // Use max delay for subsequent attempts + + if (isInInitialConnection && connectionAttempts <= maxInitialAttempts) { + initialAttempts++ + _torStatus.value = TorStatus.CONNECTING + continue + } + + isInInitialConnection = false + val now = System.currentTimeMillis() + + if (connectionAttempts >= maxConnectionAttempts && + !isInInitialConnection && + (now - lastFailureMessage > minTimeBetweenFailures || lastFailureMessage == 0L)) { + connectionAttempts = maxConnectionAttempts / 2 emitConnectionEvent(false) + lastFailureMessage = now } + + _torStatus.value = TorStatus.CONNECTING } } } @@ -151,6 +171,7 @@ class TorManager private constructor() { fun stopTor(context: Context) { try { shouldBeTorEnabled = false + isInInitialConnection = true connectionJob?.cancel() connectionJob = null @@ -197,17 +218,15 @@ class TorManager private constructor() { fun checkAndRestoreTorConnection(context: Context) { if (!shouldBeTorEnabled) return - // Prevent rapid reconnection attempts val now = System.currentTimeMillis() - if (now - lastConnectionAttempt < minRetryDelay) return + if (now - lastConnectionAttempt < (minRetryDelay / 2)) return scope?.launch { try { - // First check if Tor is actually running val torRunning = withContext(Dispatchers.IO) { try { val socket = java.net.Socket() - socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 1000) + socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 500) socket.close() true } catch (_: Exception) { @@ -217,17 +236,21 @@ class TorManager private constructor() { if (!torRunning) { if (torStatus.value != TorStatus.CONNECTING) { - // Tor is not running, start it + connectionAttempts = 0 + lastFailureMessage = 0L startTor(context) } } else if (torStatus.value != TorStatus.CONNECTED) { - // Tor is running but our status doesn't reflect it _torStatus.value = TorStatus.CONNECTED _proxyReady.value = true emitConnectionEvent(true) + connectionAttempts = 0 + lastFailureMessage = 0L } } catch (_: Exception) { if (torStatus.value != TorStatus.CONNECTING) { + connectionAttempts = 0 + lastFailureMessage = 0L startTor(context) } } diff --git a/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt b/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt index dce26ac..b53f7ca 100644 --- a/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt @@ -68,7 +68,7 @@ class MainViewModel : ViewModel() { // Set initial state based on Tor status if using Tor if (TorManager.getInstance().isTorEnabled()) { val message = if (DashboardCache.hasCachedData()) { - "Reconnecting to Tor network..." + "Connecting to Tor network..." } else { "Connecting to Tor network..." } @@ -103,35 +103,27 @@ class MainViewModel : ViewModel() { } } - // Monitor Tor connection state + // Monitor Tor status changes viewModelScope.launch { TorManager.getInstance().torStatus.collect { status -> when (status) { - TorStatus.CONNECTED -> { - // When Tor connects, show loading and refresh data - _uiState.value = DashboardUiState.Loading - refreshDashboardData() - } TorStatus.CONNECTING -> { - // Show connecting message - val message = if (DashboardCache.hasCachedData()) { - "Reconnecting to Tor network..." - } else { - "Connecting to Tor network..." + if (DashboardCache.hasCachedData()) { + _uiState.value = DashboardUiState.Error( + message = "Connecting to Tor network...", + isReconnecting = true + ) } - _uiState.value = DashboardUiState.Error(message = message, isReconnecting = true) } - else -> { - if (TorManager.getInstance().isTorEnabled()) { - // Only show Tor-related messages if Tor is enabled - val message = if (DashboardCache.hasCachedData()) { - "Reconnecting to Tor network..." - } else { - "Connecting to Tor network..." - } - _uiState.value = DashboardUiState.Error(message = message, isReconnecting = true) + TorStatus.ERROR -> { + if (!NetworkClient.isInitialized.value) { + _uiState.value = DashboardUiState.Error( + message = "Connection failed. Check server settings.", + isReconnecting = false + ) } } + else -> { /* No action needed */ } } } } @@ -333,15 +325,21 @@ class MainViewModel : ViewModel() { _feeRates.value = cachedState.feeRates _mempoolInfo.value = cachedState.mempoolInfo - // Show appropriate message based on server type - val message = if (NetworkClient.isUsingOnion()) { - "Reconnecting to Tor network..." - } else { - "Fetching data..." + // Show appropriate message based on connection state + val message = when { + !NetworkClient.isNetworkAvailable.value -> "Waiting for network connection..." + NetworkClient.isUsingOnion() && TorManager.getInstance().torStatus.value == TorStatus.CONNECTING -> + "Connecting to Tor network..." + else -> "Connecting to server..." } _uiState.value = DashboardUiState.Error(message = message, isReconnecting = true) } else { - val message = "Connection failed. Check server settings." + val message = when { + !NetworkClient.isNetworkAvailable.value -> "No network connection" + NetworkClient.isUsingOnion() && TorManager.getInstance().torStatus.value != TorStatus.CONNECTED -> + "Tor connection failed" + else -> "Connection failed. Check server settings." + } _uiState.value = DashboardUiState.Error(message = message, isReconnecting = false) } _isMainRefreshing.value = false diff --git a/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt b/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt index 66eee53..2fb5ee4 100644 --- a/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt @@ -16,6 +16,7 @@ class BlockHeightWidget : AppWidgetProvider() { companion object { const val REFRESH_ACTION = "com.example.mempal.REFRESH_BLOCK_HEIGHT_WIDGET" private var widgetScope: CoroutineScope? = null + private var activeJobs = mutableMapOf() } private fun getOrCreateScope(): CoroutineScope { @@ -32,13 +33,17 @@ class BlockHeightWidget : AppWidgetProvider() { super.onDisabled(context) // Only cancel updates if no other widgets are active val appWidgetManager = AppWidgetManager.getInstance(context) - val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) val mempoolSizeWidget = ComponentName(context, MempoolSizeWidget::class.java) + val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) + val feeRatesWidget = ComponentName(context, FeeRatesWidget::class.java) - if (appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && - appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty()) { + if (appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty() && + appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && + appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { WidgetUpdater.cancelUpdates(context) // Cancel any ongoing coroutines + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() widgetScope?.cancel() widgetScope = null } @@ -88,41 +93,74 @@ class BlockHeightWidget : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) + // Cancel any existing job for this widget + activeJobs[appWidgetId]?.cancel() + // Set loading state first setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) - // Fetch latest data - getOrCreateScope().launch { + // Start new job + activeJobs[appWidgetId] = getOrCreateScope().launch { try { val mempoolApi = WidgetNetworkClient.getMempoolApi(context) - val blockHeightResponse = mempoolApi.getBlockHeight() - if (blockHeightResponse.isSuccessful) { - blockHeightResponse.body()?.let { blockHeight -> - views.setTextViewText(R.id.block_height, - String.format(Locale.US, "%,d", blockHeight)) - - // Get block timestamp - val blockHashResponse = mempoolApi.getLatestBlockHash() - if (blockHashResponse.isSuccessful) { - val hash = blockHashResponse.body() - if (hash != null) { - val blockInfoResponse = mempoolApi.getBlockInfo(hash) - if (blockInfoResponse.isSuccessful) { - blockInfoResponse.body()?.timestamp?.let { timestamp -> - val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 - views.setTextViewText(R.id.elapsed_time, - "(${elapsedMinutes} minutes ago)") + + // Launch both API calls concurrently + val blockHeightDeferred = async { mempoolApi.getBlockHeight() } + val blockHashDeferred = async { mempoolApi.getLatestBlockHash() } + + var hasAnyData = false + + // Wait for block height first + try { + val blockHeightResponse = blockHeightDeferred.await() + if (blockHeightResponse.isSuccessful) { + blockHeightResponse.body()?.let { blockHeight -> + views.setTextViewText(R.id.block_height, + String.format(Locale.US, "%,d", blockHeight)) + hasAnyData = true + + // Process block hash and timestamp concurrently + try { + val blockHashResponse = blockHashDeferred.await() + if (blockHashResponse.isSuccessful) { + val hash = blockHashResponse.body() + if (hash != null) { + // Launch block info request immediately + val blockInfoDeferred = async { mempoolApi.getBlockInfo(hash) } + val blockInfoResponse = blockInfoDeferred.await() + if (blockInfoResponse.isSuccessful) { + blockInfoResponse.body()?.timestamp?.let { timestamp -> + val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 + views.setTextViewText(R.id.elapsed_time, + "(${elapsedMinutes} minutes ago)") + } + } } } + } catch (e: Exception) { + // If timestamp fetch fails, just show block height + views.setTextViewText(R.id.elapsed_time, "") } + + // Update widget with at least block height + appWidgetManager.updateAppWidget(appWidgetId, views) } - - appWidgetManager.updateAppWidget(appWidgetId, views) } + } catch (e: Exception) { + e.printStackTrace() + } + + // If we didn't get any data at all, show error + if (!hasAnyData) { + setErrorState(views, "No data") } } catch (e: Exception) { e.printStackTrace() + setErrorState(views, "Network error") + } finally { + appWidgetManager.updateAppWidget(appWidgetId, views) + activeJobs.remove(appWidgetId) } } } @@ -131,4 +169,9 @@ class BlockHeightWidget : AppWidgetProvider() { views.setTextViewText(R.id.block_height, "...") views.setTextViewText(R.id.elapsed_time, "") } + + private fun setErrorState(views: RemoteViews, error: String) { + views.setTextViewText(R.id.block_height, "!") + views.setTextViewText(R.id.elapsed_time, "($error)") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt b/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt index fed878e..1e02a74 100644 --- a/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt @@ -17,6 +17,7 @@ class CombinedStatsWidget : AppWidgetProvider() { companion object { const val REFRESH_ACTION = "com.example.mempal.REFRESH_COMBINED_WIDGET" private var widgetScope: CoroutineScope? = null + private var activeJobs = mutableMapOf() } private fun getOrCreateScope(): CoroutineScope { @@ -42,6 +43,8 @@ class CombinedStatsWidget : AppWidgetProvider() { appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { WidgetUpdater.cancelUpdates(context) // Cancel any ongoing coroutines + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() widgetScope?.cancel() widgetScope = null } @@ -91,65 +94,107 @@ class CombinedStatsWidget : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) + // Cancel any existing job for this widget + activeJobs[appWidgetId]?.cancel() + + // Set loading state first setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) - getOrCreateScope().launch { + // Start new job + activeJobs[appWidgetId] = getOrCreateScope().launch { try { val mempoolApi = WidgetNetworkClient.getMempoolApi(context) + var hasAnyData = false + + // Launch all API calls concurrently + val blockHeightDeferred = async { mempoolApi.getBlockHeight() } + val blockHashDeferred = async { mempoolApi.getLatestBlockHash() } + val mempoolInfoDeferred = async { mempoolApi.getMempoolInfo() } + val feeRatesDeferred = async { mempoolApi.getFeeRates() } - // Get block height and timestamp - val blockHeightResponse = mempoolApi.getBlockHeight() - if (blockHeightResponse.isSuccessful) { - blockHeightResponse.body()?.let { blockHeight -> - views.setTextViewText(R.id.block_height, - String.format(Locale.US, "%,d", blockHeight)) - - // Get block timestamp - val blockHashResponse = mempoolApi.getLatestBlockHash() - if (blockHashResponse.isSuccessful) { - val hash = blockHashResponse.body() - if (hash != null) { - val blockInfoResponse = mempoolApi.getBlockInfo(hash) - if (blockInfoResponse.isSuccessful) { - blockInfoResponse.body()?.timestamp?.let { timestamp -> - val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 - views.setTextViewText(R.id.elapsed_time, - "(${elapsedMinutes} minutes ago)") + // Process block height and timestamp + try { + val blockHeightResponse = blockHeightDeferred.await() + if (blockHeightResponse.isSuccessful) { + blockHeightResponse.body()?.let { blockHeight -> + views.setTextViewText(R.id.block_height, + String.format(Locale.US, "%,d", blockHeight)) + hasAnyData = true + + // Process timestamp + try { + val blockHashResponse = blockHashDeferred.await() + if (blockHashResponse.isSuccessful) { + val hash = blockHashResponse.body() + if (hash != null) { + val blockInfoDeferred = async { mempoolApi.getBlockInfo(hash) } + val blockInfoResponse = blockInfoDeferred.await() + if (blockInfoResponse.isSuccessful) { + blockInfoResponse.body()?.timestamp?.let { timestamp -> + val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 + views.setTextViewText(R.id.elapsed_time, + "(${elapsedMinutes} minutes ago)") + } + } } } + } catch (e: Exception) { + // If timestamp fetch fails, just clear the field + views.setTextViewText(R.id.elapsed_time, "") } } } + } catch (e: Exception) { + e.printStackTrace() } - // Get mempool size - val mempoolResponse = mempoolApi.getMempoolInfo() - if (mempoolResponse.isSuccessful) { - mempoolResponse.body()?.let { mempoolInfo -> - val sizeInMB = mempoolInfo.vsize / 1_000_000.0 - views.setTextViewText(R.id.mempool_size, - String.format(Locale.US, "%.2f vMB", sizeInMB)) - - val blocksToClean = ceil(sizeInMB / 1.5).toInt() - views.setTextViewText(R.id.mempool_blocks_to_clear, - "(${blocksToClean} blocks to clear)") + // Process mempool info + try { + val mempoolResponse = mempoolInfoDeferred.await() + if (mempoolResponse.isSuccessful) { + mempoolResponse.body()?.let { mempoolInfo -> + val sizeInMB = mempoolInfo.vsize / 1_000_000.0 + views.setTextViewText(R.id.mempool_size, + String.format(Locale.US, "%.2f vMB", sizeInMB)) + + val blocksToClean = ceil(sizeInMB / 1.5).toInt() + views.setTextViewText(R.id.mempool_blocks_to_clear, + "(${blocksToClean} blocks to clear)") + + hasAnyData = true + } } + } catch (e: Exception) { + e.printStackTrace() } - // Get fee rates - val feeResponse = mempoolApi.getFeeRates() - if (feeResponse.isSuccessful) { - feeResponse.body()?.let { feeRates -> - views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") - views.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") - views.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") + // Process fee rates + try { + val feeResponse = feeRatesDeferred.await() + if (feeResponse.isSuccessful) { + feeResponse.body()?.let { feeRates -> + views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") + views.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") + views.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") + hasAnyData = true + } } + } catch (e: Exception) { + e.printStackTrace() } + if (!hasAnyData) { + setErrorState(views, "No data") + } + appWidgetManager.updateAppWidget(appWidgetId, views) } catch (e: Exception) { e.printStackTrace() + setErrorState(views, "Network error") + } finally { + appWidgetManager.updateAppWidget(appWidgetId, views) + activeJobs.remove(appWidgetId) } } } @@ -163,4 +208,14 @@ class CombinedStatsWidget : AppWidgetProvider() { views.setTextViewText(R.id.standard_fee, "...") views.setTextViewText(R.id.economy_fee, "...") } + + private fun setErrorState(views: RemoteViews, error: String) { + views.setTextViewText(R.id.block_height, "!") + views.setTextViewText(R.id.elapsed_time, "") + views.setTextViewText(R.id.mempool_size, "!") + views.setTextViewText(R.id.mempool_blocks_to_clear, "") + views.setTextViewText(R.id.priority_fee, "!") + views.setTextViewText(R.id.standard_fee, "!") + views.setTextViewText(R.id.economy_fee, "($error)") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt b/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt index 2e5f4a8..8fd66fa 100644 --- a/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt @@ -15,6 +15,7 @@ class FeeRatesWidget : AppWidgetProvider() { companion object { const val REFRESH_ACTION = "com.example.mempal.REFRESH_FEE_RATES_WIDGET" private var widgetScope: CoroutineScope? = null + private var activeJobs = mutableMapOf() } private fun getOrCreateScope(): CoroutineScope { @@ -40,6 +41,8 @@ class FeeRatesWidget : AppWidgetProvider() { appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty()) { WidgetUpdater.cancelUpdates(context) // Cancel any ongoing coroutines + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() widgetScope?.cancel() widgetScope = null } @@ -89,25 +92,47 @@ class FeeRatesWidget : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) + // Cancel any existing job for this widget + activeJobs[appWidgetId]?.cancel() + // Set loading state first setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) - // Fetch latest data - getOrCreateScope().launch { + // Start new job + activeJobs[appWidgetId] = getOrCreateScope().launch { try { val mempoolApi = WidgetNetworkClient.getMempoolApi(context) - val response = mempoolApi.getFeeRates() - if (response.isSuccessful) { - response.body()?.let { feeRates -> - views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") - views.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") - views.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") - appWidgetManager.updateAppWidget(appWidgetId, views) + var hasAnyData = false + + // Launch API call immediately + val feeRatesDeferred = async { mempoolApi.getFeeRates() } + + // Process response + try { + val response = feeRatesDeferred.await() + if (response.isSuccessful) { + response.body()?.let { feeRates -> + views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") + views.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") + views.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") + hasAnyData = true + appWidgetManager.updateAppWidget(appWidgetId, views) + } } + } catch (e: Exception) { + e.printStackTrace() + } + + if (!hasAnyData) { + setErrorState(views, "No data") } } catch (e: Exception) { e.printStackTrace() + setErrorState(views, "Network error") + } finally { + appWidgetManager.updateAppWidget(appWidgetId, views) + activeJobs.remove(appWidgetId) } } } @@ -117,4 +142,10 @@ class FeeRatesWidget : AppWidgetProvider() { views.setTextViewText(R.id.standard_fee, "...") views.setTextViewText(R.id.economy_fee, "...") } + + private fun setErrorState(views: RemoteViews, error: String) { + views.setTextViewText(R.id.priority_fee, "!") + views.setTextViewText(R.id.standard_fee, "!") + views.setTextViewText(R.id.economy_fee, "($error)") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt b/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt index ff9d86f..4e6141f 100644 --- a/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt @@ -17,6 +17,7 @@ class MempoolSizeWidget : AppWidgetProvider() { companion object { const val REFRESH_ACTION = "com.example.mempal.REFRESH_MEMPOOL_SIZE_WIDGET" private var widgetScope: CoroutineScope? = null + private var activeJobs = mutableMapOf() } private fun getOrCreateScope(): CoroutineScope { @@ -35,11 +36,15 @@ class MempoolSizeWidget : AppWidgetProvider() { val appWidgetManager = AppWidgetManager.getInstance(context) val blockHeightWidget = ComponentName(context, BlockHeightWidget::class.java) val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) + val feeRatesWidget = ComponentName(context, FeeRatesWidget::class.java) if (appWidgetManager.getAppWidgetIds(blockHeightWidget).isEmpty() && - appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty()) { + appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && + appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { WidgetUpdater.cancelUpdates(context) // Cancel any ongoing coroutines + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() widgetScope?.cancel() widgetScope = null } @@ -89,30 +94,52 @@ class MempoolSizeWidget : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) + // Cancel any existing job for this widget + activeJobs[appWidgetId]?.cancel() + // Set loading state first setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) - // Fetch latest data - getOrCreateScope().launch { + // Start new job + activeJobs[appWidgetId] = getOrCreateScope().launch { try { val mempoolApi = WidgetNetworkClient.getMempoolApi(context) - val response = mempoolApi.getMempoolInfo() - if (response.isSuccessful) { - response.body()?.let { mempoolInfo -> - val sizeInMB = mempoolInfo.vsize / 1_000_000.0 - views.setTextViewText(R.id.mempool_size, - String.format(Locale.US, "%.2f vMB", sizeInMB)) - - val blocksToClean = ceil(sizeInMB / 1.5).toInt() - views.setTextViewText(R.id.mempool_blocks_to_clear, - "(${blocksToClean} blocks to clear)") + var hasAnyData = false + + // Launch API call immediately + val mempoolInfoDeferred = async { mempoolApi.getMempoolInfo() } + + // Process response + try { + val response = mempoolInfoDeferred.await() + if (response.isSuccessful) { + response.body()?.let { mempoolInfo -> + val sizeInMB = mempoolInfo.vsize / 1_000_000.0 + views.setTextViewText(R.id.mempool_size, + String.format(Locale.US, "%.2f vMB", sizeInMB)) + + val blocksToClean = ceil(sizeInMB / 1.5).toInt() + views.setTextViewText(R.id.mempool_blocks_to_clear, + "(${blocksToClean} blocks to clear)") - appWidgetManager.updateAppWidget(appWidgetId, views) + hasAnyData = true + appWidgetManager.updateAppWidget(appWidgetId, views) + } } + } catch (e: Exception) { + e.printStackTrace() + } + + if (!hasAnyData) { + setErrorState(views, "No data") } } catch (e: Exception) { e.printStackTrace() + setErrorState(views, "Network error") + } finally { + appWidgetManager.updateAppWidget(appWidgetId, views) + activeJobs.remove(appWidgetId) } } } @@ -121,4 +148,9 @@ class MempoolSizeWidget : AppWidgetProvider() { views.setTextViewText(R.id.mempool_size, "...") views.setTextViewText(R.id.mempool_blocks_to_clear, "") } + + private fun setErrorState(views: RemoteViews, error: String) { + views.setTextViewText(R.id.mempool_size, "!") + views.setTextViewText(R.id.mempool_blocks_to_clear, "($error)") + } } \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt new file mode 100644 index 0000000..2fabb1f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10.txt @@ -0,0 +1,2 @@ +- F-droid compatibility patch. +- Widget optimizations and bug fixes. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/com.example.mempal.yml b/fastlane/metadata/android/en-US/com.example.mempal.yml index 812269f..fec5cda 100644 --- a/fastlane/metadata/android/en-US/com.example.mempal.yml +++ b/fastlane/metadata/android/en-US/com.example.mempal.yml @@ -45,7 +45,14 @@ Builds: gradle: - yes + - versionName: 1.4.2 + versionCode: 10 + commit: + subdir: app + gradle: + - yes + AutoUpdateMode: Version UpdateCheckMode: Tags -CurrentVersion: 1.4.1 -CurrentVersionCode: 9 \ No newline at end of file +CurrentVersion: 1.4.2 +CurrentVersionCode: 10 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index c9462f2..a7e71e6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,5 +23,5 @@ kotlin.incremental.js=true org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.configureondemand=true -org.gradle.jvmargs=-Xmx1024M -Dkotlin.daemon.jvm.options\="-Xmx2048M" -XX\:+UseParallelGC +org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1024M -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8 org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ff8d90..124dfd8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,8 +15,9 @@ junit = "4.13.2" androidx-test-ext = "1.1.5" espresso = "3.5.1" constraintlayout = "2.1.4" -tor-android = "0.4.8.12" +tor-android = "0.4.7.8" jtorctl = "0.4.5.7" +localbroadcastmanager = "1.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -47,6 +48,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co tor-android = { group = "info.guardianproject", name = "tor-android", version.ref = "tor-android" } jtorctl = { group = "info.guardianproject", name = "jtorctl", version.ref = "jtorctl" } +androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 66a400f..b1cbfcb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,7 +17,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") } } }