├── fastlane └── metadata │ └── android │ └── en-US │ ├── title.txt │ ├── changelogs │ ├── 16.txt │ ├── 13.txt │ ├── 14.txt │ └── 15.txt │ ├── short_description.txt │ ├── images │ ├── icon.png │ ├── featureGraphic.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ └── 4.png │ └── full_description.txt ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── app ├── src │ └── main │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ ├── drawable │ │ │ ├── app_logo.png │ │ │ ├── widget_background.xml │ │ │ ├── stats_card_background.xml │ │ │ ├── ic_baseline_expand_24.xml │ │ │ ├── ic_cube.xml │ │ │ ├── ic_baseline_numbers_24.xml │ │ │ ├── ic_baseline_speed_24.xml │ │ │ ├── ic_baseline_open_in_new_24.xml │ │ │ ├── ic_onion.xml │ │ │ ├── ic_github.xml │ │ │ ├── ic_launcher_background.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── values │ │ │ ├── ic_launcher_background.xml │ │ │ ├── themes.xml │ │ │ ├── colors.xml │ │ │ └── strings.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── xml │ │ │ ├── fee_rates_widget_info.xml │ │ │ ├── block_height_widget_info.xml │ │ │ ├── mempool_size_widget_info.xml │ │ │ ├── data_extraction_rules.xml │ │ │ ├── combined_stats_widget_info.xml │ │ │ ├── backup_rules.xml │ │ │ └── network_security_config.xml │ │ ├── xml-v31 │ │ │ ├── fee_rates_widget_info.xml │ │ │ ├── block_height_widget_info.xml │ │ │ ├── mempool_size_widget_info.xml │ │ │ └── combined_stats_widget_info.xml │ │ ├── values-v33 │ │ │ └── themes.xml │ │ ├── layout │ │ │ ├── block_height_widget.xml │ │ │ ├── mempool_size_widget.xml │ │ │ ├── fee_rates_widget.xml │ │ │ └── combined_stats_widget.xml │ │ └── mempal-icon.svg │ │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── mempal │ │ │ ├── api │ │ │ ├── HashrateInfo.kt │ │ │ ├── Models.kt │ │ │ ├── MempoolApi.kt │ │ │ ├── WidgetNetworkClient.kt │ │ │ └── NetworkClient.kt │ │ │ ├── ui │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── MempalApplication.kt │ │ │ ├── model │ │ │ └── NotificationSettings.kt │ │ │ ├── widget │ │ │ ├── WidgetBootupReceiver.kt │ │ │ ├── WidgetEventHandler.kt │ │ │ ├── WidgetUtils.kt │ │ │ ├── WidgetUpdateWorker.kt │ │ │ ├── WidgetUpdater.kt │ │ │ ├── NetworkConnectivityReceiver.kt │ │ │ ├── FeeRatesWidget.kt │ │ │ ├── MempoolSizeWidget.kt │ │ │ └── BlockHeightWidget.kt │ │ │ ├── cache │ │ │ └── DashboardCache.kt │ │ │ ├── tor │ │ │ ├── TorForegroundService.kt │ │ │ └── TorManager.kt │ │ │ └── repository │ │ │ └── SettingsRepository.kt │ │ └── AndroidManifest.xml ├── build.gradle.kts └── proguard-rules.pro ├── settings.gradle.kts ├── LICENSE ├── gradle.properties ├── README.md ├── gradlew.bat └── gradlew /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Mempal -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/16.txt: -------------------------------------------------------------------------------- 1 | - Added support for sub sat feerates -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | A Bitcoin mempool monitoring and notification app for Android. -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/drawable/app_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/13.txt: -------------------------------------------------------------------------------- 1 | - Fixed minor color mismatch in combined widget. 2 | - Disabled APK dependency metadata 3 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeonBTC/Mempal/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #0F2135 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/api/HashrateInfo.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.api 2 | 3 | data class HashrateInfo( 4 | val currentHashrate: Double, 5 | val currentDifficulty: Double 6 | ) -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | - Added welcome screen with quick tips. 2 | - Added hashrate, difficulty, and adjustments to dashboard. 3 | - Modified widget auto-refresh intervals. 4 | - Fixed versioning mismatch. -------------------------------------------------------------------------------- /app/src/main/res/drawable/widget_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/stats_card_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/15.txt: -------------------------------------------------------------------------------- 1 | - Added swipe-down feature to refresh dashboard. 2 | - Added custom time option for widget auto-update frequency. 3 | - Fixed widget refresh bug when Android battery/data saver mode is enabled. 4 | - Various optimization and bug fixes. -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /app/src/main/res/xml/fee_rates_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_expand_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/block_height_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/mempool_size_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object AppColors { 6 | val NavyBlue = Color(0xFF0F2135) 7 | val Orange = Color(0xFFEC910C) 8 | val DarkGray = Color(0xFF252A30) 9 | val DarkerNavy = Color(0xFF0A1829) 10 | val DataGray = Color(0xFFB8C4D9) 11 | val WarningRed = Color(0xFFD32F2F) 12 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cube.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | mavenCentral() 5 | google() 6 | } 7 | } 8 | 9 | @Suppress("UnstableApiUsage") 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | repositories { 13 | google() 14 | mavenCentral() 15 | } 16 | } 17 | 18 | rootProject.name = "Mempal" 19 | include(":app") -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/MempalApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal 2 | 3 | import androidx.multidex.MultiDexApplication 4 | import com.example.mempal.api.NetworkClient 5 | import com.example.mempal.tor.TorManager 6 | 7 | class MempalApplication : MultiDexApplication() { 8 | override fun onCreate() { 9 | super.onCreate() 10 | NetworkClient.initialize(this) 11 | TorManager.getInstance().initialize(this) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v31/fee_rates_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v31/block_height_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v31/mempool_size_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_numbers_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/xml/combined_stats_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_speed_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values-v33/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_open_in_new_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v31/combined_stats_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10.0.0.0 10 | 172.16.0.0 11 | 192.168.0.0 12 | 127.0.0.1 13 | localhost 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF6200EE 4 | #FF1C1B1F 5 | #FF121212 6 | #FFCF6679 7 | #FFFFFFFF 8 | #FFFFFFFF 9 | #FFFFFFFF 10 | #FFFFFFFF 11 | #FF000000 12 | #DEFFFFFF 13 | #99FFFFFF 14 | #FF000000 15 | #FFFFFFFF 16 | #FF0F2135 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/api/Models.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.api 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class FeeRates( 6 | @SerializedName("fastestFee") val fastestFee: Int = 0, 7 | @SerializedName("halfHourFee") val halfHourFee: Int = 0, 8 | @SerializedName("hourFee") val hourFee: Int = 0, 9 | @SerializedName("economyFee") val economyFee: Int = 0 10 | ) 11 | 12 | data class MempoolInfo( 13 | @SerializedName("vsize") val vsize: Long = 0L, 14 | @SerializedName("total_fee") val totalFee: Double = 0.0, 15 | @SerializedName("unconfirmed_count") val unconfirmedCount: Int = 0, 16 | @SerializedName("fee_histogram") val feeHistogram: List> = emptyList(), 17 | val isUsingFallbackHistogram: Boolean = false 18 | ) { 19 | fun needsHistogramFallback(): Boolean = feeHistogram.isEmpty() 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.ui.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.darkColorScheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | private val DarkColorScheme = darkColorScheme( 9 | primary = AppColors.Orange, 10 | primaryContainer = AppColors.Orange, 11 | secondary = AppColors.Orange, 12 | background = AppColors.NavyBlue, 13 | surface = AppColors.DarkGray, 14 | surfaceVariant = AppColors.DarkerNavy, 15 | error = AppColors.WarningRed, 16 | onPrimary = Color.White, 17 | onSecondary = Color.White, 18 | onBackground = Color.White, 19 | onSurface = AppColors.DataGray, 20 | onSurfaceVariant = Color.White, 21 | onError = Color.Black 22 | ) 23 | 24 | @Composable 25 | fun MempalTheme( 26 | content: @Composable () -> Unit 27 | ) { 28 | MaterialTheme( 29 | colorScheme = DarkColorScheme, 30 | typography = Typography, 31 | content = content 32 | ) 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 aeonBTC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_onion.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_github.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | val Typography = Typography( 10 | headlineLarge = TextStyle( 11 | fontFamily = FontFamily.Default, 12 | fontWeight = FontWeight.Bold, 13 | fontSize = 26.sp, 14 | lineHeight = 28.sp, 15 | letterSpacing = 0.sp 16 | ), 17 | headlineMedium = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.SemiBold, 20 | fontSize = 24.sp, 21 | lineHeight = 32.sp, 22 | letterSpacing = 0.sp 23 | ), 24 | titleLarge = TextStyle( 25 | fontFamily = FontFamily.Default, 26 | fontWeight = FontWeight.Normal, 27 | fontSize = 18.sp, 28 | lineHeight = 28.sp, 29 | letterSpacing = 0.sp 30 | ), 31 | bodyLarge = TextStyle( 32 | fontFamily = FontFamily.Default, 33 | fontWeight = FontWeight.Normal, 34 | fontSize = 16.sp, 35 | lineHeight = 24.sp, 36 | letterSpacing = 0.15.sp 37 | ) 38 | ) -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Mempal is a Bitcoin network monitoring app that allows users to effortlessly track key network metrics, including block height, fee rates, hashrate, and mempool size & depth. Mempal is an essential tool for users who want to optimize their transactions and track fee rates. 2 |
3 | Key Features 4 |
5 | Detailed Mempool Insights: Mempal provides in-depth information about the current state of the Bitcoin network, including current block height, hashrate, mempool size, fee rates, and fee distribution. This allows users to gauge network congestion and make informed decisions regarding transaction fees. 6 |
7 | Real-Time Notifications: Users can tailor their notification settings to receive alerts when transaction fees drop below a specified threshold, when a specific TXID is confirmed, or when new blocks are mined. 8 |
9 | Custom Servers: Connect Mempal to a self-hosted mempool server so you don't have to rely on trusted third parties to obtain information about the Bitcoin network. 10 |
11 | Tor Integration: Enable Tor to preserve your privacy and connect to .onion mempool servers. 12 |
13 | Widgets: Receive quick and up-to-date information about fee rates, block sizes, and mempool sizes. -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. For more details, visit 11 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 12 | # org.gradle.parallel=true 13 | #Mon Nov 18 10:58:37 PST 2024 14 | android.enableJetifier=false 15 | android.nonTransitiveRClass=true 16 | android.useAndroidX=true 17 | kapt.incremental.apt=true 18 | kotlin.caching.enabled=true 19 | kotlin.code.style=official 20 | kotlin.incremental=true 21 | kotlin.incremental.java=true 22 | kotlin.incremental.js=true 23 | org.gradle.caching=true 24 | org.gradle.configuration-cache=true 25 | org.gradle.configureondemand=true 26 | org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1024M -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8 27 | org.gradle.parallel=true 28 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mempal 4 | Bitcoin Blocks 5 | Get notified when new blocks are mined. 6 | Mempool Size 7 | Get notified when mempool size falls below threshold. 8 | Fee Rates 9 | Get notified when fee rates fall below threshold. 10 | Transaction Confirmation 11 | Get notified when your transaction is confirmed. 12 | 13 | 14 | Block Height 15 | Mempool Size 16 | 1 Block 17 | 3 Blocks 18 | 6 Blocks 19 | sat/vB 20 | Block height icon 21 | Mempool size icon 22 | -------------------------------------------------------------------------------- /app/src/main/res/layout/block_height_widget.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 19 | 20 | 28 | 29 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/res/layout/mempool_size_widget.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 19 | 20 | 28 | 29 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/model/NotificationSettings.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.model 2 | 3 | enum class FeeRateType { 4 | NEXT_BLOCK, 5 | THREE_BLOCKS, 6 | SIX_BLOCKS, 7 | DAY_BLOCKS 8 | } 9 | 10 | data class NotificationSettings( 11 | val blockNotificationsEnabled: Boolean = false, 12 | val blockCheckFrequency: Int = 15, 13 | val newBlockNotificationEnabled: Boolean = false, 14 | val newBlockCheckFrequency: Int = 15, 15 | val hasNotifiedForNewBlock: Boolean = false, 16 | val specificBlockNotificationEnabled: Boolean = false, 17 | val specificBlockCheckFrequency: Int = 15, 18 | val targetBlockHeight: Int? = null, 19 | val hasNotifiedForTargetBlock: Boolean = false, 20 | val mempoolSizeNotificationsEnabled: Boolean = false, 21 | val mempoolCheckFrequency: Int = 15, 22 | val mempoolSizeThreshold: Float = 0f, 23 | val mempoolSizeAboveThreshold: Boolean = false, 24 | val feeRatesNotificationsEnabled: Boolean = false, 25 | val feeRatesCheckFrequency: Int = 15, 26 | val selectedFeeRateType: FeeRateType = FeeRateType.NEXT_BLOCK, 27 | val feeRateThreshold: Int = 0, 28 | val feeRateAboveThreshold: Boolean = false, 29 | val isServiceEnabled: Boolean = false, 30 | val txConfirmationEnabled: Boolean = false, 31 | val txConfirmationFrequency: Int = 15, 32 | val transactionId: String = "", 33 | val hasNotifiedForCurrentTx: Boolean = false, 34 | val hasNotifiedForMempoolSize: Boolean = false, 35 | val hasNotifiedForFeeRate: Boolean = false 36 | ) { 37 | companion object { 38 | const val MIN_CHECK_FREQUENCY = 1 39 | } 40 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mempal 2 | 3 | 4 | ![396710790-db06e32d-9a03-421c-ba9b-0584c00c7dec copy](https://github.com/user-attachments/assets/d72f4670-aa5e-4072-99a8-853cb3b0c9fe) 5 | 6 | 7 | [Get it on F-Droid](https://f-droid.org/en/packages/com.example.mempal/) 8 | [Get APK from GitHub](https://github.com/aeonBTC/Mempal/releases) 9 | 10 | ### Mempal is a Bitcoin network monitoring app that allows users to effortlessly track key network metrics, including block height, fee rates, hashrate, and mempool size & depth. Mempal is an essential tool for users who want to optimize their transactions and track fee rates. 11 | 12 | Key Features: 13 | 14 | - **Detailed Insights:** Mempal provides in-depth information about the current state of the Bitcoin network, including current block height, hashrate, mempool size, fee rates, and fee distribution. This allows users to gauge network congestion and make informed decisions regarding transaction fees. 15 | 16 | * **Real-Time Notifications:** Users can tailor their notification settings to receive alerts when transaction fees drop below a specified threshold, when a specific TXID is confirmed, or when new blocks are mined. 17 | 18 | * **Custom Servers:** Connect Mempal to a self-hosted mempool server so you don't have to rely on trusted third parties to obtain information about the Bitcoin network. 19 | 20 | * **Tor Integration:** Enable Tor to preserve your privacy and connect to .onion mempool servers. 21 | 22 | * **Widgets:** Receive quick and up-to-date information about fee rates, block sizes, and mempool sizes. 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/api/MempoolApi.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PropertyName") 2 | 3 | package com.example.mempal.api 4 | 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Path 8 | 9 | interface MempoolApi { 10 | @GET("api/blocks/tip/height") 11 | suspend fun getBlockHeight(): Response 12 | 13 | @GET("api/blocks/tip/hash") 14 | suspend fun getLatestBlockHash(): Response 15 | 16 | @GET("api/block/{hash}") 17 | suspend fun getBlockInfo(@Path("hash") hash: String): Response 18 | 19 | @GET("api/v1/fees/recommended") 20 | suspend fun getFeeRates(): Response 21 | 22 | @GET("api/mempool") 23 | suspend fun getMempoolInfo(): Response 24 | 25 | @GET("api/tx/{txid}") 26 | suspend fun getTransaction(@Path("txid") txid: String): Response 27 | 28 | @GET("api/v1/mining/hashrate/3d") 29 | suspend fun getHashrateInfo(): Response 30 | 31 | @GET("api/v1/difficulty-adjustment") 32 | suspend fun getDifficultyAdjustment(): Response 33 | 34 | companion object { 35 | const val BASE_URL = "https://mempool.space/" 36 | const val ONION_BASE_URL = "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/" 37 | } 38 | } 39 | 40 | data class TransactionResponse( 41 | val txid: String, 42 | val status: TransactionStatus 43 | ) 44 | 45 | data class TransactionStatus( 46 | val confirmed: Boolean, 47 | val block_height: Int, 48 | val block_hash: String, 49 | val block_time: Long 50 | ) 51 | 52 | data class BlockInfo( 53 | val id: String, 54 | val height: Int, 55 | val timestamp: Long 56 | ) 57 | 58 | data class DifficultyAdjustment( 59 | val progressPercent: Double, 60 | val difficultyChange: Double, 61 | val estimatedRetargetDate: Long, 62 | val remainingBlocks: Int, 63 | val remainingTime: Long, 64 | val previousRetarget: Double 65 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/WidgetBootupReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.util.Log 7 | import kotlinx.coroutines.DelicateCoroutinesApi 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.launch 10 | 11 | /** 12 | * Receiver to handle system boot events and package updates 13 | * This helps ensure widgets continue to update after device reboots 14 | * or when the app is updated. 15 | */ 16 | class WidgetBootupReceiver : BroadcastReceiver() { 17 | private val tag = "WidgetBootupReceiver" 18 | 19 | @OptIn(DelicateCoroutinesApi::class) 20 | override fun onReceive(context: Context, intent: Intent) { 21 | Log.d(tag, "Received system event: ${intent.action}") 22 | 23 | when (intent.action) { 24 | Intent.ACTION_BOOT_COMPLETED, 25 | Intent.ACTION_MY_PACKAGE_REPLACED, 26 | "android.intent.action.QUICKBOOT_POWERON" -> { 27 | Log.d(tag, "System restart detected, rescheduling widget updates") 28 | 29 | // Use GlobalScope as we may not have an active component 30 | GlobalScope.launch { 31 | try { 32 | // Give the system a moment to stabilize after boot/update 33 | kotlinx.coroutines.delay(5000) 34 | 35 | // Reschedule widget updates 36 | WidgetUpdater.scheduleUpdates(context.applicationContext) 37 | 38 | // Request an immediate update to refresh widgets 39 | kotlinx.coroutines.delay(2000) 40 | WidgetUpdater.requestImmediateUpdate(context.applicationContext, force = true) 41 | } catch (e: Exception) { 42 | Log.e(tag, "Error rescheduling widget updates", e) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/cache/DashboardCache.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.cache 2 | 3 | import com.example.mempal.api.FeeRates 4 | import com.example.mempal.api.HashrateInfo 5 | import com.example.mempal.api.MempoolInfo 6 | 7 | // Singleton object to store dashboard data in memory 8 | object DashboardCache { 9 | private var cachedBlockHeight: Int? = null 10 | private var cachedBlockTimestamp: Long? = null 11 | private var cachedFeeRates: FeeRates? = null 12 | private var cachedMempoolInfo: MempoolInfo? = null 13 | private var cachedHashrateInfo: HashrateInfo? = null 14 | 15 | // Save all dashboard data at once 16 | fun saveState( 17 | blockHeight: Int?, 18 | blockTimestamp: Long?, 19 | mempoolInfo: MempoolInfo?, 20 | feeRates: FeeRates? 21 | ) { 22 | cachedBlockHeight = blockHeight 23 | cachedBlockTimestamp = blockTimestamp 24 | cachedFeeRates = feeRates 25 | cachedMempoolInfo = mempoolInfo 26 | cachedHashrateInfo = null 27 | } 28 | 29 | // Get cached state 30 | fun getCachedState(): DashboardState { 31 | return DashboardState( 32 | blockHeight = cachedBlockHeight, 33 | blockTimestamp = cachedBlockTimestamp, 34 | mempoolInfo = cachedMempoolInfo, 35 | feeRates = cachedFeeRates, 36 | hashrateInfo = cachedHashrateInfo 37 | ) 38 | } 39 | 40 | // Check if we have any cached data 41 | fun hasCachedData(): Boolean { 42 | return cachedBlockHeight != null || 43 | cachedBlockTimestamp != null || 44 | cachedFeeRates != null || 45 | cachedMempoolInfo != null || 46 | cachedHashrateInfo != null 47 | } 48 | 49 | // Clear cache (useful when app is closing) 50 | fun clearCache() { 51 | cachedBlockHeight = null 52 | cachedBlockTimestamp = null 53 | cachedFeeRates = null 54 | cachedMempoolInfo = null 55 | cachedHashrateInfo = null 56 | } 57 | } 58 | 59 | // Data class to hold all dashboard state 60 | data class DashboardState( 61 | val blockHeight: Int?, 62 | val blockTimestamp: Long?, 63 | val mempoolInfo: MempoolInfo?, 64 | val feeRates: FeeRates?, 65 | val hashrateInfo: HashrateInfo? 66 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/tor/TorForegroundService.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.tor 2 | 3 | import android.app.* 4 | import android.content.Intent 5 | import android.content.pm.ServiceInfo 6 | import android.os.Build 7 | import android.os.IBinder 8 | import android.os.PowerManager 9 | import androidx.core.app.NotificationCompat 10 | import com.example.mempal.R 11 | import com.example.mempal.service.NotificationService 12 | 13 | class TorForegroundService : Service() { 14 | private var wakeLock: PowerManager.WakeLock? = null 15 | private val wakelockTag = "mempal:torServiceWakelock" 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | acquireWakeLock() 20 | } 21 | 22 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 23 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 24 | startForeground(NotificationService.NOTIFICATION_ID, createSilentNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) 25 | } else { 26 | startForeground(NotificationService.NOTIFICATION_ID, createSilentNotification()) 27 | } 28 | return START_STICKY 29 | } 30 | 31 | override fun onBind(intent: Intent?): IBinder? = null 32 | 33 | override fun onDestroy() { 34 | releaseWakeLock() 35 | super.onDestroy() 36 | } 37 | 38 | private fun createSilentNotification(): Notification { 39 | // Create a silent notification that won't be visible to the user 40 | return NotificationCompat.Builder(this, NotificationService.CHANNEL_ID) 41 | .setContentTitle("Mempal") 42 | .setContentText("Monitoring Bitcoin Network") 43 | .setSmallIcon(R.drawable.ic_cube) 44 | .setPriority(NotificationCompat.PRIORITY_MIN) 45 | .setVisibility(NotificationCompat.VISIBILITY_SECRET) 46 | .build() 47 | } 48 | 49 | private fun acquireWakeLock() { 50 | wakeLock = (getSystemService(POWER_SERVICE) as PowerManager).run { 51 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, wakelockTag).apply { 52 | acquire() 53 | } 54 | } 55 | } 56 | 57 | private fun releaseWakeLock() { 58 | wakeLock?.let { 59 | if (it.isHeld) { 60 | it.release() 61 | } 62 | } 63 | wakeLock = null 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/WidgetEventHandler.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.appwidget.AppWidgetManager 4 | import android.appwidget.AppWidgetProvider 5 | import android.content.ComponentName 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.util.Log 9 | 10 | /** 11 | * Utility class to handle system events for widgets 12 | * This centralizes the logic for handling system events like screen on and power connected 13 | * to reduce code duplication across widget classes 14 | */ 15 | object WidgetEventHandler { 16 | private const val TAG = "WidgetEventHandler" 17 | 18 | /** 19 | * Process system events for the given widget class 20 | * @param context The application context 21 | * @param intent The received intent 22 | * @param widgetClass The widget class that received the event 23 | * @param refreshAction The action string that triggers widget refresh 24 | * @return true if event was handled, false otherwise 25 | */ 26 | fun handleSystemEvent( 27 | context: Context, 28 | intent: Intent, 29 | widgetClass: Class, 30 | refreshAction: String 31 | ): Boolean { 32 | when (intent.action) { 33 | refreshAction -> { 34 | // Handle through the widget's own onReceive 35 | return false 36 | } 37 | 38 | // Process system events as update opportunities 39 | Intent.ACTION_SCREEN_ON, 40 | Intent.ACTION_POWER_CONNECTED, 41 | Intent.ACTION_BOOT_COMPLETED, 42 | Intent.ACTION_MY_PACKAGE_REPLACED -> { 43 | Log.d(TAG, "System event received for ${widgetClass.simpleName}: ${intent.action}") 44 | 45 | // Only update if it's been a while since the last update 46 | if (WidgetUpdater.shouldUpdate(context)) { 47 | val appWidgetManager = AppWidgetManager.getInstance(context) 48 | val component = ComponentName(context, widgetClass) 49 | val ids = appWidgetManager.getAppWidgetIds(component) 50 | 51 | if (ids.isNotEmpty()) { 52 | Log.d(TAG, "Requesting update for ${widgetClass.simpleName} widgets") 53 | WidgetUpdater.requestImmediateUpdate(context) 54 | } 55 | } else { 56 | Log.d(TAG, "Update throttled for ${widgetClass.simpleName}") 57 | } 58 | 59 | return true 60 | } 61 | 62 | else -> return false 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.7.3" 3 | kotlin = "1.9.22" 4 | core-ktx = "1.15.0" 5 | lifecycle-runtime = "2.8.7" 6 | activity-compose = "1.10.0" 7 | compose-compiler = "1.5.8" 8 | compose-bom = "2025.01.00" 9 | retrofit = "2.9.0" 10 | okhttp = "4.12.0" 11 | compose-material3 = "1.3.1" 12 | compose-material = "1.7.6" 13 | splash = "1.0.1" 14 | junit = "4.13.2" 15 | androidx-test-ext = "1.2.1" 16 | espresso = "3.6.1" 17 | constraintlayout = "2.2.0" 18 | tor-android = "0.4.7.14" 19 | jtorctl = "0.4.5.7" 20 | localbroadcastmanager = "1.1.0" 21 | pull-refresh = "0.32.0" 22 | 23 | [libraries] 24 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } 25 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } 26 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } 27 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 28 | androidx-ui = { group = "androidx.compose.ui", name = "ui" } 29 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 30 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 31 | androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "compose-material3" } 32 | androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "compose-material" } 33 | androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "compose-material" } 34 | androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } 35 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 36 | androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash" } 37 | pull-refresh = { group = "com.google.accompanist", name = "accompanist-swiperefresh", version.ref = "pull-refresh" } 38 | 39 | retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } 40 | retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } 41 | okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } 42 | okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } 43 | 44 | junit = { group = "junit", name = "junit", version.ref = "junit" } 45 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } 46 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } 47 | 48 | tor-android = { group = "info.guardianproject", name = "tor-android", version.ref = "tor-android" } 49 | jtorctl = { group = "info.guardianproject", name = "jtorctl", version.ref = "jtorctl" } 50 | androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" } 51 | 52 | [plugins] 53 | android-application = { id = "com.android.application", version.ref = "agp" } 54 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/WidgetUtils.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.SystemClock 7 | import com.example.mempal.MainActivity 8 | import com.example.mempal.api.NetworkClient 9 | import com.example.mempal.repository.SettingsRepository 10 | 11 | object WidgetUtils { 12 | private var lastTapTime = 0L 13 | private const val DOUBLE_TAP_TIMEOUT = 500L // ms 14 | private var serviceInitialized = false 15 | private var isFirstTap = true 16 | 17 | /** 18 | * Check if a tap event is actually a double-tap 19 | * @return true if this is the second tap in a double-tap sequence 20 | */ 21 | fun isDoubleTap(): Boolean { 22 | val currentTime = SystemClock.elapsedRealtime() 23 | val timeSinceLastTap = currentTime - lastTapTime 24 | 25 | // Safety check - if it's been a very long time since last tap (30 seconds), 26 | // reset the state to avoid getting stuck 27 | if (timeSinceLastTap > 30000) { 28 | resetTapState() 29 | } 30 | 31 | if (isFirstTap) { 32 | // This is the first tap in a potential double-tap sequence 33 | isFirstTap = false 34 | lastTapTime = currentTime 35 | return false 36 | } else if (timeSinceLastTap < DOUBLE_TAP_TIMEOUT) { 37 | // This is the second tap, within the timeout window 38 | // Reset for next sequence 39 | resetTapState() 40 | return true 41 | } else { 42 | // Too much time has passed, start a new sequence 43 | lastTapTime = currentTime 44 | // Keep isFirstTap as false since this is a new first tap 45 | return false 46 | } 47 | } 48 | 49 | /** 50 | * Reset the tap state to initial values 51 | * This can be called explicitly when network errors occur to ensure 52 | * tap detection works properly on the next attempt 53 | */ 54 | fun resetTapState() { 55 | isFirstTap = true 56 | lastTapTime = 0L 57 | } 58 | 59 | /** 60 | * Get a PendingIntent to launch the main app 61 | */ 62 | fun getLaunchAppIntent(context: Context): PendingIntent { 63 | val intent = Intent(context, MainActivity::class.java).apply { 64 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or 65 | Intent.FLAG_ACTIVITY_SINGLE_TOP or 66 | Intent.FLAG_ACTIVITY_CLEAR_TOP 67 | } 68 | return PendingIntent.getActivity( 69 | context, 70 | 0, 71 | intent, 72 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE 73 | ) 74 | } 75 | 76 | /** 77 | * Ensure essential services are initialized for widget updates 78 | * This is important when the app is killed but widgets need to refresh 79 | */ 80 | @Synchronized 81 | fun ensureInitialized(context: Context) { 82 | if (!serviceInitialized) { 83 | try { 84 | // Initialize settings repository 85 | SettingsRepository.getInstance(context) 86 | 87 | // Try to initialize network client if needed 88 | if (!NetworkClient.isInitialized.value) { 89 | try { 90 | NetworkClient.initialize(context) 91 | } catch (e: Exception) { 92 | // If main client fails, we'll fall back to WidgetNetworkClient 93 | e.printStackTrace() 94 | } 95 | } 96 | 97 | serviceInitialized = true 98 | } catch (e: Exception) { 99 | e.printStackTrace() 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/res/mempal-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 | 33 | 43 | 44 | 45 | 55 | 56 | 57 | 61 | 65 | 70 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | } 5 | 6 | android { 7 | namespace = "com.example.mempal" 8 | 9 | dependenciesInfo { 10 | // Disables dependency metadata when building APKs. 11 | includeInApk = false 12 | // Disables dependency metadata when building Android App Bundles. 13 | includeInBundle = false 14 | } 15 | 16 | compileSdk = 35 17 | 18 | defaultConfig { 19 | applicationId = "com.example.mempal" 20 | minSdk = 24 21 | targetSdk = 35 22 | versionCode = 16 23 | versionName = "1.5.4" 24 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 25 | multiDexEnabled = true 26 | } 27 | 28 | buildTypes { 29 | debug { 30 | isMinifyEnabled = false 31 | isShrinkResources = false 32 | } 33 | release { 34 | isMinifyEnabled = true 35 | isShrinkResources = true 36 | proguardFiles( 37 | getDefaultProguardFile("proguard-android-optimize.txt"), 38 | "proguard-rules.pro" 39 | ) 40 | signingConfig = signingConfigs.getByName("debug") 41 | } 42 | } 43 | 44 | buildFeatures { 45 | compose = true 46 | buildConfig = true 47 | } 48 | 49 | composeOptions { 50 | kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() 51 | } 52 | 53 | compileOptions { 54 | sourceCompatibility = JavaVersion.VERSION_17 55 | targetCompatibility = JavaVersion.VERSION_17 56 | } 57 | 58 | kotlinOptions { 59 | jvmTarget = "17" 60 | freeCompilerArgs += listOf( 61 | "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" 62 | ) 63 | } 64 | 65 | packaging { 66 | resources { 67 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 68 | } 69 | } 70 | } 71 | 72 | dependencies { 73 | implementation(libs.androidx.core.ktx) 74 | implementation(libs.androidx.lifecycle.runtime.ktx) 75 | implementation(libs.androidx.activity.compose) 76 | implementation(platform(libs.androidx.compose.bom)) 77 | implementation(libs.androidx.ui) 78 | implementation(libs.androidx.ui.graphics) 79 | implementation(libs.androidx.ui.tooling.preview) 80 | implementation(libs.androidx.material3) 81 | implementation(libs.androidx.constraintlayout) 82 | implementation(libs.pull.refresh) 83 | 84 | testImplementation(libs.junit) 85 | androidTestImplementation(libs.androidx.junit) 86 | androidTestImplementation(libs.androidx.espresso.core) 87 | androidTestImplementation(platform(libs.androidx.compose.bom)) 88 | //noinspection UseTomlInstead 89 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 90 | //noinspection UseTomlInstead 91 | debugImplementation("androidx.compose.ui:ui-tooling") 92 | //noinspection UseTomlInstead 93 | debugImplementation("androidx.compose.ui:ui-test-manifest") 94 | 95 | implementation(libs.androidx.lifecycle.viewmodel) 96 | implementation(libs.androidx.runtime.livedata) 97 | 98 | // Retrofit 99 | implementation(libs.retrofit) 100 | implementation(libs.retrofit.converter.gson) 101 | implementation(libs.okhttp) 102 | implementation(libs.okhttp.logging) 103 | 104 | // For experimental Material APIs 105 | implementation(libs.androidx.material3) 106 | 107 | // Material icons extended 108 | implementation(libs.androidx.material.icons) 109 | implementation(libs.androidx.runtime.livedata) 110 | 111 | implementation(libs.androidx.core.splashscreen) 112 | implementation("androidx.multidex:multidex:2.0.1") 113 | implementation(libs.androidx.core.ktx) 114 | implementation("androidx.appcompat:appcompat:1.7.0") 115 | 116 | implementation(libs.tor.android) 117 | implementation(libs.jtorctl) 118 | implementation(libs.androidx.localbroadcastmanager) 119 | 120 | // WorkManager 121 | implementation("androidx.work:work-runtime-ktx:2.10.1") 122 | implementation("androidx.work:work-runtime:2.10.1") 123 | } 124 | 125 | tasks.whenTaskAdded { 126 | if (name.contains("ArtProfile") || name.contains("BaselineProfile") || name.contains("baseline")) { 127 | enabled = false 128 | } 129 | } 130 | 131 | // Disable baseline profile generation tasks 132 | tasks.configureEach { 133 | if (name.contains("ArtProfile") || name.contains("BaselineProfile") || name.contains("baseline")) { 134 | enabled = false 135 | } 136 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/fee_rates_widget.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 19 | 20 | 26 | 27 | 32 | 33 | 40 | 41 | 48 | 49 | 50 | 51 | 57 | 58 | 64 | 65 | 70 | 71 | 78 | 79 | 86 | 87 | 88 | 89 | 95 | 96 | 102 | 103 | 108 | 109 | 116 | 117 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/WidgetUpdateWorker.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.app.PendingIntent 4 | import android.appwidget.AppWidgetManager 5 | import android.appwidget.AppWidgetProvider 6 | import android.content.ComponentName 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.os.PowerManager 10 | import android.util.Log 11 | import androidx.work.CoroutineWorker 12 | import androidx.work.WorkerParameters 13 | import kotlinx.coroutines.* 14 | 15 | class WidgetUpdateWorker( 16 | private val appContext: Context, 17 | params: WorkerParameters 18 | ) : CoroutineWorker(appContext, params) { 19 | private val tag = "WidgetUpdateWorker" 20 | 21 | override suspend fun doWork(): Result = withContext(Dispatchers.IO) { 22 | var wakeLock: PowerManager.WakeLock? = null 23 | try { 24 | // Initialize important services that would normally be initialized by the app 25 | initializeServices(appContext) 26 | 27 | // Check battery status for adaptive behavior 28 | val isUnderRestrictions = WidgetUpdater.checkUpdateRestrictions(appContext) 29 | val updateDelay = if (isUnderRestrictions) 300L else 500L // Faster updates when under restrictions 30 | 31 | // Acquire wake lock with adaptive timeout 32 | wakeLock = WidgetUpdater.acquireWakeLock(appContext) 33 | 34 | // Check if we have widgets that need updating 35 | val appWidgetManager = AppWidgetManager.getInstance(appContext) 36 | val widgetsToUpdate = mutableListOf, String>>() 37 | 38 | // Check each widget type 39 | val widgetTypes = listOf( 40 | BlockHeightWidget::class.java to BlockHeightWidget.REFRESH_ACTION, 41 | MempoolSizeWidget::class.java to MempoolSizeWidget.REFRESH_ACTION, 42 | FeeRatesWidget::class.java to FeeRatesWidget.REFRESH_ACTION, 43 | CombinedStatsWidget::class.java to CombinedStatsWidget.REFRESH_ACTION 44 | ) 45 | 46 | for ((widgetClass, action) in widgetTypes) { 47 | val widgetComponent = ComponentName(appContext, widgetClass) 48 | val widgetIds = appWidgetManager.getAppWidgetIds(widgetComponent) 49 | if (widgetIds.isNotEmpty()) { 50 | widgetsToUpdate.add(widgetClass to action) 51 | } 52 | } 53 | 54 | // If no widgets to update, just return success 55 | if (widgetsToUpdate.isEmpty()) { 56 | Log.d(tag, "No widgets found to update") 57 | return@withContext Result.success() 58 | } 59 | 60 | // Log update attempt for debugging 61 | Log.d(tag, "Updating ${widgetsToUpdate.size} widget types") 62 | 63 | // Update each type of widget with a delay between them 64 | widgetsToUpdate.forEachIndexed { index, (widgetClass, action) -> 65 | updateWidget(appWidgetManager, widgetClass, action, index) 66 | if (index < widgetsToUpdate.size - 1) { 67 | delay(updateDelay) 68 | } 69 | } 70 | 71 | Result.success() 72 | } catch (e: Exception) { 73 | Log.e(tag, "Error updating widgets", e) 74 | e.printStackTrace() 75 | 76 | // Only retry if we have a network error, not for other exceptions 77 | if (e is java.net.UnknownHostException || 78 | e is java.net.SocketTimeoutException || 79 | e is java.io.IOException) { 80 | Result.retry() 81 | } else { 82 | Result.failure() 83 | } 84 | } finally { 85 | // Release wake lock 86 | WidgetUpdater.releaseWakeLock(wakeLock) 87 | } 88 | } 89 | 90 | private fun initializeServices(context: Context) { 91 | try { 92 | // This initialization is important for widget updates when app is killed 93 | // Initialize here in case the app's Application class hasn't initialized these 94 | WidgetUtils.ensureInitialized(context) 95 | } catch (e: Exception) { 96 | Log.e(tag, "Error initializing services: ${e.message}") 97 | e.printStackTrace() 98 | } 99 | } 100 | 101 | private fun updateWidget( 102 | appWidgetManager: AppWidgetManager, 103 | widgetClass: Class, 104 | action: String, 105 | requestCode: Int 106 | ) { 107 | try { 108 | val widgetComponent = ComponentName(appContext, widgetClass) 109 | val widgetIds = appWidgetManager.getAppWidgetIds(widgetComponent) 110 | 111 | if (widgetIds.isNotEmpty()) { 112 | val refreshIntent = Intent(appContext, widgetClass).apply { 113 | this.action = action 114 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_INCLUDE_STOPPED_PACKAGES) 115 | } 116 | 117 | PendingIntent.getBroadcast( 118 | appContext, 119 | requestCode, 120 | refreshIntent, 121 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 122 | ).send() 123 | } 124 | } catch (e: Exception) { 125 | Log.e(tag, "Error updating widget: ${widgetClass.simpleName}", e) 126 | e.printStackTrace() 127 | } 128 | } 129 | 130 | companion object { 131 | const val WORK_NAME = "widget_update_work" 132 | } 133 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Keep your model classes and their fields 24 | -keep class com.example.mempal.api.** { *; } 25 | -keep class com.example.mempal.models.** { *; } 26 | -keep class com.example.mempal.model.** { *; } 27 | -keep class com.example.mempal.repository.** { *; } 28 | -keepclassmembers class com.example.mempal.api.** { *; } 29 | -keepclassmembers class com.example.mempal.models.** { *; } 30 | -keepclassmembers class com.example.mempal.model.** { *; } 31 | -keepclassmembers class com.example.mempal.repository.** { *; } 32 | 33 | # Keep specific model classes 34 | -keep class com.example.mempal.api.FeeRates { *; } 35 | -keep class com.example.mempal.api.MempoolInfo { *; } 36 | -keep class com.example.mempal.api.BlockInfo { *; } 37 | -keep class com.example.mempal.api.TransactionResponse { *; } 38 | -keep class com.example.mempal.api.TransactionStatus { *; } 39 | -keep class com.example.mempal.model.NotificationSettings { *; } 40 | -keep class com.example.mempal.model.FeeRateType { *; } 41 | 42 | # Keep NetworkClient and its dependencies 43 | -keep class com.example.mempal.api.NetworkClient { *; } 44 | -keep class com.example.mempal.api.WidgetNetworkClient { *; } 45 | -keepclassmembers class com.example.mempal.api.NetworkClient { *; } 46 | -keepclassmembers class com.example.mempal.api.WidgetNetworkClient { *; } 47 | 48 | # Keep MempoolApi interface and its methods 49 | -keep interface com.example.mempal.api.MempoolApi { *; } 50 | -keepclassmembers interface com.example.mempal.api.MempoolApi { *; } 51 | 52 | # Keep Android system classes 53 | -keep class android.net.ConnectivityManager { *; } 54 | -keep class android.net.Network { *; } 55 | -keep class android.net.NetworkCapabilities { *; } 56 | -keep class android.net.NetworkRequest { *; } 57 | -keep class android.net.NetworkRequest$Builder { *; } 58 | -keep class android.content.SharedPreferences { *; } 59 | -keep class android.content.SharedPreferences$Editor { *; } 60 | 61 | # Keep WeakReference and other essential Java classes 62 | -keep class java.lang.ref.WeakReference { *; } 63 | -keep class java.net.InetSocketAddress { *; } 64 | -keep class java.net.Proxy { *; } 65 | -keep class java.net.Proxy$Type { *; } 66 | 67 | # Retrofit 68 | -keepattributes Signature 69 | -keepattributes *Annotation* 70 | -keep class retrofit2.** { *; } 71 | -keepclasseswithmembers class * { 72 | @retrofit2.http.* ; 73 | } 74 | -keepclassmembers,allowshrinking,allowobfuscation interface * { 75 | @retrofit2.http.* ; 76 | } 77 | 78 | # OkHttp 79 | -keepattributes Signature 80 | -keepattributes *Annotation* 81 | -keep class okhttp3.** { *; } 82 | -keep interface okhttp3.** { *; } 83 | -dontwarn okhttp3.** 84 | -dontwarn okio.** 85 | 86 | # Gson 87 | -keep class com.google.gson.** { *; } 88 | -keep class * implements com.google.gson.TypeAdapterFactory 89 | -keep class * implements com.google.gson.JsonSerializer 90 | -keep class * implements com.google.gson.JsonDeserializer 91 | -keepclassmembers,allowobfuscation class * { 92 | @com.google.gson.annotations.SerializedName ; 93 | } 94 | 95 | # Kotlin Coroutines 96 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} 97 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} 98 | -keepclassmembernames class kotlinx.** { 99 | volatile ; 100 | } 101 | -keep class kotlinx.coroutines.** { *; } 102 | -keep class kotlin.** { *; } 103 | 104 | # WorkManager 105 | -keepclassmembers class * extends androidx.work.Worker { 106 | public (android.content.Context,androidx.work.WorkerParameters); 107 | } 108 | 109 | # Tor 110 | -keep class org.torproject.** { *; } 111 | -keep class net.freehaven.tor.control.** { *; } 112 | 113 | # Keep source file names and line numbers for better crash reports 114 | -keepattributes SourceFile,LineNumberTable 115 | -keepattributes Exceptions,InnerClasses 116 | 117 | # Keep Retrofit service methods 118 | -keepclassmembernames,allowobfuscation interface * { 119 | @retrofit2.http.* ; 120 | } 121 | 122 | # Keep generic signatures and annotations 123 | -keepattributes Signature 124 | -keepattributes RuntimeVisibleAnnotations 125 | -keepattributes RuntimeInvisibleAnnotations 126 | -keepattributes RuntimeVisibleParameterAnnotations 127 | -keepattributes RuntimeInvisibleParameterAnnotations 128 | 129 | # Keep Enum members 130 | -keepclassmembers enum * { 131 | public static **[] values(); 132 | public static ** valueOf(java.lang.String); 133 | } 134 | 135 | # Keep StateFlow and related classes 136 | -keep class kotlinx.coroutines.flow.StateFlow { *; } 137 | -keep class kotlinx.coroutines.flow.MutableStateFlow { *; } 138 | -keep class kotlinx.coroutines.flow.SharedFlow { *; } 139 | -keep class kotlinx.coroutines.flow.Flow { *; } 140 | 141 | # If you keep the line number information, uncomment this to 142 | # hide the original source file name. 143 | #-renamesourcefileattribute SourceFile 144 | 145 | # Additional Custom Optimization Settings 146 | -allowaccessmodification 147 | -mergeinterfacesaggressively 148 | -optimizationpasses 5 149 | -overloadaggressively -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.api 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import android.util.Log 7 | import com.example.mempal.repository.SettingsRepository 8 | import com.google.gson.GsonBuilder 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | import retrofit2.Retrofit 12 | import retrofit2.converter.gson.GsonConverterFactory 13 | import java.util.concurrent.TimeUnit 14 | 15 | object WidgetNetworkClient { 16 | private const val TIMEOUT_SECONDS = 10L 17 | private const val DEFAULT_API_URL = "https://mempool.space" 18 | private var retrofit: Retrofit? = null 19 | private var mempoolApi: MempoolApi? = null 20 | private var isNetworkAvailable = false 21 | 22 | private val loggingInterceptor = HttpLoggingInterceptor().apply { 23 | level = HttpLoggingInterceptor.Level.BODY 24 | } 25 | 26 | fun getMempoolApi(context: Context): MempoolApi { 27 | // Update network status 28 | updateNetworkStatus(context) 29 | 30 | // Check if we have a valid API instance 31 | mempoolApi?.let { api -> 32 | if (!hasUrlChanged(context)) { 33 | return api 34 | } 35 | } 36 | 37 | // Create new API instance 38 | return createMempoolApi(context).also { 39 | mempoolApi = it 40 | } 41 | } 42 | 43 | /** 44 | * Check if network connection is available 45 | * @param context Application context 46 | * @return true if network is available, false otherwise 47 | */ 48 | fun isNetworkAvailable(context: Context): Boolean { 49 | updateNetworkStatus(context) 50 | return isNetworkAvailable 51 | } 52 | 53 | /** 54 | * Reset all cached network status and API instances. 55 | * This is useful when network connectivity is restored 56 | * to ensure fresh connections are established. 57 | */ 58 | fun resetCache() { 59 | // Reset network state 60 | isNetworkAvailable = false 61 | 62 | // Reset API instance to force recreation 63 | mempoolApi = null 64 | retrofit = null 65 | 66 | Log.d("WidgetNetworkClient", "Network cache reset") 67 | } 68 | 69 | private fun updateNetworkStatus(context: Context) { 70 | try { 71 | val connectivityManager = 72 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager 73 | 74 | val network = connectivityManager?.activeNetwork 75 | val capabilities = connectivityManager?.getNetworkCapabilities(network) 76 | 77 | isNetworkAvailable = capabilities != null && 78 | (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || 79 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || 80 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) && 81 | capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && 82 | capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) 83 | } catch (e: Exception) { 84 | e.printStackTrace() 85 | isNetworkAvailable = false 86 | } 87 | } 88 | 89 | private fun hasUrlChanged(context: Context): Boolean { 90 | val settingsRepository = SettingsRepository.getInstance(context) 91 | val currentUrl = settingsRepository.getApiUrl() 92 | return retrofit?.baseUrl()?.toString()?.contains(currentUrl) != true 93 | } 94 | 95 | private fun createMempoolApi(context: Context): MempoolApi { 96 | val settingsRepository = SettingsRepository.getInstance(context) 97 | val userApiUrl = settingsRepository.getApiUrl() 98 | 99 | // If the user's custom server is a .onion address, use the default mempool.space 100 | val baseUrl = if (userApiUrl.contains(".onion")) { 101 | DEFAULT_API_URL 102 | } else { 103 | userApiUrl 104 | }.let { url -> 105 | if (!url.endsWith("/")) "$url/" else url 106 | } 107 | 108 | val clientBuilder = OkHttpClient.Builder() 109 | .addInterceptor(loggingInterceptor) 110 | .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) 111 | .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) 112 | .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) 113 | .retryOnConnectionFailure(true) 114 | .addInterceptor { chain -> 115 | // Check network availability before proceeding 116 | if (!isNetworkAvailable) { 117 | throw java.io.IOException("No network connection available") 118 | } 119 | 120 | try { 121 | var attempt = 0 122 | var response = chain.proceed(chain.request()) 123 | while (!response.isSuccessful && attempt < 2) { 124 | attempt++ 125 | response.close() 126 | response = chain.proceed(chain.request()) 127 | } 128 | response 129 | } catch (e: java.net.UnknownHostException) { 130 | // Convert DNS resolution failures to a standard IOException 131 | // This prevents crashes and allows proper error handling 132 | isNetworkAvailable = false // Update our network state 133 | throw java.io.IOException("Unable to access network: DNS resolution failed", e) 134 | } catch (e: java.net.SocketTimeoutException) { 135 | // Also handle timeout exceptions similarly 136 | throw java.io.IOException("Network connection timed out", e) 137 | } 138 | } 139 | 140 | val gson = GsonBuilder() 141 | .setLenient() 142 | .create() 143 | 144 | retrofit = Retrofit.Builder() 145 | .baseUrl(baseUrl) 146 | .client(clientBuilder.build()) 147 | .addConverterFactory(GsonConverterFactory.create(gson)) 148 | .build() 149 | 150 | return retrofit!!.create(MempoolApi::class.java) 151 | } 152 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 52 | 57 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 82 | 83 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 95 | 96 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 108 | 109 | 110 | 111 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 32 | 37 | 41 | 42 | 48 | 49 | 50 | 51 | 52 | 53 | 58 | 63 | 68 | 73 | 80 | 87 | 94 | 101 | 108 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 148 | 151 | 154 | 157 | 160 | 163 | 166 | 169 | 172 | 175 | 178 | 181 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/WidgetUpdater.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.IntentFilter 6 | import android.os.BatteryManager 7 | import android.os.PowerManager 8 | import android.os.SystemClock 9 | import android.util.Log 10 | import androidx.work.* 11 | import com.example.mempal.repository.SettingsRepository 12 | import java.util.concurrent.TimeUnit 13 | 14 | object WidgetUpdater { 15 | private const val WAKE_LOCK_TAG = "mempal:widget_update_wake_lock" 16 | private const val MIN_UPDATE_INTERVAL = 15L // Minimum update interval in minutes 17 | private const val ONE_TIME_WORK_NAME = "widget_one_time_update_work" 18 | private const val DELAYED_WORK_NAME = "widget_delayed_update_work" 19 | private const val MIN_UPDATE_THRESHOLD = 5 * 60 * 1000L // 5 minutes in milliseconds 20 | private const val TAG = "WidgetUpdater" 21 | 22 | // Track the last time widgets were updated 23 | private var lastUpdateTime = 0L 24 | 25 | // Check if enough time has passed since last update to allow a system-event triggered update 26 | fun shouldUpdate(context: Context? = null): Boolean { 27 | val currentTime = SystemClock.elapsedRealtime() 28 | val baseThreshold = MIN_UPDATE_THRESHOLD 29 | 30 | // Adapt threshold based on battery state if context is available 31 | val threshold = if (context != null) { 32 | val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) 33 | val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 34 | val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 35 | val batteryStatus = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 36 | 37 | val batteryPct = if (level >= 0 && scale > 0) level * 100 / scale else -1 38 | val isCharging = batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING || 39 | batteryStatus == BatteryManager.BATTERY_STATUS_FULL 40 | 41 | // If charging or high battery, allow more frequent updates 42 | when { 43 | isCharging -> baseThreshold / 2 // Half the threshold if charging 44 | batteryPct > 80 -> baseThreshold * 3 / 4 // 75% of threshold if battery > 80% 45 | batteryPct < 20 -> baseThreshold * 2 // Double threshold if battery < 20% 46 | else -> baseThreshold 47 | } 48 | } else { 49 | baseThreshold 50 | } 51 | 52 | return (currentTime - lastUpdateTime) > threshold 53 | } 54 | 55 | fun scheduleUpdates(context: Context) { 56 | val settingsRepository = SettingsRepository.getInstance(context) 57 | val updateInterval = settingsRepository.getUpdateFrequency().coerceAtLeast(MIN_UPDATE_INTERVAL) 58 | 59 | // Cancel any existing work first 60 | WorkManager.getInstance(context).cancelUniqueWork(WidgetUpdateWorker.WORK_NAME) 61 | WorkManager.getInstance(context).cancelUniqueWork(ONE_TIME_WORK_NAME) 62 | WorkManager.getInstance(context).cancelUniqueWork(DELAYED_WORK_NAME) 63 | 64 | // Create standard network constraints 65 | val constraints = Constraints.Builder() 66 | .setRequiredNetworkType(NetworkType.CONNECTED) 67 | .build() 68 | 69 | val inputData = workDataOf( 70 | "wake_lock_tag" to WAKE_LOCK_TAG 71 | ) 72 | 73 | // 1. Schedule periodic updates with flexible interval 74 | val updateRequest = PeriodicWorkRequestBuilder( 75 | updateInterval, 76 | TimeUnit.MINUTES, 77 | (updateInterval / 5).coerceAtLeast(5), // Increased flexibility window 78 | TimeUnit.MINUTES 79 | ) 80 | .setConstraints(constraints) 81 | .setInputData(inputData) 82 | .setBackoffCriteria( 83 | BackoffPolicy.LINEAR, 84 | WorkRequest.MIN_BACKOFF_MILLIS, 85 | TimeUnit.MILLISECONDS 86 | ) 87 | .build() 88 | 89 | WorkManager.getInstance(context).enqueueUniquePeriodicWork( 90 | WidgetUpdateWorker.WORK_NAME, 91 | ExistingPeriodicWorkPolicy.UPDATE, 92 | updateRequest 93 | ) 94 | 95 | // 2a. Schedule an immediate expedited work without delay 96 | val expeditedRequest = OneTimeWorkRequestBuilder() 97 | .setConstraints(constraints) 98 | .setInputData(inputData) 99 | .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) 100 | .build() 101 | 102 | WorkManager.getInstance(context).enqueueUniqueWork( 103 | ONE_TIME_WORK_NAME, 104 | ExistingWorkPolicy.REPLACE, 105 | expeditedRequest 106 | ) 107 | 108 | // 2b. Also schedule a regular (non-expedited) delayed job 109 | try { 110 | val delayTime = (updateInterval / 2).coerceAtMost(30) 111 | Log.d(TAG, "Scheduling delayed update for $delayTime minutes from now") 112 | 113 | val delayedRequest = OneTimeWorkRequestBuilder() 114 | .setConstraints(constraints) 115 | .setInputData(inputData) 116 | .setInitialDelay(delayTime, TimeUnit.MINUTES) 117 | .build() 118 | 119 | WorkManager.getInstance(context).enqueueUniqueWork( 120 | DELAYED_WORK_NAME, 121 | ExistingWorkPolicy.REPLACE, 122 | delayedRequest 123 | ) 124 | } catch (e: Exception) { 125 | Log.e(TAG, "Error scheduling delayed update", e) 126 | } 127 | 128 | // Update the last update time 129 | lastUpdateTime = SystemClock.elapsedRealtime() 130 | } 131 | 132 | fun requestImmediateUpdate(context: Context, force: Boolean = false) { 133 | // Only request an update if it's been at least 1 minute since the last update 134 | // unless force is true 135 | val currentTime = SystemClock.elapsedRealtime() 136 | if (force || (currentTime - lastUpdateTime) > 60000) { 137 | val constraints = Constraints.Builder() 138 | .setRequiredNetworkType(NetworkType.CONNECTED) 139 | .build() 140 | 141 | val expeditedRequest = OneTimeWorkRequestBuilder() 142 | .setConstraints(constraints) 143 | .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) 144 | .build() 145 | 146 | WorkManager.getInstance(context).enqueueUniqueWork( 147 | "widget_immediate_update", 148 | ExistingWorkPolicy.REPLACE, 149 | expeditedRequest 150 | ) 151 | 152 | lastUpdateTime = currentTime 153 | } 154 | } 155 | 156 | // Check if updates are likely to be restricted based on battery status 157 | fun checkUpdateRestrictions(context: Context): Boolean { 158 | val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) 159 | val batteryStatus = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 160 | val isCharging = batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING || 161 | batteryStatus == BatteryManager.BATTERY_STATUS_FULL 162 | 163 | val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager 164 | val isPowerSaveMode = powerManager.isPowerSaveMode 165 | 166 | // If device is charging or not in power save mode, updates should work fine 167 | return !isCharging && isPowerSaveMode 168 | } 169 | 170 | fun cancelUpdates(context: Context) { 171 | WorkManager.getInstance(context).cancelUniqueWork(WidgetUpdateWorker.WORK_NAME) 172 | WorkManager.getInstance(context).cancelUniqueWork(ONE_TIME_WORK_NAME) 173 | WorkManager.getInstance(context).cancelUniqueWork(DELAYED_WORK_NAME) 174 | } 175 | 176 | fun acquireWakeLock(context: Context): PowerManager.WakeLock? { 177 | return try { 178 | val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager 179 | powerManager.newWakeLock( 180 | PowerManager.PARTIAL_WAKE_LOCK, 181 | WAKE_LOCK_TAG 182 | ).apply { 183 | acquire(1 * 60 * 1000L) // Reduced to 1 minute max to be more battery-friendly 184 | } 185 | } catch (e: Exception) { 186 | e.printStackTrace() 187 | null 188 | } 189 | } 190 | 191 | fun releaseWakeLock(wakeLock: PowerManager.WakeLock?) { 192 | try { 193 | if (wakeLock?.isHeld == true) { 194 | wakeLock.release() 195 | } 196 | } catch (e: Exception) { 197 | e.printStackTrace() 198 | } 199 | } 200 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /app/src/main/res/layout/combined_stats_widget.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 19 | 20 | 29 | 30 | 42 | 43 | 51 | 52 | 61 | 62 | 63 | 71 | 72 | 84 | 85 | 93 | 94 | 103 | 104 | 105 | 106 | 113 | 114 | 120 | 121 | 127 | 128 | 134 | 135 | 142 | 143 | 150 | 151 | 152 | 153 | 159 | 160 | 166 | 167 | 173 | 174 | 181 | 182 | 189 | 190 | 191 | 192 | 198 | 199 | 205 | 206 | 212 | 213 | 220 | 221 | 228 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/NetworkConnectivityReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.appwidget.AppWidgetProvider 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.IntentFilter 8 | import android.net.ConnectivityManager 9 | import android.net.Network 10 | import android.net.NetworkCapabilities 11 | import android.net.NetworkRequest 12 | import android.os.PowerManager 13 | import android.util.Log 14 | import com.example.mempal.api.WidgetNetworkClient 15 | 16 | /** 17 | * BroadcastReceiver that monitors network connectivity changes and power save mode changes 18 | * and updates widgets when internet is restored or power save mode changes 19 | */ 20 | class NetworkConnectivityReceiver : BroadcastReceiver() { 21 | companion object { 22 | private const val TAG = "NetworkReceiver" 23 | private var wasOffline = false 24 | private var wasInPowerSaveMode = false 25 | private var networkCallbackRegistered = false 26 | private var powerSaveModeReceiverRegistered = false 27 | 28 | /** 29 | * Register the network callback to monitor connectivity changes 30 | */ 31 | fun registerNetworkCallback(context: Context) { 32 | if (networkCallbackRegistered) return 33 | 34 | try { 35 | val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 36 | 37 | val networkCallback = object : ConnectivityManager.NetworkCallback() { 38 | override fun onAvailable(network: Network) { 39 | // When a network becomes available 40 | Log.d(TAG, "Network available") 41 | 42 | // Check if internet is actually accessible 43 | val capabilities = connectivityManager.getNetworkCapabilities(network) 44 | val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true && 45 | capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) 46 | 47 | if (hasInternet && wasOffline) { 48 | Log.d(TAG, "Internet restored, refreshing widgets") 49 | wasOffline = false 50 | 51 | // Reset NetworkClient state 52 | WidgetNetworkClient.resetCache() 53 | 54 | // Reset tap state 55 | WidgetUtils.resetTapState() 56 | 57 | // Request immediate widget update 58 | refreshAllWidgets(context) 59 | } 60 | } 61 | 62 | override fun onLost(network: Network) { 63 | // When a network is lost 64 | Log.d(TAG, "Network lost") 65 | wasOffline = true 66 | } 67 | } 68 | 69 | val networkRequest = NetworkRequest.Builder() 70 | .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 71 | .build() 72 | 73 | connectivityManager.registerNetworkCallback(networkRequest, networkCallback) 74 | networkCallbackRegistered = true 75 | 76 | // Initialize the wasOffline state based on current connectivity 77 | wasOffline = !WidgetNetworkClient.isNetworkAvailable(context) 78 | 79 | Log.d(TAG, "Network callback registered, initial offline state: $wasOffline") 80 | 81 | // Also register power save mode receiver 82 | registerPowerSaveModeReceiver(context) 83 | } catch (e: Exception) { 84 | Log.e(TAG, "Error registering network callback", e) 85 | } 86 | } 87 | 88 | /** 89 | * Register a receiver for power save mode changes 90 | */ 91 | private fun registerPowerSaveModeReceiver(context: Context) { 92 | if (powerSaveModeReceiverRegistered) return 93 | 94 | try { 95 | // Get current power save mode state 96 | val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager 97 | wasInPowerSaveMode = powerManager.isPowerSaveMode 98 | 99 | // Create and register the receiver 100 | val powerSaveReceiver = object : BroadcastReceiver() { 101 | override fun onReceive(context: Context, intent: Intent) { 102 | if (intent.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) { 103 | val powerManagerService = context.getSystemService(Context.POWER_SERVICE) as PowerManager 104 | val isInPowerSaveMode = powerManagerService.isPowerSaveMode 105 | 106 | if (isInPowerSaveMode != wasInPowerSaveMode) { 107 | Log.d(TAG, "Power save mode changed: $isInPowerSaveMode") 108 | wasInPowerSaveMode = isInPowerSaveMode 109 | 110 | // Reset tap state to fix any unresponsive widgets 111 | WidgetUtils.resetTapState() 112 | 113 | // Reset network client cache 114 | WidgetNetworkClient.resetCache() 115 | 116 | // Refresh widgets to adapt to new power mode 117 | refreshAllWidgets(context) 118 | } 119 | } 120 | } 121 | } 122 | 123 | val filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) 124 | context.applicationContext.registerReceiver(powerSaveReceiver, filter) 125 | powerSaveModeReceiverRegistered = true 126 | 127 | Log.d(TAG, "Power save mode receiver registered, initial state: $wasInPowerSaveMode") 128 | } catch (e: Exception) { 129 | Log.e(TAG, "Error registering power save mode receiver", e) 130 | } 131 | } 132 | 133 | /** 134 | * Refresh all widget types 135 | */ 136 | private fun refreshAllWidgets(context: Context) { 137 | refreshWidgetType(context, BlockHeightWidget::class.java, BlockHeightWidget.REFRESH_ACTION) 138 | refreshWidgetType(context, MempoolSizeWidget::class.java, MempoolSizeWidget.REFRESH_ACTION) 139 | refreshWidgetType(context, FeeRatesWidget::class.java, FeeRatesWidget.REFRESH_ACTION) 140 | refreshWidgetType(context, CombinedStatsWidget::class.java, CombinedStatsWidget.REFRESH_ACTION) 141 | } 142 | 143 | /** 144 | * Refresh a specific widget type 145 | */ 146 | private fun refreshWidgetType(context: Context, widgetClass: Class, refreshAction: String) { 147 | val intent = Intent(context, widgetClass).apply { 148 | action = refreshAction 149 | addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) 150 | } 151 | context.sendBroadcast(intent) 152 | } 153 | } 154 | 155 | override fun onReceive(context: Context, intent: Intent) { 156 | when (intent.action) { 157 | // Handle connectivity changes 158 | @Suppress("DEPRECATION") // Used for backwards compatibility on older devices 159 | ConnectivityManager.CONNECTIVITY_ACTION -> { 160 | // Use our reliable connectivity check 161 | val hasNetwork = WidgetNetworkClient.isNetworkAvailable(context) 162 | 163 | if (hasNetwork && wasOffline) { 164 | Log.d(TAG, "Internet restored (via broadcast), refreshing widgets") 165 | wasOffline = false 166 | 167 | // Reset NetworkClient state 168 | WidgetNetworkClient.resetCache() 169 | 170 | // Reset tap state 171 | WidgetUtils.resetTapState() 172 | 173 | // Refresh all widgets 174 | refreshAllWidgets(context) 175 | } else if (!hasNetwork) { 176 | wasOffline = true 177 | } 178 | } 179 | 180 | // Handle power save mode changes 181 | PowerManager.ACTION_POWER_SAVE_MODE_CHANGED -> { 182 | val powerManagerService = context.getSystemService(Context.POWER_SERVICE) as PowerManager 183 | val isInPowerSaveMode = powerManagerService.isPowerSaveMode 184 | 185 | if (isInPowerSaveMode != wasInPowerSaveMode) { 186 | Log.d(TAG, "Power save mode changed (via broadcast): $isInPowerSaveMode") 187 | wasInPowerSaveMode = isInPowerSaveMode 188 | 189 | // Reset tap state 190 | WidgetUtils.resetTapState() 191 | 192 | // Reset network client 193 | WidgetNetworkClient.resetCache() 194 | 195 | // Refresh widgets 196 | refreshAllWidgets(context) 197 | } 198 | } 199 | } 200 | } 201 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/tor/TorManager.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.tor 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.SharedPreferences 6 | import android.os.Build 7 | import kotlinx.coroutines.* 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.MutableSharedFlow 11 | import kotlinx.coroutines.flow.SharedFlow 12 | import org.torproject.jni.TorService 13 | import java.io.File 14 | import java.lang.ref.WeakReference 15 | 16 | enum class TorStatus { 17 | DISCONNECTED, 18 | CONNECTING, 19 | CONNECTED, 20 | ERROR 21 | } 22 | 23 | class TorManager private constructor() { 24 | private var torService: TorService? = null 25 | private val _torStatus = MutableStateFlow(TorStatus.DISCONNECTED) 26 | val torStatus: StateFlow = _torStatus 27 | private var dataDir: File? = null 28 | private var prefsRef: WeakReference? = null 29 | private val _proxyReady = MutableStateFlow(false) 30 | private var scope: CoroutineScope? = null 31 | private var isForegroundServiceRunning = false 32 | private var shouldBeTorEnabled = false 33 | private var connectionJob: Job? = null 34 | private val _torConnectionEvent = MutableSharedFlow() 35 | val torConnectionEvent: SharedFlow = _torConnectionEvent 36 | private var lastConnectionAttempt = 0L 37 | private var lastFailureMessage = 0L 38 | private var connectionAttempts = 0 39 | private val maxConnectionAttempts = 20 40 | private val minRetryDelay = 1500L 41 | private val maxRetryDelay = 20000L 42 | private val initialConnectionTimeout = 7000L 43 | private val minTimeBetweenFailures = 30000L 44 | private val maxInitialAttempts = 5 45 | private var isInInitialConnection = true 46 | 47 | companion object { 48 | @Volatile 49 | private var instance: TorManager? = null 50 | private const val ACTION_START = "org.torproject.android.intent.action.START" 51 | private const val ACTION_STOP = "org.torproject.android.intent.action.STOP" 52 | private const val PREFS_NAME = "tor_prefs" 53 | private const val KEY_TOR_ENABLED = "tor_enabled" 54 | 55 | fun getInstance(): TorManager { 56 | return instance ?: synchronized(this) { 57 | instance ?: TorManager().also { instance = it } 58 | } 59 | } 60 | } 61 | 62 | fun initialize(context: Context) { 63 | try { 64 | scope?.cancel() 65 | scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 66 | 67 | val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 68 | prefsRef = WeakReference(prefs) 69 | 70 | val dir = File(context.filesDir, "tor") 71 | if (!dir.exists()) { 72 | dir.mkdirs() 73 | } 74 | dataDir = dir 75 | torService = TorService() 76 | 77 | shouldBeTorEnabled = prefs.getBoolean(KEY_TOR_ENABLED, false) 78 | 79 | if (shouldBeTorEnabled) { 80 | startTor(context) 81 | } 82 | } catch (e: Exception) { 83 | _torStatus.value = TorStatus.ERROR 84 | e.printStackTrace() 85 | } 86 | } 87 | 88 | private suspend fun emitConnectionEvent(connected: Boolean) { 89 | _torConnectionEvent.emit(connected) 90 | } 91 | 92 | fun startTor(context: Context) { 93 | try { 94 | shouldBeTorEnabled = true 95 | _torStatus.value = TorStatus.CONNECTING 96 | connectionAttempts = 0 97 | lastFailureMessage = 0L 98 | isInInitialConnection = true 99 | 100 | val intent = Intent(context, TorService::class.java).apply { 101 | action = ACTION_START 102 | putExtra("directory", dataDir?.absolutePath) 103 | } 104 | context.startService(intent) 105 | 106 | connectionJob?.cancel() 107 | connectionJob = scope?.launch { 108 | var currentDelay = minRetryDelay 109 | var initialAttempts = 0 110 | 111 | while (isActive) { 112 | delay(if (connectionAttempts == 0) initialConnectionTimeout else currentDelay) 113 | 114 | if (connectionAttempts > 0) { 115 | currentDelay = (currentDelay * 1.2).toLong().coerceAtMost(maxRetryDelay) 116 | } 117 | 118 | try { 119 | withContext(Dispatchers.IO) { 120 | val socket = java.net.Socket() 121 | try { 122 | socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 5000) 123 | socket.close() 124 | _torStatus.value = TorStatus.CONNECTED 125 | _proxyReady.value = true 126 | emitConnectionEvent(true) 127 | connectionAttempts = 0 128 | initialAttempts = 0 129 | lastConnectionAttempt = System.currentTimeMillis() 130 | lastFailureMessage = 0L 131 | isInInitialConnection = false 132 | return@withContext 133 | } catch (e: Exception) { 134 | socket.close() 135 | throw e 136 | } 137 | } 138 | break 139 | } catch (_: Exception) { 140 | connectionAttempts++ 141 | 142 | if (isInInitialConnection && connectionAttempts <= maxInitialAttempts) { 143 | initialAttempts++ 144 | _torStatus.value = TorStatus.CONNECTING 145 | continue 146 | } 147 | 148 | isInInitialConnection = false 149 | val now = System.currentTimeMillis() 150 | 151 | if (connectionAttempts >= maxConnectionAttempts && 152 | !isInInitialConnection && 153 | (now - lastFailureMessage > minTimeBetweenFailures || lastFailureMessage == 0L)) { 154 | connectionAttempts = maxConnectionAttempts / 2 155 | emitConnectionEvent(false) 156 | lastFailureMessage = now 157 | } 158 | 159 | _torStatus.value = TorStatus.CONNECTING 160 | } 161 | } 162 | } 163 | } catch (e: Exception) { 164 | _torStatus.value = TorStatus.ERROR 165 | _proxyReady.value = false 166 | scope?.launch { emitConnectionEvent(false) } 167 | e.printStackTrace() 168 | } 169 | } 170 | 171 | fun stopTor(context: Context) { 172 | try { 173 | shouldBeTorEnabled = false 174 | isInInitialConnection = true 175 | 176 | connectionJob?.cancel() 177 | connectionJob = null 178 | 179 | val intent = Intent(context, TorService::class.java).apply { 180 | action = ACTION_STOP 181 | } 182 | context.stopService(intent) 183 | 184 | stopForegroundService(context) 185 | 186 | _torStatus.value = TorStatus.DISCONNECTED 187 | _proxyReady.value = false 188 | } catch (e: Exception) { 189 | _torStatus.value = TorStatus.ERROR 190 | e.printStackTrace() 191 | } 192 | } 193 | 194 | fun startForegroundService(context: Context) { 195 | if (!isForegroundServiceRunning && torStatus.value == TorStatus.CONNECTED) { 196 | val foregroundIntent = Intent(context, TorForegroundService::class.java) 197 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 198 | context.startForegroundService(foregroundIntent) 199 | } else { 200 | context.startService(foregroundIntent) 201 | } 202 | isForegroundServiceRunning = true 203 | } 204 | } 205 | 206 | fun stopForegroundService(context: Context) { 207 | if (isForegroundServiceRunning) { 208 | val foregroundIntent = Intent(context, TorForegroundService::class.java) 209 | context.stopService(foregroundIntent) 210 | isForegroundServiceRunning = false 211 | } 212 | } 213 | 214 | fun isTorEnabled(): Boolean { 215 | return prefsRef?.get()?.getBoolean(KEY_TOR_ENABLED, false) == true 216 | } 217 | 218 | fun checkAndRestoreTorConnection(context: Context) { 219 | if (!shouldBeTorEnabled) return 220 | 221 | val now = System.currentTimeMillis() 222 | if (now - lastConnectionAttempt < (minRetryDelay / 2)) return 223 | 224 | scope?.launch { 225 | try { 226 | val torRunning = withContext(Dispatchers.IO) { 227 | try { 228 | val socket = java.net.Socket() 229 | socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 500) 230 | socket.close() 231 | true 232 | } catch (_: Exception) { 233 | false 234 | } 235 | } 236 | 237 | if (!torRunning) { 238 | if (torStatus.value != TorStatus.CONNECTING) { 239 | connectionAttempts = 0 240 | lastFailureMessage = 0L 241 | startTor(context) 242 | } 243 | } else if (torStatus.value != TorStatus.CONNECTED) { 244 | _torStatus.value = TorStatus.CONNECTED 245 | _proxyReady.value = true 246 | emitConnectionEvent(true) 247 | connectionAttempts = 0 248 | lastFailureMessage = 0L 249 | } 250 | } catch (_: Exception) { 251 | if (torStatus.value != TorStatus.CONNECTING) { 252 | connectionAttempts = 0 253 | lastFailureMessage = 0L 254 | startTor(context) 255 | } 256 | } 257 | } 258 | } 259 | 260 | fun cleanup() { 261 | connectionJob?.cancel() 262 | connectionJob = null 263 | scope?.cancel() 264 | scope = null 265 | } 266 | 267 | fun saveTorState(enabled: Boolean) { 268 | prefsRef?.get()?.edit()?.putBoolean(KEY_TOR_ENABLED, enabled)?.apply() 269 | } 270 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.app.PendingIntent 4 | import android.appwidget.AppWidgetManager 5 | import android.appwidget.AppWidgetProvider 6 | import android.content.ComponentName 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.util.Log 10 | import android.widget.RemoteViews 11 | import com.example.mempal.R 12 | import com.example.mempal.api.WidgetNetworkClient 13 | import kotlinx.coroutines.* 14 | 15 | class FeeRatesWidget : AppWidgetProvider() { 16 | companion object { 17 | const val REFRESH_ACTION = "com.example.mempal.REFRESH_FEE_RATES_WIDGET" 18 | private var widgetScope: CoroutineScope? = null 19 | private var activeJobs = mutableMapOf() 20 | private const val TAG = "FeeRatesWidget" 21 | } 22 | 23 | private fun getOrCreateScope(): CoroutineScope { 24 | return widgetScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { widgetScope = it } 25 | } 26 | 27 | override fun onEnabled(context: Context) { 28 | super.onEnabled(context) 29 | getOrCreateScope() // Initialize scope when widget is enabled 30 | 31 | // Ensure services initialized 32 | WidgetUtils.ensureInitialized(context) 33 | 34 | // Register network connectivity monitor 35 | NetworkConnectivityReceiver.registerNetworkCallback(context) 36 | 37 | // Schedule updates 38 | WidgetUpdater.scheduleUpdates(context) 39 | } 40 | 41 | override fun onDisabled(context: Context) { 42 | super.onDisabled(context) 43 | // Only cancel updates if no other widgets are active 44 | val appWidgetManager = AppWidgetManager.getInstance(context) 45 | val blockHeightWidget = ComponentName(context, BlockHeightWidget::class.java) 46 | val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) 47 | val mempoolSizeWidget = ComponentName(context, MempoolSizeWidget::class.java) 48 | 49 | if (appWidgetManager.getAppWidgetIds(blockHeightWidget).isEmpty() && 50 | appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && 51 | appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty()) { 52 | WidgetUpdater.cancelUpdates(context) 53 | // Cancel any ongoing coroutines 54 | activeJobs.values.forEach { it.cancel() } 55 | activeJobs.clear() 56 | widgetScope?.cancel() 57 | widgetScope = null 58 | } 59 | } 60 | 61 | override fun onReceive(context: Context, intent: Intent) { 62 | // Ensure initialization on ANY event to the widget 63 | WidgetUtils.ensureInitialized(context) 64 | 65 | super.onReceive(context, intent) 66 | 67 | // First see if this is a system event handled by the common handler 68 | if (WidgetEventHandler.handleSystemEvent(context, intent, FeeRatesWidget::class.java, REFRESH_ACTION)) { 69 | return 70 | } 71 | 72 | // Otherwise handle widget-specific REFRESH_ACTION 73 | if (intent.action == REFRESH_ACTION) { 74 | if (WidgetUtils.isDoubleTap()) { 75 | // Launch app on double tap 76 | val launchIntent = WidgetUtils.getLaunchAppIntent(context) 77 | launchIntent.send() 78 | } else { 79 | // Single tap - refresh only this widget 80 | Log.d(TAG, "Refresh action received - updating widget") 81 | 82 | // Check for network availability before trying to update 83 | if (!WidgetNetworkClient.isNetworkAvailable(context)) { 84 | Log.d(TAG, "Network unavailable, resetting tap state") 85 | // No network, reset tap state immediately to prevent getting stuck 86 | WidgetUtils.resetTapState() 87 | 88 | // Update widget with error state 89 | val appWidgetManager = AppWidgetManager.getInstance(context) 90 | val thisWidget = ComponentName(context, FeeRatesWidget::class.java) 91 | val widgetIds = appWidgetManager.getAppWidgetIds(thisWidget) 92 | 93 | // Just update the first widget to show error message 94 | if (widgetIds.isNotEmpty()) { 95 | val views = RemoteViews(context.packageName, R.layout.fee_rates_widget) 96 | setErrorState(views) 97 | appWidgetManager.updateAppWidget(widgetIds[0], views) 98 | } 99 | 100 | return 101 | } 102 | 103 | // Network is available, proceed with update 104 | val appWidgetManager = AppWidgetManager.getInstance(context) 105 | val thisWidget = ComponentName(context, FeeRatesWidget::class.java) 106 | onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) 107 | } 108 | } 109 | } 110 | 111 | override fun onUpdate( 112 | context: Context, 113 | appWidgetManager: AppWidgetManager, 114 | appWidgetIds: IntArray 115 | ) { 116 | // Ensure services are initialized 117 | WidgetUtils.ensureInitialized(context) 118 | 119 | // Update each widget 120 | appWidgetIds.forEach { appWidgetId -> 121 | updateAppWidget(context, appWidgetManager, appWidgetId) 122 | } 123 | } 124 | 125 | private fun updateAppWidget( 126 | context: Context, 127 | appWidgetManager: AppWidgetManager, 128 | appWidgetId: Int 129 | ) { 130 | val views = RemoteViews(context.packageName, R.layout.fee_rates_widget) 131 | 132 | // Create refresh intent 133 | val refreshIntent = Intent(context, FeeRatesWidget::class.java).apply { 134 | action = REFRESH_ACTION 135 | // Include flags to make it work better when app is killed 136 | addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) 137 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 138 | } 139 | val refreshPendingIntent = PendingIntent.getBroadcast( 140 | context, 141 | appWidgetId, // Use widget ID as request code for uniqueness 142 | refreshIntent, 143 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // Use FLAG_MUTABLE instead of FLAG_IMMUTABLE 144 | ) 145 | 146 | // Cancel any existing job for this widget 147 | activeJobs[appWidgetId]?.cancel() 148 | 149 | // Set loading state first 150 | setLoadingState(views) 151 | // Set click handler immediately after creating views 152 | views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) 153 | appWidgetManager.updateAppWidget(appWidgetId, views) 154 | 155 | // Create a separate mutable state for views that can be updated by multiple coroutines 156 | val sharedViews = views 157 | var viewsUpdated = false 158 | 159 | // Start new job 160 | activeJobs[appWidgetId] = getOrCreateScope().launch { 161 | var viewsPreparedForData = false 162 | try { 163 | Log.d(TAG, "Starting network request for widget update for ID: $appWidgetId") 164 | val mempoolApi = WidgetNetworkClient.getMempoolApi(context) 165 | 166 | val feeRatesDeferred = async(SupervisorJob() + coroutineContext) { 167 | try { mempoolApi.getFeeRates() } catch (e: Exception) { Log.e(TAG, "Fee rates request failed for $appWidgetId: ${e.message}"); null } 168 | } 169 | 170 | val response = feeRatesDeferred.await() 171 | 172 | if (response != null && response.isSuccessful) { 173 | response.body()?.let { feeRates -> 174 | sharedViews.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") 175 | sharedViews.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") 176 | sharedViews.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") 177 | viewsPreparedForData = true 178 | 179 | // Update widget with data immediately when we have it 180 | if (!viewsUpdated) { 181 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 182 | viewsUpdated = true 183 | Log.d(TAG, "Widget $appWidgetId UI updated with new data") 184 | } 185 | 186 | Log.d(TAG, "Data prepared for widget ID: $appWidgetId") 187 | } ?: run { 188 | Log.w(TAG, "Fee rates response body was null for ID: $appWidgetId") 189 | } 190 | } else { 191 | Log.w(TAG, "Fee rates response unsuccessful or null for ID: $appWidgetId. Code: ${response?.code()}") 192 | } 193 | 194 | if (!viewsPreparedForData) { 195 | setErrorState(sharedViews) 196 | Log.d(TAG, "Error state set for widget ID: $appWidgetId after data fetch attempt") 197 | } 198 | 199 | } catch (e: CancellationException) { 200 | Log.d(TAG, "Job for widget $appWidgetId was cancelled during try block.") 201 | 202 | // Don't throw the exception if we already updated the UI 203 | if (!viewsUpdated && viewsPreparedForData) { 204 | // Final attempt to update UI before exiting 205 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 206 | Log.d(TAG, "Managed to update widget $appWidgetId UI despite job cancellation") 207 | viewsUpdated = true 208 | } 209 | throw e 210 | } catch (e: Exception) { 211 | Log.e(TAG, "Exception during widget update for ID: $appWidgetId", e) 212 | setErrorState(sharedViews) 213 | Log.d(TAG, "Error state set for widget ID: $appWidgetId due to exception") 214 | } finally { 215 | val job = coroutineContext[Job] 216 | if (job?.isCancelled == false || !viewsUpdated) { 217 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 218 | Log.d(TAG, "Final update in finally for widget $appWidgetId. Data: $viewsPreparedForData") 219 | } else { 220 | Log.d(TAG, "Job for widget $appWidgetId was cancelled. UI was already updated in finally.") 221 | } 222 | activeJobs.remove(appWidgetId) 223 | WidgetUtils.resetTapState() 224 | } 225 | } 226 | } 227 | 228 | private fun setLoadingState(views: RemoteViews) { 229 | views.setTextViewText(R.id.priority_fee, "...") 230 | views.setTextViewText(R.id.standard_fee, "...") 231 | views.setTextViewText(R.id.economy_fee, "...") 232 | } 233 | 234 | private fun setErrorState(views: RemoteViews) { 235 | views.setTextViewText(R.id.priority_fee, "?") 236 | views.setTextViewText(R.id.standard_fee, "?") 237 | views.setTextViewText(R.id.economy_fee, "?") 238 | } 239 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/repository/SettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.repository 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import com.example.mempal.model.NotificationSettings 6 | import com.example.mempal.model.FeeRateType 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import java.lang.ref.WeakReference 10 | 11 | class SettingsRepository private constructor(context: Context) { 12 | private val applicationContext = context.applicationContext 13 | private val prefs: SharedPreferences = applicationContext.getSharedPreferences( 14 | "mempal_settings", 15 | Context.MODE_PRIVATE 16 | ) 17 | 18 | private val _settings = MutableStateFlow(loadNotificationSettings()) 19 | val settings: StateFlow = _settings 20 | 21 | companion object { 22 | @Volatile 23 | private var instance: WeakReference? = null 24 | private const val KEY_API_URL = "api_url" 25 | private const val KEY_UPDATE_FREQUENCY = "widget_update_frequency" 26 | private const val DEFAULT_API_URL = "https://mempool.space" 27 | private const val DEFAULT_UPDATE_FREQUENCY = 30L // 30 minutes 28 | private const val KEY_NOTIFICATION_TIME_UNIT = "notification_time_unit" 29 | private const val DEFAULT_TIME_UNIT = "minutes" 30 | private const val KEY_SERVER_NEEDS_RESTART = "server_needs_restart" 31 | private const val KEY_SAVED_SERVERS = "saved_servers" 32 | 33 | // Notification Settings Keys 34 | private const val KEY_BLOCK_NOTIFICATIONS_ENABLED = "block_notifications_enabled" 35 | private const val KEY_BLOCK_CHECK_FREQUENCY = "block_check_frequency" 36 | private const val KEY_NEW_BLOCK_NOTIFICATIONS_ENABLED = "new_block_notifications_enabled" 37 | private const val KEY_NEW_BLOCK_CHECK_FREQUENCY = "new_block_check_frequency" 38 | private const val KEY_SPECIFIC_BLOCK_NOTIFICATIONS_ENABLED = "specific_block_notifications_enabled" 39 | private const val KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY = "specific_block_check_frequency" 40 | private const val KEY_TARGET_BLOCK_HEIGHT = "target_block_height" 41 | private const val KEY_MEMPOOL_SIZE_NOTIFICATIONS_ENABLED = "mempool_size_notifications_enabled" 42 | private const val KEY_MEMPOOL_CHECK_FREQUENCY = "mempool_check_frequency" 43 | private const val KEY_MEMPOOL_SIZE_THRESHOLD = "mempool_size_threshold" 44 | private const val KEY_MEMPOOL_SIZE_ABOVE_THRESHOLD = "mempool_size_above_threshold" 45 | private const val KEY_FEE_RATES_NOTIFICATIONS_ENABLED = "fee_rates_notifications_enabled" 46 | private const val KEY_FEE_RATES_CHECK_FREQUENCY = "fee_rates_check_frequency" 47 | private const val KEY_SELECTED_FEE_RATE_TYPE = "selected_fee_rate_type" 48 | private const val KEY_FEE_RATE_THRESHOLD = "fee_rate_threshold" 49 | private const val KEY_FEE_RATE_ABOVE_THRESHOLD = "fee_rate_above_threshold" 50 | private const val KEY_TX_CONFIRMATION_ENABLED = "tx_confirmation_enabled" 51 | private const val KEY_TX_CONFIRMATION_FREQUENCY = "tx_confirmation_frequency" 52 | private const val KEY_TRANSACTION_ID = "transaction_id" 53 | 54 | private const val KEY_VISIBLE_CARDS = "visible_cards" 55 | private val DEFAULT_VISIBLE_CARDS = setOf( 56 | "Block Height", 57 | "Hashrate", 58 | "Mempool Size", 59 | "Fee Rates", 60 | "Fee Distribution" 61 | ) 62 | private const val KEY_IS_FIRST_LAUNCH = "is_first_launch" 63 | 64 | fun getInstance(context: Context): SettingsRepository { 65 | val currentInstance = instance?.get() 66 | if (currentInstance != null) { 67 | return currentInstance 68 | } 69 | 70 | return synchronized(this) { 71 | val newInstance = SettingsRepository(context) 72 | instance = WeakReference(newInstance) 73 | newInstance 74 | } 75 | } 76 | 77 | fun cleanup() { 78 | synchronized(this) { 79 | instance?.clear() 80 | instance = null 81 | } 82 | } 83 | } 84 | 85 | fun getApiUrl(): String { 86 | return prefs.getString(KEY_API_URL, DEFAULT_API_URL) ?: DEFAULT_API_URL 87 | } 88 | 89 | fun saveApiUrl(url: String) { 90 | prefs.edit() 91 | .putString(KEY_API_URL, url) 92 | .putBoolean(KEY_SERVER_NEEDS_RESTART, true) 93 | .apply() 94 | } 95 | 96 | fun getUpdateFrequency(): Long { 97 | return try { 98 | prefs.getLong(KEY_UPDATE_FREQUENCY, DEFAULT_UPDATE_FREQUENCY) 99 | } catch (_: ClassCastException) { 100 | prefs.getInt(KEY_UPDATE_FREQUENCY, DEFAULT_UPDATE_FREQUENCY.toInt()).toLong() 101 | } 102 | } 103 | 104 | fun saveUpdateFrequency(minutes: Long) { 105 | prefs.edit().putLong(KEY_UPDATE_FREQUENCY, minutes).apply() 106 | } 107 | 108 | fun getNotificationTimeUnit(): String { 109 | return prefs.getString(KEY_NOTIFICATION_TIME_UNIT, DEFAULT_TIME_UNIT) ?: DEFAULT_TIME_UNIT 110 | } 111 | 112 | fun saveNotificationTimeUnit(timeUnit: String) { 113 | prefs.edit().putString(KEY_NOTIFICATION_TIME_UNIT, timeUnit).apply() 114 | } 115 | 116 | private fun loadNotificationSettings(): NotificationSettings { 117 | return NotificationSettings( 118 | blockNotificationsEnabled = prefs.getBoolean(KEY_BLOCK_NOTIFICATIONS_ENABLED, false), 119 | blockCheckFrequency = if (prefs.contains(KEY_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_BLOCK_CHECK_FREQUENCY, 0) else 0, 120 | newBlockNotificationEnabled = prefs.getBoolean(KEY_NEW_BLOCK_NOTIFICATIONS_ENABLED, false), 121 | newBlockCheckFrequency = if (prefs.contains(KEY_NEW_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, 0) else 0, 122 | specificBlockNotificationEnabled = prefs.getBoolean(KEY_SPECIFIC_BLOCK_NOTIFICATIONS_ENABLED, false), 123 | specificBlockCheckFrequency = if (prefs.contains(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, 0) else 0, 124 | targetBlockHeight = if (prefs.contains(KEY_TARGET_BLOCK_HEIGHT)) prefs.getInt(KEY_TARGET_BLOCK_HEIGHT, -1) else null, 125 | mempoolSizeNotificationsEnabled = prefs.getBoolean(KEY_MEMPOOL_SIZE_NOTIFICATIONS_ENABLED, false), 126 | mempoolCheckFrequency = if (prefs.contains(KEY_MEMPOOL_CHECK_FREQUENCY)) prefs.getInt(KEY_MEMPOOL_CHECK_FREQUENCY, 0) else 0, 127 | mempoolSizeThreshold = if (prefs.contains(KEY_MEMPOOL_SIZE_THRESHOLD)) prefs.getFloat(KEY_MEMPOOL_SIZE_THRESHOLD, 0f) else 0f, 128 | mempoolSizeAboveThreshold = prefs.getBoolean(KEY_MEMPOOL_SIZE_ABOVE_THRESHOLD, false), 129 | feeRatesNotificationsEnabled = prefs.getBoolean(KEY_FEE_RATES_NOTIFICATIONS_ENABLED, false), 130 | feeRatesCheckFrequency = if (prefs.contains(KEY_FEE_RATES_CHECK_FREQUENCY)) prefs.getInt(KEY_FEE_RATES_CHECK_FREQUENCY, 0) else 0, 131 | selectedFeeRateType = FeeRateType.entries[prefs.getInt(KEY_SELECTED_FEE_RATE_TYPE, 0)], 132 | feeRateThreshold = if (prefs.contains(KEY_FEE_RATE_THRESHOLD)) prefs.getInt(KEY_FEE_RATE_THRESHOLD, 0) else 0, 133 | feeRateAboveThreshold = prefs.getBoolean(KEY_FEE_RATE_ABOVE_THRESHOLD, false), 134 | txConfirmationEnabled = prefs.getBoolean(KEY_TX_CONFIRMATION_ENABLED, false), 135 | txConfirmationFrequency = if (prefs.contains(KEY_TX_CONFIRMATION_FREQUENCY)) prefs.getInt(KEY_TX_CONFIRMATION_FREQUENCY, 0) else 0, 136 | transactionId = prefs.getString(KEY_TRANSACTION_ID, "") ?: "" 137 | ) 138 | } 139 | 140 | fun updateSettings(settings: NotificationSettings) { 141 | _settings.value = settings 142 | 143 | // Save all notification settings except service state 144 | prefs.edit().apply { 145 | putBoolean(KEY_BLOCK_NOTIFICATIONS_ENABLED, settings.blockNotificationsEnabled) 146 | if (settings.blockCheckFrequency == 0) remove(KEY_BLOCK_CHECK_FREQUENCY) else putInt(KEY_BLOCK_CHECK_FREQUENCY, settings.blockCheckFrequency) 147 | putBoolean(KEY_NEW_BLOCK_NOTIFICATIONS_ENABLED, settings.newBlockNotificationEnabled) 148 | if (settings.newBlockCheckFrequency == 0) remove(KEY_NEW_BLOCK_CHECK_FREQUENCY) else putInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, settings.newBlockCheckFrequency) 149 | putBoolean(KEY_SPECIFIC_BLOCK_NOTIFICATIONS_ENABLED, settings.specificBlockNotificationEnabled) 150 | if (settings.specificBlockCheckFrequency == 0) remove(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY) else putInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, settings.specificBlockCheckFrequency) 151 | settings.targetBlockHeight?.let { putInt(KEY_TARGET_BLOCK_HEIGHT, it) } ?: remove(KEY_TARGET_BLOCK_HEIGHT) 152 | putBoolean(KEY_MEMPOOL_SIZE_NOTIFICATIONS_ENABLED, settings.mempoolSizeNotificationsEnabled) 153 | if (settings.mempoolCheckFrequency == 0) remove(KEY_MEMPOOL_CHECK_FREQUENCY) else putInt(KEY_MEMPOOL_CHECK_FREQUENCY, settings.mempoolCheckFrequency) 154 | if (settings.mempoolSizeThreshold == 0f) remove(KEY_MEMPOOL_SIZE_THRESHOLD) else putFloat(KEY_MEMPOOL_SIZE_THRESHOLD, settings.mempoolSizeThreshold) 155 | putBoolean(KEY_MEMPOOL_SIZE_ABOVE_THRESHOLD, settings.mempoolSizeAboveThreshold) 156 | putBoolean(KEY_FEE_RATES_NOTIFICATIONS_ENABLED, settings.feeRatesNotificationsEnabled) 157 | if (settings.feeRatesCheckFrequency == 0) remove(KEY_FEE_RATES_CHECK_FREQUENCY) else putInt(KEY_FEE_RATES_CHECK_FREQUENCY, settings.feeRatesCheckFrequency) 158 | putInt(KEY_SELECTED_FEE_RATE_TYPE, settings.selectedFeeRateType.ordinal) 159 | if (settings.feeRateThreshold == 0) remove(KEY_FEE_RATE_THRESHOLD) else putInt(KEY_FEE_RATE_THRESHOLD, settings.feeRateThreshold) 160 | putBoolean(KEY_FEE_RATE_ABOVE_THRESHOLD, settings.feeRateAboveThreshold) 161 | putBoolean(KEY_TX_CONFIRMATION_ENABLED, settings.txConfirmationEnabled) 162 | if (settings.txConfirmationFrequency == 0) remove(KEY_TX_CONFIRMATION_FREQUENCY) else putInt(KEY_TX_CONFIRMATION_FREQUENCY, settings.txConfirmationFrequency) 163 | putString(KEY_TRANSACTION_ID, settings.transactionId) 164 | }.apply() 165 | } 166 | 167 | fun clearServerRestartFlag() { 168 | prefs.edit().putBoolean(KEY_SERVER_NEEDS_RESTART, false).apply() 169 | } 170 | 171 | fun needsRestartForServer(): Boolean { 172 | return prefs.getBoolean(KEY_SERVER_NEEDS_RESTART, false) 173 | } 174 | 175 | fun getSavedServers(): Set { 176 | return prefs.getStringSet(KEY_SAVED_SERVERS, setOf()) ?: setOf() 177 | } 178 | 179 | fun addSavedServer(url: String) { 180 | val currentServers = getSavedServers().toMutableSet() 181 | currentServers.add(url.trimEnd('/')) 182 | prefs.edit().putStringSet(KEY_SAVED_SERVERS, currentServers).apply() 183 | } 184 | 185 | fun removeSavedServer(url: String) { 186 | val currentServers = getSavedServers().toMutableSet() 187 | currentServers.remove(url) 188 | prefs.edit().putStringSet(KEY_SAVED_SERVERS, currentServers).apply() 189 | } 190 | 191 | fun isFirstLaunch(): Boolean { 192 | return prefs.getBoolean(KEY_IS_FIRST_LAUNCH, true) 193 | } 194 | 195 | fun setFirstLaunchComplete() { 196 | prefs.edit().putBoolean(KEY_IS_FIRST_LAUNCH, false).apply() 197 | } 198 | 199 | fun getVisibleCards(): Set { 200 | return prefs.getStringSet(KEY_VISIBLE_CARDS, DEFAULT_VISIBLE_CARDS) ?: DEFAULT_VISIBLE_CARDS 201 | } 202 | 203 | fun saveVisibleCards(visibleCards: Set) { 204 | prefs.edit().putStringSet(KEY_VISIBLE_CARDS, visibleCards).apply() 205 | } 206 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.app.PendingIntent 4 | import android.appwidget.AppWidgetManager 5 | import android.appwidget.AppWidgetProvider 6 | import android.content.ComponentName 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.util.Log 10 | import android.widget.RemoteViews 11 | import com.example.mempal.R 12 | import com.example.mempal.api.WidgetNetworkClient 13 | import kotlinx.coroutines.* 14 | import java.util.Locale 15 | import kotlin.math.ceil 16 | 17 | class MempoolSizeWidget : AppWidgetProvider() { 18 | companion object { 19 | const val REFRESH_ACTION = "com.example.mempal.REFRESH_MEMPOOL_SIZE_WIDGET" 20 | private var widgetScope: CoroutineScope? = null 21 | private var activeJobs = mutableMapOf() 22 | private const val TAG = "MempoolSizeWidget" 23 | } 24 | 25 | private fun getOrCreateScope(): CoroutineScope { 26 | return widgetScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { widgetScope = it } 27 | } 28 | 29 | override fun onEnabled(context: Context) { 30 | super.onEnabled(context) 31 | getOrCreateScope() // Initialize scope when widget is enabled 32 | 33 | // Ensure services initialized 34 | WidgetUtils.ensureInitialized(context) 35 | 36 | // Register network connectivity monitor 37 | NetworkConnectivityReceiver.registerNetworkCallback(context) 38 | 39 | // Schedule updates 40 | WidgetUpdater.scheduleUpdates(context) 41 | } 42 | 43 | override fun onDisabled(context: Context) { 44 | super.onDisabled(context) 45 | // Only cancel updates if no other widgets are active 46 | val appWidgetManager = AppWidgetManager.getInstance(context) 47 | val blockHeightWidget = ComponentName(context, BlockHeightWidget::class.java) 48 | val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) 49 | val feeRatesWidget = ComponentName(context, FeeRatesWidget::class.java) 50 | 51 | if (appWidgetManager.getAppWidgetIds(blockHeightWidget).isEmpty() && 52 | appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && 53 | appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { 54 | WidgetUpdater.cancelUpdates(context) 55 | // Cancel any ongoing coroutines 56 | activeJobs.values.forEach { it.cancel() } 57 | activeJobs.clear() 58 | widgetScope?.cancel() 59 | widgetScope = null 60 | } 61 | } 62 | 63 | override fun onReceive(context: Context, intent: Intent) { 64 | // Ensure initialization on ANY event to the widget 65 | WidgetUtils.ensureInitialized(context) 66 | 67 | super.onReceive(context, intent) 68 | 69 | // First see if this is a system event handled by the common handler 70 | if (WidgetEventHandler.handleSystemEvent(context, intent, MempoolSizeWidget::class.java, REFRESH_ACTION)) { 71 | return 72 | } 73 | 74 | // Otherwise handle widget-specific REFRESH_ACTION 75 | if (intent.action == REFRESH_ACTION) { 76 | if (WidgetUtils.isDoubleTap()) { 77 | // Launch app on double tap 78 | val launchIntent = WidgetUtils.getLaunchAppIntent(context) 79 | launchIntent.send() 80 | } else { 81 | // Single tap - refresh only this widget 82 | Log.d(TAG, "Refresh action received - updating widget") 83 | 84 | // Check for network availability before trying to update 85 | if (!WidgetNetworkClient.isNetworkAvailable(context)) { 86 | Log.d(TAG, "Network unavailable, resetting tap state") 87 | // No network, reset tap state immediately to prevent getting stuck 88 | WidgetUtils.resetTapState() 89 | 90 | // Update widget with error state 91 | val appWidgetManager = AppWidgetManager.getInstance(context) 92 | val thisWidget = ComponentName(context, MempoolSizeWidget::class.java) 93 | val widgetIds = appWidgetManager.getAppWidgetIds(thisWidget) 94 | 95 | // Just update the first widget to show error message 96 | if (widgetIds.isNotEmpty()) { 97 | val views = RemoteViews(context.packageName, R.layout.mempool_size_widget) 98 | setErrorState(views) 99 | appWidgetManager.updateAppWidget(widgetIds[0], views) 100 | } 101 | 102 | return 103 | } 104 | 105 | // Network is available, proceed with update 106 | val appWidgetManager = AppWidgetManager.getInstance(context) 107 | val thisWidget = ComponentName(context, MempoolSizeWidget::class.java) 108 | onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) 109 | } 110 | } 111 | } 112 | 113 | override fun onUpdate( 114 | context: Context, 115 | appWidgetManager: AppWidgetManager, 116 | appWidgetIds: IntArray 117 | ) { 118 | // Ensure services are initialized 119 | WidgetUtils.ensureInitialized(context) 120 | 121 | // Update each widget 122 | appWidgetIds.forEach { appWidgetId -> 123 | updateAppWidget(context, appWidgetManager, appWidgetId) 124 | } 125 | } 126 | 127 | private fun updateAppWidget( 128 | context: Context, 129 | appWidgetManager: AppWidgetManager, 130 | appWidgetId: Int 131 | ) { 132 | val views = RemoteViews(context.packageName, R.layout.mempool_size_widget) 133 | 134 | // Create refresh intent 135 | val refreshIntent = Intent(context, MempoolSizeWidget::class.java).apply { 136 | action = REFRESH_ACTION 137 | // Include flags to make it work better when app is killed 138 | addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) 139 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 140 | } 141 | val refreshPendingIntent = PendingIntent.getBroadcast( 142 | context, 143 | appWidgetId, // Use widget ID as request code for uniqueness 144 | refreshIntent, 145 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // Use FLAG_MUTABLE instead of FLAG_IMMUTABLE 146 | ) 147 | 148 | // Cancel any existing job for this widget 149 | activeJobs[appWidgetId]?.cancel() 150 | 151 | // Set loading state first 152 | setLoadingState(views) 153 | // Set click handler immediately after creating views 154 | views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) 155 | appWidgetManager.updateAppWidget(appWidgetId, views) 156 | 157 | // Create a separate mutable state for views that can be updated by multiple coroutines 158 | val sharedViews = views 159 | var viewsUpdated = false 160 | 161 | // Start new job 162 | activeJobs[appWidgetId] = getOrCreateScope().launch { 163 | var viewsPreparedForData = false 164 | try { 165 | Log.d(TAG, "Starting network request for widget update for ID: $appWidgetId") 166 | val mempoolApi = WidgetNetworkClient.getMempoolApi(context) 167 | 168 | val mempoolInfoDeferred = async(SupervisorJob() + coroutineContext) { 169 | try { 170 | mempoolApi.getMempoolInfo() 171 | } catch (e: Exception) { 172 | Log.e(TAG, "Mempool info request failed for ID: $appWidgetId: ${e.message}") 173 | null 174 | } 175 | } 176 | 177 | val response = mempoolInfoDeferred.await() 178 | 179 | if (response != null && response.isSuccessful) { 180 | response.body()?.let { 181 | val sizeInMB = it.vsize / 1_000_000.0 182 | sharedViews.setTextViewText(R.id.mempool_size, 183 | String.format(Locale.US, "%.2f vMB", sizeInMB)) 184 | 185 | val blocksToClean = ceil(sizeInMB / 1.5).toInt() 186 | sharedViews.setTextViewText(R.id.mempool_blocks_to_clear, 187 | "$blocksToClean ${if (blocksToClean == 1) "block" else "blocks"} to clear") 188 | viewsPreparedForData = true 189 | 190 | // Update widget with data immediately when we have it 191 | if (!viewsUpdated) { 192 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 193 | viewsUpdated = true 194 | Log.d(TAG, "Widget $appWidgetId UI updated with new data") 195 | } 196 | 197 | Log.d(TAG, "Data prepared for widget ID: $appWidgetId") 198 | } ?: run { 199 | Log.w(TAG, "Mempool info response body was null for ID: $appWidgetId") 200 | } 201 | } else { 202 | Log.w(TAG, "Mempool info response unsuccessful or null for ID: $appWidgetId. Code: ${response?.code()}") 203 | } 204 | 205 | if (!viewsPreparedForData) { 206 | setErrorState(sharedViews) 207 | Log.d(TAG, "Error state set for widget ID: $appWidgetId after data fetch attempt") 208 | } 209 | 210 | } catch (e: CancellationException) { 211 | Log.d(TAG, "Job for widget $appWidgetId was cancelled during try block.") 212 | 213 | // Don't throw the exception if we already updated the UI 214 | if (!viewsUpdated && viewsPreparedForData) { 215 | // Final attempt to update UI before exiting 216 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 217 | Log.d(TAG, "Managed to update widget $appWidgetId UI despite job cancellation") 218 | viewsUpdated = true 219 | } 220 | throw e // Re-throw to ensure cancellation is propagated 221 | } catch (e: Exception) { 222 | Log.e(TAG, "Exception during widget update for ID: $appWidgetId", e) 223 | setErrorState(sharedViews) 224 | Log.d(TAG, "Error state set for widget ID: $appWidgetId due to exception") 225 | } finally { 226 | val job = coroutineContext[Job] 227 | if (job?.isCancelled == false || !viewsUpdated) { 228 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 229 | Log.d(TAG, "Final update in finally for widget $appWidgetId. Data: $viewsPreparedForData") 230 | } else { 231 | Log.d(TAG, "Job for widget $appWidgetId was cancelled. UI was already updated in finally.") 232 | } 233 | activeJobs.remove(appWidgetId) 234 | WidgetUtils.resetTapState() 235 | } 236 | } 237 | } 238 | 239 | private fun setLoadingState(views: RemoteViews) { 240 | views.setTextViewText(R.id.mempool_size, "...") 241 | views.setTextViewText(R.id.mempool_blocks_to_clear, "") 242 | } 243 | 244 | private fun setErrorState(views: RemoteViews) { 245 | views.setTextViewText(R.id.mempool_size, "?") 246 | views.setTextViewText(R.id.mempool_blocks_to_clear, "") 247 | } 248 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/api/NetworkClient.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.api 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.Network 6 | import android.net.NetworkCapabilities 7 | import android.net.NetworkRequest 8 | import com.example.mempal.repository.SettingsRepository 9 | import com.example.mempal.tor.TorManager 10 | import com.example.mempal.tor.TorStatus 11 | import com.google.gson.GsonBuilder 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import okhttp3.OkHttpClient 16 | import okhttp3.logging.HttpLoggingInterceptor 17 | import retrofit2.Retrofit 18 | import retrofit2.converter.gson.GsonConverterFactory 19 | import java.lang.ref.WeakReference 20 | import java.util.concurrent.TimeUnit 21 | 22 | object NetworkClient { 23 | private const val TIMEOUT_SECONDS = 10L 24 | private const val TEST_TIMEOUT_SECONDS = 10L 25 | private const val ONION_TEST_TIMEOUT_SECONDS = 60L 26 | private const val TOR_TIMEOUT_SECONDS = 60L 27 | private const val MAX_PARALLEL_CONNECTIONS = 5 28 | private const val TOR_MAX_IDLE_CONNECTIONS = 5 29 | 30 | private var retrofit: Retrofit? = null 31 | private var contextRef: WeakReference? = null 32 | private val _isInitialized = MutableStateFlow(false) 33 | val isInitialized: StateFlow = _isInitialized 34 | private var coroutineScope: CoroutineScope? = null 35 | private var connectivityManager: ConnectivityManager? = null 36 | private val _isNetworkAvailable = MutableStateFlow(false) 37 | val isNetworkAvailable: StateFlow = _isNetworkAvailable 38 | private var lastInitAttempt = 0L 39 | 40 | private var _mempoolApi: MempoolApi? = null 41 | val mempoolApi: MempoolApi 42 | get() { 43 | if (!_isInitialized.value) { 44 | contextRef?.get()?.let { context -> 45 | // Try to re-initialize if we have a context 46 | if (System.currentTimeMillis() - lastInitAttempt > 1000) { // Prevent spam 47 | initialize(context) 48 | } 49 | } 50 | // Return null API if not initialized, let caller handle it 51 | return _mempoolApi ?: throw IllegalStateException("NetworkClient not initialized. Please ensure initialize() is called first.") 52 | } 53 | return _mempoolApi ?: throw IllegalStateException("NetworkClient not initialized. Please ensure initialize() is called first.") 54 | } 55 | 56 | private val networkCallback = object : ConnectivityManager.NetworkCallback() { 57 | override fun onAvailable(network: Network) { 58 | super.onAvailable(network) 59 | coroutineScope?.launch { 60 | _isNetworkAvailable.value = true 61 | 62 | val torManager = TorManager.getInstance() 63 | if (torManager.isTorEnabled()) { 64 | if (torManager.torStatus.value == TorStatus.CONNECTED) { 65 | setupRetrofit(true) 66 | _isInitialized.value = _mempoolApi != null 67 | } 68 | } else { 69 | setupRetrofit(false) 70 | _isInitialized.value = _mempoolApi != null 71 | } 72 | } 73 | } 74 | 75 | override fun onLost(network: Network) { 76 | super.onLost(network) 77 | _isNetworkAvailable.value = false 78 | _isInitialized.value = false 79 | _mempoolApi = null 80 | } 81 | } 82 | 83 | private val loggingInterceptor = HttpLoggingInterceptor().apply { 84 | level = HttpLoggingInterceptor.Level.BODY 85 | } 86 | 87 | @Synchronized 88 | fun initialize(context: Context) { 89 | if (_isInitialized.value) return // Already initialized 90 | lastInitAttempt = System.currentTimeMillis() 91 | println("Initializing NetworkClient...") 92 | contextRef = WeakReference(context.applicationContext) 93 | coroutineScope?.cancel() // Cancel any existing scope 94 | coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 95 | 96 | // Setup connectivity monitoring 97 | connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 98 | val networkRequest = NetworkRequest.Builder() 99 | .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 100 | .build() 101 | connectivityManager?.registerNetworkCallback(networkRequest, networkCallback) 102 | 103 | // Check initial network state 104 | _isNetworkAvailable.value = isNetworkCurrentlyAvailable() 105 | 106 | // Check if current API URL is an onion address and manage Tor accordingly 107 | val settingsRepository = SettingsRepository.getInstance(context) 108 | val currentApiUrl = settingsRepository.getApiUrl() 109 | val torManager = TorManager.getInstance() 110 | 111 | if (currentApiUrl.contains(".onion")) { 112 | if (!torManager.isTorEnabled()) { 113 | println("Onion address detected, enabling Tor") 114 | torManager.startTor(context) 115 | } 116 | } else if (torManager.isTorEnabled()) { 117 | println("Non-onion address detected, disabling Tor") 118 | torManager.stopTor(context) 119 | } 120 | 121 | coroutineScope?.launch { 122 | torManager.torStatus.collect { status -> 123 | println("Tor status changed: $status") 124 | if (status == TorStatus.CONNECTED || status == TorStatus.DISCONNECTED) { 125 | if (isNetworkCurrentlyAvailable()) { 126 | println("Setting up Retrofit with useProxy=${status == TorStatus.CONNECTED}") 127 | setupRetrofit(status == TorStatus.CONNECTED) 128 | _isInitialized.value = _mempoolApi != null 129 | } else { 130 | _isInitialized.value = false 131 | } 132 | } else { 133 | _isInitialized.value = false 134 | } 135 | } 136 | } 137 | } 138 | 139 | private fun isNetworkCurrentlyAvailable(): Boolean { 140 | val cm = connectivityManager ?: return false 141 | val activeNetwork = cm.activeNetwork ?: return false 142 | val capabilities = cm.getNetworkCapabilities(activeNetwork) ?: return false 143 | return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 144 | } 145 | 146 | fun cleanup() { 147 | connectivityManager?.unregisterNetworkCallback(networkCallback) 148 | connectivityManager = null 149 | coroutineScope?.cancel() 150 | coroutineScope = null 151 | contextRef = null 152 | retrofit = null 153 | _mempoolApi = null 154 | _isInitialized.value = false 155 | _isNetworkAvailable.value = false 156 | loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY 157 | } 158 | 159 | private fun setupRetrofit(useProxy: Boolean) { 160 | try { 161 | val context = contextRef?.get() ?: throw IllegalStateException("Context not available") 162 | val baseUrl = SettingsRepository.getInstance(context).getApiUrl().let { url -> 163 | if (!url.endsWith("/")) "$url/" else url 164 | } 165 | 166 | println("Setting up Retrofit with baseUrl: $baseUrl") 167 | 168 | val isOnion = baseUrl.contains(".onion") 169 | val timeoutSeconds = when { 170 | useProxy && isOnion -> TOR_TIMEOUT_SECONDS 171 | else -> TIMEOUT_SECONDS 172 | } 173 | 174 | val dispatcher = okhttp3.Dispatcher().apply { 175 | maxRequests = MAX_PARALLEL_CONNECTIONS 176 | maxRequestsPerHost = MAX_PARALLEL_CONNECTIONS 177 | } 178 | 179 | val clientBuilder = OkHttpClient.Builder() 180 | .dispatcher(dispatcher) 181 | .connectTimeout(timeoutSeconds, TimeUnit.SECONDS) 182 | .readTimeout(timeoutSeconds, TimeUnit.SECONDS) 183 | .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) 184 | .retryOnConnectionFailure(true) 185 | 186 | // Only add logging for debug purposes in Tor 187 | if (useProxy && isOnion) { 188 | clientBuilder.addInterceptor(loggingInterceptor) 189 | } 190 | 191 | // Optimize retry strategy 192 | clientBuilder.addInterceptor { chain -> 193 | val maxAttempts = if (useProxy && isOnion) 2 else 1 194 | var attempt = 0 195 | var response = chain.proceed(chain.request()) 196 | 197 | while (!response.isSuccessful && attempt < maxAttempts) { 198 | attempt++ 199 | response.close() 200 | response = chain.proceed(chain.request()) 201 | } 202 | response 203 | } 204 | 205 | if (useProxy && isOnion) { 206 | println("Setting up Tor proxy") 207 | clientBuilder.proxy(java.net.Proxy( 208 | java.net.Proxy.Type.SOCKS, 209 | java.net.InetSocketAddress("127.0.0.1", 9050) 210 | )) 211 | 212 | // Optimize connection pool for Tor 213 | clientBuilder.connectionPool( 214 | okhttp3.ConnectionPool( 215 | TOR_MAX_IDLE_CONNECTIONS, 216 | 30, 217 | TimeUnit.SECONDS 218 | ) 219 | ) 220 | } else { 221 | // Optimize connection pool for regular connections 222 | clientBuilder.connectionPool( 223 | okhttp3.ConnectionPool( 224 | MAX_PARALLEL_CONNECTIONS, 225 | 30, 226 | TimeUnit.SECONDS 227 | ) 228 | ) 229 | } 230 | 231 | val gson = GsonBuilder() 232 | .setLenient() 233 | .create() 234 | 235 | retrofit = Retrofit.Builder() 236 | .baseUrl(baseUrl) 237 | .client(clientBuilder.build()) 238 | .addConverterFactory(GsonConverterFactory.create(gson)) 239 | .build() 240 | 241 | _mempoolApi = retrofit!!.create(MempoolApi::class.java) 242 | println("Retrofit setup complete") 243 | } catch (e: Exception) { 244 | println("Error setting up Retrofit: ${e.message}") 245 | _mempoolApi = null 246 | _isInitialized.value = false 247 | throw e 248 | } 249 | } 250 | 251 | fun createTestClient(baseUrl: String, useTor: Boolean = false): MempoolApi { 252 | val clientBuilder = OkHttpClient.Builder() 253 | .addInterceptor(loggingInterceptor) 254 | .connectTimeout(if (useTor && baseUrl.contains(".onion")) ONION_TEST_TIMEOUT_SECONDS else TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) 255 | .readTimeout(if (useTor && baseUrl.contains(".onion")) ONION_TEST_TIMEOUT_SECONDS else TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) 256 | .writeTimeout(if (useTor && baseUrl.contains(".onion")) ONION_TEST_TIMEOUT_SECONDS else TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) 257 | 258 | if (useTor && baseUrl.contains(".onion")) { 259 | clientBuilder.proxy(java.net.Proxy( 260 | java.net.Proxy.Type.SOCKS, 261 | java.net.InetSocketAddress("127.0.0.1", 9050) 262 | )) 263 | } 264 | 265 | val formattedUrl = if (!baseUrl.endsWith("/")) "$baseUrl/" else baseUrl 266 | 267 | val testRetrofit = Retrofit.Builder() 268 | .baseUrl(formattedUrl) 269 | .client(clientBuilder.build()) 270 | .addConverterFactory(GsonConverterFactory.create()) 271 | .build() 272 | 273 | return testRetrofit.create(MempoolApi::class.java) 274 | } 275 | 276 | fun isUsingOnion(): Boolean { 277 | return contextRef?.get()?.let { context -> 278 | SettingsRepository.getInstance(context).getApiUrl().contains(".onion") 279 | } == true 280 | } 281 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt: -------------------------------------------------------------------------------- 1 | package com.example.mempal.widget 2 | 3 | import android.app.PendingIntent 4 | import android.appwidget.AppWidgetManager 5 | import android.appwidget.AppWidgetProvider 6 | import android.content.ComponentName 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.util.Log 10 | import android.widget.RemoteViews 11 | import com.example.mempal.R 12 | import com.example.mempal.api.WidgetNetworkClient 13 | import kotlinx.coroutines.* 14 | import java.util.* 15 | 16 | class BlockHeightWidget : AppWidgetProvider() { 17 | companion object { 18 | const val REFRESH_ACTION = "com.example.mempal.REFRESH_BLOCK_HEIGHT_WIDGET" 19 | private var widgetScope: CoroutineScope? = null 20 | private var activeJobs = mutableMapOf() 21 | private const val TAG = "BlockHeightWidget" 22 | } 23 | 24 | private fun getOrCreateScope(): CoroutineScope { 25 | return widgetScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { widgetScope = it } 26 | } 27 | 28 | override fun onEnabled(context: Context) { 29 | super.onEnabled(context) 30 | getOrCreateScope() // Initialize scope when widget is enabled 31 | 32 | // Ensure services initialized 33 | WidgetUtils.ensureInitialized(context) 34 | 35 | // Register network connectivity monitor 36 | NetworkConnectivityReceiver.registerNetworkCallback(context) 37 | 38 | // Schedule updates 39 | WidgetUpdater.scheduleUpdates(context) 40 | } 41 | 42 | override fun onDisabled(context: Context) { 43 | super.onDisabled(context) 44 | // Only cancel updates if no other widgets are active 45 | val appWidgetManager = AppWidgetManager.getInstance(context) 46 | val mempoolSizeWidget = ComponentName(context, MempoolSizeWidget::class.java) 47 | val combinedStatsWidget = ComponentName(context, CombinedStatsWidget::class.java) 48 | val feeRatesWidget = ComponentName(context, FeeRatesWidget::class.java) 49 | 50 | if (appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty() && 51 | appWidgetManager.getAppWidgetIds(combinedStatsWidget).isEmpty() && 52 | appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { 53 | WidgetUpdater.cancelUpdates(context) 54 | // Cancel any ongoing coroutines 55 | activeJobs.values.forEach { it.cancel() } 56 | activeJobs.clear() 57 | widgetScope?.cancel() 58 | widgetScope = null 59 | } 60 | } 61 | 62 | override fun onReceive(context: Context, intent: Intent) { 63 | // Ensure initialization on ANY event to the widget 64 | WidgetUtils.ensureInitialized(context) 65 | 66 | super.onReceive(context, intent) 67 | 68 | // First see if this is a system event handled by the common handler 69 | if (WidgetEventHandler.handleSystemEvent(context, intent, BlockHeightWidget::class.java, REFRESH_ACTION)) { 70 | return 71 | } 72 | 73 | // Otherwise handle widget-specific REFRESH_ACTION 74 | if (intent.action == REFRESH_ACTION) { 75 | if (WidgetUtils.isDoubleTap()) { 76 | // Launch app on double tap 77 | val launchIntent = WidgetUtils.getLaunchAppIntent(context) 78 | launchIntent.send() 79 | } else { 80 | // Single tap - refresh only this widget 81 | Log.d(TAG, "Refresh action received - updating widget") 82 | 83 | // Check for network availability before trying to update 84 | if (!WidgetNetworkClient.isNetworkAvailable(context)) { 85 | Log.d(TAG, "Network unavailable, resetting tap state") 86 | // No network, reset tap state immediately to prevent getting stuck 87 | WidgetUtils.resetTapState() 88 | 89 | // Update widget with error state 90 | val appWidgetManager = AppWidgetManager.getInstance(context) 91 | val thisWidget = ComponentName(context, BlockHeightWidget::class.java) 92 | val widgetIds = appWidgetManager.getAppWidgetIds(thisWidget) 93 | 94 | // Just update the first widget to show error message 95 | if (widgetIds.isNotEmpty()) { 96 | val views = RemoteViews(context.packageName, R.layout.block_height_widget) 97 | setErrorState(views) 98 | appWidgetManager.updateAppWidget(widgetIds[0], views) 99 | } 100 | 101 | return 102 | } 103 | 104 | // Network is available, proceed with update 105 | val appWidgetManager = AppWidgetManager.getInstance(context) 106 | val thisWidget = ComponentName(context, BlockHeightWidget::class.java) 107 | onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) 108 | } 109 | } 110 | } 111 | 112 | override fun onUpdate( 113 | context: Context, 114 | appWidgetManager: AppWidgetManager, 115 | appWidgetIds: IntArray 116 | ) { 117 | // Ensure services are initialized 118 | WidgetUtils.ensureInitialized(context) 119 | 120 | // Update each widget 121 | appWidgetIds.forEach { appWidgetId -> 122 | updateAppWidget(context, appWidgetManager, appWidgetId) 123 | } 124 | } 125 | 126 | private fun updateAppWidget( 127 | context: Context, 128 | appWidgetManager: AppWidgetManager, 129 | appWidgetId: Int 130 | ) { 131 | val views = RemoteViews(context.packageName, R.layout.block_height_widget) 132 | 133 | // Create refresh intent 134 | val refreshIntent = Intent(context, BlockHeightWidget::class.java).apply { 135 | action = REFRESH_ACTION 136 | // Include flags to make it work better when app is killed 137 | addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) 138 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 139 | } 140 | val refreshPendingIntent = PendingIntent.getBroadcast( 141 | context, 142 | appWidgetId, // Use widget ID as request code for uniqueness 143 | refreshIntent, 144 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // Use FLAG_MUTABLE instead of FLAG_IMMUTABLE 145 | ) 146 | 147 | // Cancel any existing job for this widget 148 | activeJobs[appWidgetId]?.cancel() 149 | 150 | // Set loading state first 151 | setLoadingState(views) 152 | // Set click handler immediately after creating views 153 | views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) 154 | appWidgetManager.updateAppWidget(appWidgetId, views) 155 | 156 | // Create a separate mutable state for views that can be updated by multiple coroutines 157 | val sharedViews = views 158 | var viewsUpdated = false 159 | 160 | // Start new job 161 | activeJobs[appWidgetId] = getOrCreateScope().launch { 162 | var viewsPreparedForData = false 163 | try { 164 | Log.d(TAG, "Starting network request for widget update for ID: $appWidgetId") 165 | val mempoolApi = WidgetNetworkClient.getMempoolApi(context) 166 | 167 | val blockHeightDeferred = async(SupervisorJob() + coroutineContext) { 168 | try { mempoolApi.getBlockHeight() } catch (e: Exception) { Log.e(TAG, "BH request failed for $appWidgetId: ${e.message}"); null } 169 | } 170 | val blockHashDeferred = async(SupervisorJob() + coroutineContext) { 171 | try { mempoolApi.getLatestBlockHash() } catch (e: Exception) { Log.e(TAG, "Hash request failed for $appWidgetId: ${e.message}"); null } 172 | } 173 | 174 | val blockHeightResponse = blockHeightDeferred.await() 175 | 176 | if (blockHeightResponse != null && blockHeightResponse.isSuccessful) { 177 | blockHeightResponse.body()?.let { blockHeight -> 178 | sharedViews.setTextViewText(R.id.block_height, String.format(Locale.US, "%,d", blockHeight)) 179 | // Block height is primary data, consider this a partial success for now 180 | viewsPreparedForData = true 181 | 182 | val blockHashResponse = blockHashDeferred.await() 183 | if (blockHashResponse != null && blockHashResponse.isSuccessful) { 184 | blockHashResponse.body()?.let { hash -> 185 | try { 186 | val blockInfoResponse = mempoolApi.getBlockInfo(hash) // This can also fail 187 | if (blockInfoResponse.isSuccessful) { 188 | blockInfoResponse.body()?.timestamp?.let { timestamp -> 189 | val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 190 | sharedViews.setTextViewText(R.id.elapsed_time, "$elapsedMinutes ${if (elapsedMinutes == 1L) "minute" else "minutes"} ago") 191 | } 192 | } else { 193 | Log.w(TAG, "Block info response unsuccessful for $appWidgetId: ${blockInfoResponse.code()}") 194 | sharedViews.setTextViewText(R.id.elapsed_time, "") // Clear if failed 195 | } 196 | } catch (e: Exception) { 197 | Log.e(TAG, "Error fetching block info for $appWidgetId: ${e.message}") 198 | sharedViews.setTextViewText(R.id.elapsed_time, "") // Clear on exception 199 | } 200 | } ?: Log.w(TAG, "Block hash body was null for $appWidgetId") 201 | } else { 202 | Log.w(TAG, "Block hash response unsuccessful or null for $appWidgetId. Code: ${blockHashResponse?.code()}") 203 | sharedViews.setTextViewText(R.id.elapsed_time, "") // Clear if hash fetch failed 204 | } 205 | 206 | // Update widget with data immediately when we have it 207 | if (!viewsUpdated) { 208 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 209 | viewsUpdated = true 210 | Log.d(TAG, "Widget $appWidgetId UI updated with new data") 211 | } 212 | 213 | Log.d(TAG, "Data prepared (at least partially) for widget ID: $appWidgetId") 214 | } ?: run { 215 | Log.w(TAG, "Block height response body was null for $appWidgetId") 216 | viewsPreparedForData = false // Explicitly mark as failed if block height body is null 217 | } 218 | } else { 219 | Log.w(TAG, "Block height response unsuccessful or null for $appWidgetId. Code: ${blockHeightResponse?.code()}") 220 | } 221 | 222 | if (!viewsPreparedForData) { 223 | setErrorState(sharedViews) 224 | Log.d(TAG, "Error state set for widget ID: $appWidgetId after data fetch attempt") 225 | } 226 | 227 | } catch (e: CancellationException) { 228 | Log.d(TAG, "Job for widget $appWidgetId was cancelled during try block.") 229 | 230 | // Don't throw the exception if we already updated the UI 231 | if (!viewsUpdated && viewsPreparedForData) { 232 | // Final attempt to update UI before exiting 233 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 234 | Log.d(TAG, "Managed to update widget $appWidgetId UI despite job cancellation") 235 | viewsUpdated = true 236 | } 237 | throw e 238 | } catch (e: Exception) { 239 | Log.e(TAG, "Exception during widget update for ID: $appWidgetId", e) 240 | setErrorState(sharedViews) 241 | Log.d(TAG, "Error state set for widget ID: $appWidgetId due to exception") 242 | } finally { 243 | val job = coroutineContext[Job] 244 | if (job?.isCancelled == false || !viewsUpdated) { 245 | appWidgetManager.updateAppWidget(appWidgetId, sharedViews) 246 | Log.d(TAG, "Final update in finally for widget $appWidgetId. Data: $viewsPreparedForData") 247 | } else { 248 | Log.d(TAG, "Job for widget $appWidgetId was cancelled. UI was already updated in finally.") 249 | } 250 | activeJobs.remove(appWidgetId) 251 | WidgetUtils.resetTapState() 252 | } 253 | } 254 | } 255 | 256 | private fun setLoadingState(views: RemoteViews) { 257 | views.setTextViewText(R.id.block_height, "...") 258 | views.setTextViewText(R.id.elapsed_time, "") 259 | } 260 | 261 | private fun setErrorState(views: RemoteViews) { 262 | views.setTextViewText(R.id.block_height, "?") 263 | views.setTextViewText(R.id.elapsed_time, "") 264 | } 265 | } --------------------------------------------------------------------------------