├── 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 | 
5 |
6 |
7 | [
](https://f-droid.org/en/packages/com.example.mempal/)
8 | [
](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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------