diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9aa8f55..d06dea8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.example.mempal" minSdk = 24 targetSdk = 34 - versionCode = 7 - versionName = "1.3.0" + versionCode = 8 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0876fa5..a4b7fb7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,75 +1,81 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 295c208..d02afb5 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/example/mempal/MainActivity.kt b/app/src/main/java/com/example/mempal/MainActivity.kt index 2dfaf97..a9c1531 100644 --- a/app/src/main/java/com/example/mempal/MainActivity.kt +++ b/app/src/main/java/com/example/mempal/MainActivity.kt @@ -16,9 +16,12 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -43,6 +46,7 @@ import androidx.compose.material3.Switch import androidx.compose.runtime.* import androidx.compose.runtime.collectAsState import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -51,10 +55,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.core.content.ContextCompat @@ -64,7 +66,7 @@ import androidx.lifecycle.lifecycleScope import com.example.mempal.api.FeeRates import com.example.mempal.api.MempoolInfo import com.example.mempal.api.NetworkClient -import com.example.mempal.api.Result +import com.example.mempal.cache.DashboardCache import com.example.mempal.model.FeeRateType import com.example.mempal.model.NotificationSettings import com.example.mempal.repository.SettingsRepository @@ -73,9 +75,9 @@ import com.example.mempal.tor.TorManager import com.example.mempal.tor.TorStatus import com.example.mempal.ui.theme.AppColors import com.example.mempal.ui.theme.MempalTheme +import com.example.mempal.viewmodel.DashboardUiState import com.example.mempal.viewmodel.MainViewModel import com.example.mempal.widget.WidgetUpdater -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Locale @@ -140,7 +142,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() splashScreen.setKeepOnScreenCondition { - viewModel.uiState.value is Result.Loading && !viewModel.hasInitialData + false // Don't hold the splash screen, let the app load immediately } super.onCreate(savedInstanceState) @@ -150,10 +152,14 @@ class MainActivity : ComponentActivity() { NetworkClient.initialize(applicationContext) settingsRepository = SettingsRepository.getInstance(applicationContext) - // Fix: Move the NetworkClient initialization check into a proper coroutine scope + // Clear the server restart flag on app start + settingsRepository.clearServerRestartFlag() + + // Add Tor connection event listener lifecycleScope.launch { - NetworkClient.isInitialized.collect { isInitialized -> - if (isInitialized) { + TorManager.getInstance().torConnectionEvent.collect { connected -> + if (connected && !viewModel.hasInitialData) { + // Only refresh if we don't have data yet viewModel.refreshData() } } @@ -186,6 +192,16 @@ class MainActivity : ComponentActivity() { // Check service state and update settings immediately updateServiceState() + // In onCreate, after the existing NetworkClient initialization + lifecycleScope.launch { + NetworkClient.isNetworkAvailable.collect { isAvailable -> + if (isAvailable && NetworkClient.isInitialized.value) { + // Network is available and client is initialized, refresh data + viewModel.refreshData() + } + } + } + setContent { MempalTheme { MainScreen(viewModel) @@ -198,6 +214,10 @@ class MainActivity : ComponentActivity() { // Update service state on resume updateServiceState() + // Check and restore Tor connection if needed + val torManager = TorManager.getInstance() + torManager.checkAndRestoreTorConnection(applicationContext) + // Existing onResume code if (!NetworkClient.isInitialized.value) { NetworkClient.initialize(applicationContext) @@ -211,6 +231,7 @@ class MainActivity : ComponentActivity() { // Only cleanup if activity is actually being destroyed, not recreated NetworkClient.cleanup() SettingsRepository.cleanup() + TorManager.getInstance().cleanup() // Add this line requestPermissionLauncher.unregister() } } @@ -243,17 +264,18 @@ private fun MainScreen(viewModel: MainViewModel) { val isInitialized by NetworkClient.isInitialized.collectAsState() val context = LocalContext.current - // Effect to handle periodic refresh - LaunchedEffect(selectedTab, isInitialized) { + // Effect to handle tab changes and periodic refresh + LaunchedEffect(selectedTab) { + // Notify ViewModel of tab change + viewModel.onTabSelected(selectedTab) + + // Only set up periodic refresh for dashboard tab if (selectedTab == 0) { while (true) { + delay(300000) // 5 minute delay between refreshes if (isInitialized) { viewModel.refreshData() - } else { - // If NetworkClient is not initialized, try to re-initialize - NetworkClient.initialize(context.applicationContext) } - delay(30000) // 30 seconds delay between refreshes } } } @@ -350,7 +372,7 @@ private fun MainScreen(viewModel: MainViewModel) { 0 -> MainContent( viewModel = viewModel, uiState = uiState, - modifier = Modifier.padding(paddingValues) + modifier = Modifier.padding(paddingValues), ) 1 -> NotificationsScreen(modifier = Modifier.padding(paddingValues)) 2 -> SettingsScreen(modifier = Modifier.padding(paddingValues)) @@ -374,6 +396,7 @@ private fun AppHeader(onRefresh: () -> Unit) { val torManager = remember { TorManager.getInstance() } val torStatus by torManager.torStatus.collectAsState() + val torEnabled = remember(torStatus) { torManager.isTorEnabled() } Box( modifier = Modifier @@ -390,11 +413,11 @@ private fun AppHeader(onRefresh: () -> Unit) { ) // Tor Status Indicator - if (torStatus == TorStatus.CONNECTED) { + if (torEnabled || torStatus == TorStatus.CONNECTING || torStatus == TorStatus.CONNECTED) { Box( modifier = Modifier .align(Alignment.BottomStart) - .padding(start = 26.5.dp, bottom = 18.5.dp) + .padding(start = 27.dp, bottom = 18.dp) ) { var showTooltip by remember { mutableStateOf(false) } @@ -404,8 +427,13 @@ private fun AppHeader(onRefresh: () -> Unit) { ) { Image( painter = painterResource(id = R.drawable.ic_onion), - contentDescription = "Tor Connected", - modifier = Modifier.size(24.dp) + contentDescription = "Tor Status", + modifier = Modifier + .size(24.dp) + .graphicsLayer(alpha = when (torStatus) { + TorStatus.CONNECTED -> 1f + else -> 0.5f + }) ) } @@ -421,7 +449,11 @@ private fun AppHeader(onRefresh: () -> Unit) { tonalElevation = 4.dp ) { Text( - text = "Tor Connected", + text = when (torStatus) { + TorStatus.CONNECTED -> "Tor Connected" + TorStatus.CONNECTING -> "Tor Connecting..." + else -> "Tor Enabled" + }, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyLarge, color = Color.White @@ -433,7 +465,7 @@ private fun AppHeader(onRefresh: () -> Unit) { } IconButton( - onClick = { + onClick = { isRotating = true onRefresh() }, @@ -459,63 +491,78 @@ private fun AppHeader(onRefresh: () -> Unit) { @Composable private fun MainContent( viewModel: MainViewModel, - uiState: Result, + uiState: DashboardUiState, modifier: Modifier = Modifier ) { val blockHeight by viewModel.blockHeight.observeAsState() val blockTimestamp by viewModel.blockTimestamp.observeAsState() val feeRates by viewModel.feeRates.observeAsState() val mempoolInfo by viewModel.mempoolInfo.observeAsState() + val isInitialized = NetworkClient.isInitialized.collectAsState() + + // Determine the appropriate message based on Tor connection and cache state + val statusMessage = when { + // If Tor is connected and we're loading data + isInitialized.value && uiState is DashboardUiState.Loading -> "Fetching data..." + + // If Tor is not connected + !isInitialized.value -> { + if (DashboardCache.hasCachedData()) "Reconnecting to Tor network..." else "Connecting to Tor network..." + } + + // For error states + uiState is DashboardUiState.Error -> uiState.message + + // For success states with cache + uiState is DashboardUiState.Success && uiState.isCache -> { + if (DashboardCache.hasCachedData()) "Reconnecting to Tor network..." else "Connecting to Tor network..." + } + + // No message for other states + else -> null + } when (uiState) { - is Result.Error -> { + is DashboardUiState.Error -> { if (!viewModel.hasInitialData) { - // Show loading cards if we haven't received any data yet MainContentDisplay( blockHeight = null, blockTimestamp = null, feeRates = null, mempoolInfo = null, - modifier = modifier + modifier = modifier, + viewModel = viewModel, + statusMessage = statusMessage ) } else { - // Show error only if we had data before ErrorDisplay( - message = uiState.message, + message = statusMessage ?: "Unknown error occurred", onRetry = viewModel::refreshData, modifier = modifier ) } } - else -> MainContentDisplay( - blockHeight = blockHeight, - blockTimestamp = blockTimestamp, - feeRates = feeRates, - mempoolInfo = mempoolInfo, - modifier = modifier - ) - } -} - -@Composable -private fun ErrorDisplay( - message: String, - onRetry: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onRetry) { - Text("Retry") + is DashboardUiState.Success -> { + MainContentDisplay( + blockHeight = blockHeight, + blockTimestamp = blockTimestamp, + feeRates = feeRates, + mempoolInfo = mempoolInfo, + modifier = modifier, + viewModel = viewModel, + statusMessage = statusMessage + ) + } + DashboardUiState.Loading -> { + MainContentDisplay( + blockHeight = blockHeight, + blockTimestamp = blockTimestamp, + feeRates = feeRates, + mempoolInfo = mempoolInfo, + modifier = modifier, + viewModel = viewModel, + statusMessage = statusMessage + ) } } } @@ -526,17 +573,74 @@ private fun MainContentDisplay( blockTimestamp: Long?, feeRates: FeeRates?, mempoolInfo: MempoolInfo?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: MainViewModel? = null, + statusMessage: String? = null ) { + val uiState by viewModel?.uiState?.collectAsState() ?: remember { mutableStateOf(DashboardUiState.Loading) } + val isInitialized = NetworkClient.isInitialized.collectAsState() + val isMainRefreshing by viewModel?.isMainRefreshing?.collectAsState() ?: remember { mutableStateOf(false) } + + // Remember the warning tooltip state + val warningTooltip = remember(mempoolInfo) { + if (mempoolInfo?.isUsingFallbackHistogram == true) { + "Your custom server doesn't provide fee distribution data. " + + "We're using mempool.space as a fallback source for this information." + } else null + } + + // Show spinner for loading states and connection messages + val showSpinner = !isInitialized.value || + uiState is DashboardUiState.Loading || + statusMessage?.contains("Connecting") == true || + statusMessage?.contains("Reconnecting") == true || + statusMessage?.contains("Fetching") == true + Column( modifier = modifier .fillMaxSize() .padding(horizontal = 12.dp) + .padding(bottom = 4.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + // Status message at the top + if (!statusMessage.isNullOrEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = AppColors.DarkerNavy + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (showSpinner) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .padding(end = 8.dp), + color = AppColors.Orange, + strokeWidth = 2.dp + ) + } + Text( + text = statusMessage, + style = MaterialTheme.typography.bodyMedium, + color = AppColors.DataGray + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + + // Existing cards DataCard( - title = "Current Block Height", + title = "Block Height", icon = Icons.Default.Numbers, content = blockHeight?.let { { @@ -557,7 +661,9 @@ private fun MainContentDisplay( } } }, - isLoading = blockHeight == null + isLoading = blockHeight == null, + onRefresh = viewModel?.let { { it.refreshBlockData() } }, + isMainRefreshing = isMainRefreshing ) DataCard( @@ -580,30 +686,39 @@ private fun MainContentDisplay( } } }, - isLoading = mempoolInfo == null + isLoading = mempoolInfo == null, + onRefresh = viewModel?.let { { it.refreshMempoolInfo() } }, + isMainRefreshing = isMainRefreshing ) DataCard( title = "Fee Rates (sat/vB)", - content = if (feeRates != null) { { FeeRatesContent(feeRates) } } else null, - icon = Icons.Default.CurrencyBitcoin, - tooltip = "This section shows a rough average of recommended fees with varying confirmation times." + - "\n\n*Note: At times a flood of transactions may enter the mempool and drastically push up fee rates. " + - "These floods are often temporary with only a few vMB worth of transactions that clear relatively quickly. " + - "In this scenario be sure to check the 'Fee Distribution' table to see how big the flood is and how quickly it will clear " + - "to ensure you do not overpay fees.", + content = feeRates?.let { { FeeRatesContent(it) } }, + icon = Icons.Default.Timeline, + tooltip = "This section shows the average recommended fee rate with estimated confirmation times." + + "\n\nNOTE: The mempool can sometimes experience a flood of transactions, leading to drastically higher fees. " + + "These floods are often only a few vMB and clear quickly. To avoid overpaying fees, use the " + + """"Fee Distribution" table to gauge the size and clearing time of the flood.""", + isLoading = feeRates == null, + onRefresh = viewModel?.let { { it.refreshFeeRates() } }, + isMainRefreshing = isMainRefreshing ) DataCard( title = "Fee Distribution", - content = if (mempoolInfo != null) { { HistogramContent(mempoolInfo) } } else null, + content = mempoolInfo?.let { { HistogramContent(it) } }, icon = Icons.Default.BarChart, - tooltip = "Fee Distribution shows a detailed breakdown of the mempool, giving you more insight into choosing the correct fee rate. " + + tooltip = "This section shows a detailed breakdown of the mempool. Fee ranges are shown on the left and " + + "the cumulative size of transactions on the right" + "\n\nRange Key:" + - "\n-Green will be confirmed in the next block." + - "\n-Yellow might be confirmed in the next block." + - "\n-Red will not be confirmed in the next block." + - "\n\n*Note: Each Bitcoin block confirms about 1.5vMB worth of transactions.", + "\n- Green will confirm in the next block." + + "\n- Yellow might confirm in the next block." + + "\n- Red will not confirm in the next block." + + "\n\nNOTE: Each Bitcoin block confirms about 1.5 vMB worth of transactions.", + warningTooltip = warningTooltip, + isLoading = mempoolInfo == null, + onRefresh = viewModel?.let { { it.refreshMempoolInfo() } }, + isMainRefreshing = isMainRefreshing ) } } @@ -616,10 +731,35 @@ private fun DataCard( value: String? = null, content: (@Composable () -> Unit)? = null, tooltip: String? = null, - isLoading: Boolean = value == null && content == null + warningTooltip: String? = null, + isLoading: Boolean = value == null && content == null, + onRefresh: (() -> Unit)? = null, + isMainRefreshing: Boolean = false ) { + var isRefreshing by remember { mutableStateOf(false) } + val rotation by animateFloatAsState( + targetValue = if (isRefreshing || isMainRefreshing) 360f else 0f, + animationSpec = tween( + durationMillis = 500, + easing = FastOutSlowInEasing + ), + finishedListener = { isRefreshing = false }, + label = "" + ) + Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .then( + if (onRefresh != null) { + Modifier.clickable { + if (!isLoading && !isRefreshing && !isMainRefreshing) { + isRefreshing = true + onRefresh() + } + } + } else Modifier + ), colors = CardDefaults.cardColors( containerColor = AppColors.DarkerNavy ) @@ -638,7 +778,9 @@ private fun DataCard( imageVector = icon, contentDescription = null, tint = AppColors.Orange, - modifier = Modifier.size(24.dp) + modifier = Modifier + .size(24.dp) + .graphicsLayer(rotationZ = if (isRefreshing || isMainRefreshing) rotation else 0f) ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -650,9 +792,25 @@ private fun DataCard( Spacer(modifier = Modifier.width(4.dp)) TooltipButton(tooltip = tooltip) } + if (warningTooltip != null) { + Spacer(modifier = Modifier.weight(1f)) + TooltipButton( + tooltip = warningTooltip, + icon = Icons.Default.Warning, + tint = AppColors.Orange + ) + } } - if (isLoading) { + if (value != null) { + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + color = AppColors.DataGray + ) + } else if (content != null) { + content() + } else if (isLoading) { Box( modifier = Modifier .fillMaxWidth() @@ -665,23 +823,17 @@ private fun DataCard( strokeWidth = 2.dp ) } - } else { - if (value != null) { - Text( - text = value, - style = MaterialTheme.typography.headlineMedium, - color = AppColors.DataGray - ) - } else if (content != null) { - content() - } } } } } @Composable -private fun TooltipButton(tooltip: String) { +private fun TooltipButton( + tooltip: String, + icon: ImageVector = Icons.Default.Info, + tint: Color = MaterialTheme.colorScheme.onSurface +) { var showTooltip by remember { mutableStateOf(false) } Box { IconButton( @@ -689,10 +841,10 @@ private fun TooltipButton(tooltip: String) { modifier = Modifier.size(34.dp) ) { Icon( - imageVector = Icons.Default.Info, + imageVector = icon, contentDescription = "Info", modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurface + tint = tint ) } if (showTooltip) { @@ -935,9 +1087,10 @@ private fun NotificationsScreen( Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .padding(horizontal = 12.dp) + .padding(bottom = 4.dp) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = "Notifications", @@ -970,14 +1123,14 @@ private fun NotificationsScreen( Text( text = if (settings.isServiceEnabled)"Stop Notification Service" else "Start Notification Service", style = MaterialTheme.typography.titleMedium, - color = Color.White + color = Color.White.copy(alpha = if (settings.isServiceEnabled || isAnyNotificationEnabled) 1f else 0.5f) ) } // Bitcoin Blocks section NotificationSection( config = NotificationSectionConfig( - title = stringResource(R.string.blocks_title), + title = "Blocks", description = "Get notified when blocks are mined.", enabled = settings.blockNotificationsEnabled, frequency = settings.blockCheckFrequency, @@ -1127,10 +1280,6 @@ private fun NotificationSection( newBlockConfig: NewBlockConfig? = null, specificBlockConfig: SpecificBlockConfig? = null ) { - var debouncedBlockHeight by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - var debounceJob by remember { mutableStateOf(null) } - Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -1140,10 +1289,9 @@ private fun NotificationSection( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Main header with toggle Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1166,11 +1314,8 @@ private fun NotificationSection( ) if (config.enabled && newBlockConfig != null) { - // New Block Notifications Sub-section Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( @@ -1198,7 +1343,7 @@ private fun NotificationSection( checkedThumbColor = Color.White, checkedTrackColor = AppColors.Orange, checkedBorderColor = AppColors.Orange, - uncheckedThumbColor = AppColors.DataGray, + uncheckedThumbColor = Color.Gray, uncheckedTrackColor = AppColors.DarkerNavy, uncheckedBorderColor = Color.Gray ) @@ -1206,23 +1351,12 @@ private fun NotificationSection( } if (newBlockConfig.enabled) { - OutlinedTextField( - value = newBlockConfig.frequency.toString(), + NumericTextField( + value = if (newBlockConfig.frequency == 0) "" else newBlockConfig.frequency.toString(), onValueChange = { - it.toIntOrNull()?.let { value -> - if (value > 0) newBlockConfig.onFrequencyChange(value) - } + newBlockConfig.onFrequencyChange(if (it.isEmpty()) 0 else it.toIntOrNull() ?: 0) }, - label = { Text("Check Frequency (minutes)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Check Interval (minutes)" ) } } @@ -1235,9 +1369,7 @@ private fun NotificationSection( ) Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( @@ -1265,7 +1397,7 @@ private fun NotificationSection( checkedThumbColor = Color.White, checkedTrackColor = AppColors.Orange, checkedBorderColor = AppColors.Orange, - uncheckedThumbColor = AppColors.DataGray, + uncheckedThumbColor = Color.Gray, uncheckedTrackColor = AppColors.DarkerNavy, uncheckedBorderColor = Color.Gray ) @@ -1273,51 +1405,33 @@ private fun NotificationSection( } if (specificBlockConfig.enabled) { - OutlinedTextField( + var debouncedBlockHeight by remember(specificBlockConfig.targetHeight) { + mutableStateOf(specificBlockConfig.targetHeight?.toString() ?: "") + } + + NumericTextField( value = debouncedBlockHeight, onValueChange = { newValue -> debouncedBlockHeight = newValue - if (newValue.isNotEmpty()) { + if (newValue.isEmpty()) { + specificBlockConfig.onTargetHeightChange(null) + } else { newValue.toIntOrNull()?.let { value -> if (value > 0) { - debounceJob?.cancel() - debounceJob = scope.launch { - delay(4000) // 4 second debounce - specificBlockConfig.onTargetHeightChange(value) - } + specificBlockConfig.onTargetHeightChange(value) } } } }, - label = { Text("Target Block Height", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Target Block Height" ) - OutlinedTextField( - value = specificBlockConfig.frequency.toString(), + NumericTextField( + value = if (specificBlockConfig.frequency == 0) "" else specificBlockConfig.frequency.toString(), onValueChange = { - it.toIntOrNull()?.let { value -> - if (value > 0) specificBlockConfig.onFrequencyChange(value) - } + specificBlockConfig.onFrequencyChange(if (it.isEmpty()) 0 else it.toIntOrNull() ?: 0) }, - label = { Text("Check Frequency (minutes)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Check Interval (minutes)" ) } } @@ -1338,9 +1452,7 @@ private fun MempoolSizeNotificationSection( onThresholdChange: (Float) -> Unit, onAboveThresholdChange: (Boolean) -> Unit ) { - var debouncedThreshold by remember { mutableFloatStateOf(threshold) } - val scope = rememberCoroutineScope() - var debounceJob by remember { mutableStateOf(null) } + var debouncedThreshold by remember(threshold) { mutableStateOf(if (threshold == 0f) "" else threshold.toString()) } Card( modifier = Modifier.fillMaxWidth(), @@ -1351,8 +1463,8 @@ private fun MempoolSizeNotificationSection( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -1366,7 +1478,13 @@ private fun MempoolSizeNotificationSection( ) Switch( checked = enabled, - onCheckedChange = onEnabledChange + onCheckedChange = onEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AppColors.Orange, + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = AppColors.DarkerNavy + ) ) } Text( @@ -1376,21 +1494,23 @@ private fun MempoolSizeNotificationSection( ) if (enabled) { Column( - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { ThresholdToggle( isAboveThreshold = aboveThreshold, onToggleChange = onAboveThresholdChange ) + OutlinedTextField( - value = debouncedThreshold.toString(), + value = debouncedThreshold, onValueChange = { newValue -> - newValue.toFloatOrNull()?.let { value -> - if (value > 0) { - debouncedThreshold = value - debounceJob?.cancel() - debounceJob = scope.launch { - delay(1000) // 1 second debounce + // Allow empty, digits, and a single decimal point + if (newValue.isEmpty() || newValue.matches(Regex("^\\d*\\.?\\d*$"))) { + debouncedThreshold = newValue + if (newValue.isEmpty()) { + onThresholdChange(0f) + } else { + newValue.toFloatOrNull()?.let { value -> onThresholdChange(value) } } @@ -1398,7 +1518,7 @@ private fun MempoolSizeNotificationSection( }, label = { Text("Threshold (vMB)", color = AppColors.DataGray) }, modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = AppColors.DataGray, @@ -1407,23 +1527,13 @@ private fun MempoolSizeNotificationSection( focusedTextColor = AppColors.Orange ) ) - OutlinedTextField( - value = frequency.toString(), + + NumericTextField( + value = if (frequency == 0) "" else frequency.toString(), onValueChange = { - it.toIntOrNull()?.let { value -> - if (value > 0) onFrequencyChange(value) - } + onFrequencyChange(if (it.isEmpty()) 0 else it.toIntOrNull() ?: 0) }, - label = { Text("Check Frequency (minutes)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Check Interval (minutes)" ) } } @@ -1445,9 +1555,7 @@ private fun FeeRatesNotificationSection( onAboveThresholdChange: (Boolean) -> Unit ) { var expanded by remember { mutableStateOf(false) } - var debouncedThreshold by remember { mutableIntStateOf(threshold) } - val scope = rememberCoroutineScope() - var debounceJob by remember { mutableStateOf(null) } + var debouncedThreshold by remember(threshold) { mutableIntStateOf(threshold) } Card( modifier = Modifier.fillMaxWidth(), @@ -1458,8 +1566,8 @@ private fun FeeRatesNotificationSection( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -1483,37 +1591,27 @@ private fun FeeRatesNotificationSection( ) if (enabled) { Column( - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { ThresholdToggle( isAboveThreshold = isAboveThreshold, onToggleChange = onAboveThresholdChange ) - OutlinedTextField( - value = debouncedThreshold.toString(), + NumericTextField( + value = if (debouncedThreshold == 0) "" else debouncedThreshold.toString(), onValueChange = { newValue -> - newValue.toIntOrNull()?.let { value -> - if (value > 0) { + if (newValue.isEmpty()) { + debouncedThreshold = 0 + onThresholdChange(0) + } else { + newValue.toIntOrNull()?.let { value -> debouncedThreshold = value - debounceJob?.cancel() - debounceJob = scope.launch { - delay(1000) // 1 second debounce - onThresholdChange(value) - } + onThresholdChange(value) } } }, - label = { Text("Threshold (sat/vB)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Threshold (sat/vB)" ) ExposedDropdownMenuBox( @@ -1523,8 +1621,8 @@ private fun FeeRatesNotificationSection( OutlinedTextField( value = when (selectedFeeRateType) { FeeRateType.NEXT_BLOCK -> "Next Block" - FeeRateType.TWO_BLOCKS -> "3 Blocks" - FeeRateType.FOUR_BLOCKS -> "6 Blocks" + FeeRateType.THREE_BLOCKS -> "3 Blocks" + FeeRateType.SIX_BLOCKS -> "6 Blocks" FeeRateType.DAY_BLOCKS -> "1 Day" }, label = { Text("Fee Rate", color = AppColors.DataGray) }, @@ -1555,14 +1653,14 @@ private fun FeeRatesNotificationSection( DropdownMenuItem( text = { Text("3 Blocks") }, onClick = { - onFeeRateTypeChange(FeeRateType.TWO_BLOCKS) + onFeeRateTypeChange(FeeRateType.THREE_BLOCKS) expanded = false } ) DropdownMenuItem( text = { Text("6 Blocks") }, onClick = { - onFeeRateTypeChange(FeeRateType.FOUR_BLOCKS) + onFeeRateTypeChange(FeeRateType.SIX_BLOCKS) expanded = false } ) @@ -1576,23 +1674,12 @@ private fun FeeRatesNotificationSection( } } - OutlinedTextField( - value = frequency.toString(), + NumericTextField( + value = if (frequency == 0) "" else frequency.toString(), onValueChange = { - it.toIntOrNull()?.let { value -> - if (value > 0) onFrequencyChange(value) - } + onFrequencyChange(if (it.isEmpty()) 0 else it.toIntOrNull() ?: 0) }, - label = { Text("Check Frequency (minutes)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Check Interval (minutes)" ) } } @@ -1618,8 +1705,8 @@ private fun TransactionConfirmationSection( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -1630,14 +1717,14 @@ private fun TransactionConfirmationSection( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Confirmation", - style = MaterialTheme.typography.headlineMedium, - color = AppColors.Orange - ) + text = "Confirmation", + style = MaterialTheme.typography.headlineMedium, + color = AppColors.Orange + ) Spacer(modifier = Modifier.width(4.dp)) TooltipButton( - tooltip = "Caution: This feature has privacy implications.\nIf you're concerned about privacy, be sure to use the " + - "'Enable Tor' option in settings or connect to your own custom mempool server.", + tooltip = "CAUTION: This feature has privacy implications.\nIf you're concerned about privacy, be sure to use the " + + """"Enable Tor" option in settings or connect to your own custom mempool server.""", ) } Switch( @@ -1646,17 +1733,21 @@ private fun TransactionConfirmationSection( ) } Text( - text = "Get notified when your transaction is confirmed.", + text = "Get notified when a transaction is confirmed.", style = MaterialTheme.typography.titleMedium, color = AppColors.DataGray ) if (enabled) { Column( - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { OutlinedTextField( value = transactionId, - onValueChange = onTransactionIdChange, + onValueChange = { newValue -> + if (newValue.isEmpty() || newValue.all { it.isLetterOrDigit() }) { + onTransactionIdChange(newValue) + } + }, label = { Text("Transaction ID", color = AppColors.DataGray) }, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -1667,23 +1758,12 @@ private fun TransactionConfirmationSection( focusedTextColor = AppColors.Orange ) ) - OutlinedTextField( - value = frequency.toString(), + NumericTextField( + value = if (frequency == 0) "" else frequency.toString(), onValueChange = { - it.toIntOrNull()?.let { value -> - if (value > 0) onFrequencyChange(value) - } + onFrequencyChange(if (it.isEmpty()) 0 else it.toIntOrNull() ?: 0) }, - label = { Text("Check Frequency (minutes)", color = AppColors.DataGray) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = AppColors.DataGray, - focusedBorderColor = AppColors.Orange, - unfocusedTextColor = AppColors.DataGray, - focusedTextColor = AppColors.Orange - ) + label = "Check Interval (minutes)" ) } } @@ -1699,18 +1779,36 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { val torStatus by torManager.torStatus.collectAsState() var torEnabled by remember { mutableStateOf(torManager.isTorEnabled()) } var updateFrequency by remember { mutableLongStateOf(settingsRepository.getUpdateFrequency()) } - + + // Track initial values + val initialTorEnabled = rememberSaveable { torManager.isTorEnabled() } + val initialApiUrl = rememberSaveable { settingsRepository.getApiUrl() } + var selectedOption by remember { mutableIntStateOf( if (settingsRepository.getApiUrl() == "https://mempool.space") 0 else 1 ) } - var customUrl by remember { mutableStateOf( - if (settingsRepository.getApiUrl() != "https://mempool.space") - settingsRepository.getApiUrl() else "" - ) } + val initialSelectedOption = rememberSaveable { + if (settingsRepository.getApiUrl() == "https://mempool.space") 0 else 1 + } + + var customUrl by remember { + mutableStateOf( + if (settingsRepository.getApiUrl() != "https://mempool.space") + settingsRepository.getApiUrl() else "" + ) + } var showRestartDialog by remember { mutableStateOf(false) } var showUrlError by remember { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + var isTestingConnection by remember { mutableStateOf(false) } + val savedServers = remember { mutableStateOf(settingsRepository.getSavedServers().toList()) } + + // Check if settings have changed + val hasServerSettingsChanged = selectedOption != initialSelectedOption || + (selectedOption == 1 && customUrl != initialApiUrl) || + torEnabled != initialTorEnabled // URL validation function fun isValidUrl(url: String): Boolean { @@ -1727,7 +1825,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { } "https://mempool.space" } else { - customUrl.trim() + customUrl.trim().trimEnd('/') } if (selectedOption == 1) { @@ -1748,10 +1846,6 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { } // Add this function to handle server testing - val scope = rememberCoroutineScope() - var isTestingConnection by remember { mutableStateOf(false) } - var testResult by remember { mutableStateOf(null) } - suspend fun testServerConnection(url: String): Boolean { return try { if (url.contains(".onion")) { @@ -1775,9 +1869,10 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .padding(horizontal = 12.dp) + .padding(bottom = 4.dp) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = "Settings", @@ -1809,12 +1904,12 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { selected = selectedOption == 0, onClick = { selectedOption = 0 - customUrl = "" if (torManager.isTorEnabled()) { torManager.stopTor(context) torEnabled = false } - } + }, + modifier = Modifier.fillMaxWidth() ) RadioOption( @@ -1824,99 +1919,210 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { ) if (selectedOption == 1) { - OutlinedTextField( - value = customUrl, - onValueChange = { - customUrl = it - showUrlError = false - testResult = null - if (it.contains(".onion")) { - if (!torEnabled) { - torEnabled = true - torManager.startTor(context) - } - } - }, - label = { Text(if (torEnabled) "Onion Address" else "Address", color = AppColors.DataGray) }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = AppColors.Orange, - unfocusedBorderColor = AppColors.DataGray - ), - isError = showUrlError - ) - - if (showUrlError) { - Text( - text = "URL must start with http:// or https://", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 16.dp, top = 4.dp) - ) + var isDropdownExpanded by remember { mutableStateOf(false) } + + // Function to check if URL is mempool.space + fun isDefaultServer(url: String): Boolean { + val trimmed = url.trim() + return trimmed == "mempool.space" || + trimmed == "mempool.space/" || + trimmed == "https://mempool.space" || + trimmed == "https://mempool.space/" } - // Test Server button and result - Column( + ExposedDropdownMenuBox( + expanded = isDropdownExpanded, + onExpandedChange = { newValue -> + // Only allow auto-collapse, not auto-expand + if (!newValue) isDropdownExpanded = false + }, modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .padding(top = 10.dp) ) { - Button( - onClick = { - isTestingConnection = true - scope.launch { - testResult = testServerConnection(customUrl) - isTestingConnection = false + OutlinedTextField( + value = customUrl, + onValueChange = { url -> + customUrl = url + showUrlError = isDefaultServer(url) + testResult = null + if (url.contains(".onion")) { + if (!torEnabled) { + torEnabled = true + torManager.startTor(context) + } } }, - enabled = !isTestingConnection && customUrl.isNotEmpty(), - colors = ButtonDefaults.buttonColors( - containerColor = AppColors.Orange, - disabledContainerColor = AppColors.Orange.copy(alpha = 0.5f) + label = { Text(if (torEnabled) "Onion Address" else "Server Address", color = AppColors.DataGray) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AppColors.Orange, + unfocusedBorderColor = AppColors.DataGray, + unfocusedTextColor = AppColors.DataGray, + focusedTextColor = AppColors.Orange ), - modifier = Modifier.fillMaxWidth() + isError = showUrlError, + supportingText = if (showUrlError) { + { Text( + text = if (isDefaultServer(customUrl)) + "Use default server option instead" + else "URL must start with http:// or https://", + color = MaterialTheme.colorScheme.error + ) } + } else null, + trailingIcon = { + IconButton(onClick = { isDropdownExpanded = !isDropdownExpanded }) { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = isDropdownExpanded) + } + } + ) + + ExposedDropdownMenu( + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + modifier = Modifier.exposedDropdownSize() ) { - if (isTestingConnection) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = Color.White, - strokeWidth = 2.dp + savedServers.value.forEach { serverUrl -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (serverUrl.length > 26) + serverUrl.take(26) + "..." + else serverUrl, + style = MaterialTheme.typography.bodyLarge, + color = AppColors.DataGray, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + settingsRepository.removeSavedServer(serverUrl) + savedServers.value = settingsRepository.getSavedServers().toList() + }, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete server", + tint = Color(0xFFA00000) // Brighter Dark Red + ) + } + } + }, + onClick = { + customUrl = serverUrl.trimEnd('/') + if (serverUrl.contains(".onion")) { + torEnabled = true + torManager.startTor(context) + } + isDropdownExpanded = false + }, + contentPadding = PaddingValues(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + colors = MenuDefaults.itemColors( + textColor = AppColors.DataGray + ) ) - } else { - Text("Test Server") } - } - if (testResult != null && !isTestingConnection && customUrl.isNotEmpty()) { - Text( - text = if (testResult == true) "Connection successful" else "Connection failed", - color = if (testResult == true) Color.Green else Color.Red, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) + if (savedServers.value.isEmpty()) { + DropdownMenuItem( + text = { + Text( + text = "No saved servers", + style = MaterialTheme.typography.bodyLarge, + color = AppColors.DataGray.copy(alpha = 0.7f) + ) + }, + onClick = { isDropdownExpanded = false }, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + colors = MenuDefaults.itemColors( + textColor = AppColors.DataGray + ) + ) + } } } - } - - // Tor controls - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { + // Connection status indicator + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = when { + selectedOption == 0 -> Color.Green + isTestingConnection -> Color.Yellow + testResult == true -> Color.Green + testResult == false -> Color.Red + else -> Color.Gray + }, + shape = CircleShape + ) + ) Text( - text = "Enable Tor", - style = MaterialTheme.typography.bodyLarge, + text = when { + selectedOption == 0 -> "Connected to mempool.space" + isTestingConnection -> "Testing connection..." + testResult == true -> "Connected successfully" + testResult == false -> "Connection failed" + else -> "Connection status" + }, + style = MaterialTheme.typography.bodySmall, color = AppColors.DataGray ) + } + + LaunchedEffect(customUrl, selectedOption, torEnabled, torStatus) { + if (selectedOption == 1 && customUrl.isNotEmpty() && + (customUrl.startsWith("http://") || customUrl.startsWith("https://"))) { + isTestingConnection = true + // Wait for 2s of no changes before testing + delay(2000) + // If Tor is enabled, wait until it's connected + if (torEnabled && torStatus != TorStatus.CONNECTED) { + testResult = null + isTestingConnection = false + return@LaunchedEffect + } + // First attempt + testResult = testServerConnection(customUrl) + // If first attempt fails, wait 1 second1 and try again + if (testResult == false) { + delay(1000) + testResult = testServerConnection(customUrl) + } + isTestingConnection = false + } + } + } + } + + // Tor settings section + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Enable Tor", + style = MaterialTheme.typography.titleMedium, + color = AppColors.DataGray + ) + if (torEnabled) { Text( text = "Status: ${torStatus.name}", color = when (torStatus) { @@ -1928,64 +2134,205 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp) ) - if (torEnabled) { - Text( - text = "Tor defaults to mempool.space's onion address.", - style = MaterialTheme.typography.bodySmall, - color = AppColors.DataGray.copy(alpha = 0.7f), - modifier = Modifier.padding(top = 2.dp) - ) - } + Text( + text = "Tor defaults to mempool.space's onion address.", + style = MaterialTheme.typography.bodySmall, + color = AppColors.DataGray.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 2.dp) + ) } - Switch( - checked = torEnabled, - onCheckedChange = { enabled -> - if (enabled && customUrl.isNotEmpty() && (!customUrl.startsWith("http://") && !customUrl.startsWith("https://"))) { - showUrlError = true - return@Switch - } - torEnabled = enabled - if (enabled) { - selectedOption = 1 - torManager.startTor(context) - if (customUrl.isEmpty()) { - customUrl = "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/" - } - } else { - torManager.stopTor(context) - customUrl = "" + } + Switch( + checked = torEnabled, + onCheckedChange = { enabled -> + if (enabled && customUrl.isNotEmpty() && (!customUrl.startsWith("http://") && !customUrl.startsWith("https://"))) { + showUrlError = true + return@Switch + } + torEnabled = enabled + if (enabled) { + selectedOption = 1 + torManager.startTor(context) + if (customUrl.isEmpty() || !customUrl.contains(".onion")) { + customUrl = "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion" } - }, - enabled = torStatus != TorStatus.CONNECTING, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = AppColors.Orange, - uncheckedThumbColor = AppColors.DataGray, - uncheckedTrackColor = AppColors.DarkerNavy, - checkedBorderColor = Color.Gray, - uncheckedBorderColor = Color.Gray - ) + } else { + torManager.stopTor(context) + } + }, + enabled = torStatus != TorStatus.CONNECTING, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AppColors.Orange, + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = AppColors.DarkerNavy, + checkedBorderColor = AppColors.Orange, + uncheckedBorderColor = Color.Gray ) - } + ) } Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Button( - onClick = { handleSave() }, + onClick = { + if (selectedOption == 1 && (customUrl.isEmpty() || (!customUrl.startsWith("http://") && !customUrl.startsWith("https://")))) { + showUrlError = true + return@Button + } + if (selectedOption == 1) { + settingsRepository.saveApiUrl(customUrl) + settingsRepository.addSavedServer(customUrl) + savedServers.value = settingsRepository.getSavedServers().toList() + } + handleSave() + }, + enabled = hasServerSettingsChanged && + !(torEnabled && torStatus != TorStatus.CONNECTED), // Disable if Tor is enabled but not connected colors = ButtonDefaults.buttonColors( - containerColor = AppColors.Orange + containerColor = AppColors.Orange, + disabledContainerColor = AppColors.Orange.copy(alpha = 0.5f) ), modifier = Modifier.fillMaxWidth() ) { - Text("Save") + Text( + text = if (torEnabled && torStatus != TorStatus.CONNECTED) + "Waiting for Tor..." + else + "Save" + ) + } + } + } + } + + // Notifications Settings Card + Card( + colors = CardDefaults.cardColors( + containerColor = AppColors.DarkerNavy + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Notifications", + style = MaterialTheme.typography.headlineMedium, + color = AppColors.Orange + ) + Spacer(modifier = Modifier.width(4.dp)) + TooltipButton( + tooltip = """For instant alerts, use this option to set the notifications "check interval" field to seconds.""" + ) + } + + var expanded by remember { mutableStateOf(false) } + val isUsingCustomServer = settingsRepository.getApiUrl() != "https://mempool.space" && + settingsRepository.getApiUrl() != "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion" + val serverRestartComplete = !settingsRepository.needsRestartForServer() + val timeUnitEnabled = isUsingCustomServer && serverRestartComplete + + // Force minutes when using default server or before restart + val selectedTimeUnit = remember { + if (!timeUnitEnabled) { + settingsRepository.saveNotificationTimeUnit("minutes") + mutableStateOf("minutes") + } else { + mutableStateOf(settingsRepository.getNotificationTimeUnit()) + } + } + + // Update time unit if server changes or restart status changes + LaunchedEffect(timeUnitEnabled) { + if (!timeUnitEnabled) { + selectedTimeUnit.value = "minutes" + settingsRepository.saveNotificationTimeUnit("minutes") + } + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (timeUnitEnabled) expanded = !expanded } + ) { + OutlinedTextField( + value = when (selectedTimeUnit.value) { + "seconds" -> "Seconds" + else -> "Minutes" + }, + onValueChange = {}, + readOnly = true, + enabled = timeUnitEnabled, + label = { Text("Check Interval Mode") }, + trailingIcon = { + if (timeUnitEnabled) { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .padding(top = 16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AppColors.Orange, + unfocusedBorderColor = AppColors.DataGray, + focusedLabelColor = AppColors.Orange, + disabledTextColor = AppColors.DataGray.copy(alpha = 0.7f), + disabledBorderColor = AppColors.DataGray.copy(alpha = 0.5f), + disabledLabelColor = AppColors.DataGray.copy(alpha = 0.7f) + ) + ) + + if (timeUnitEnabled) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + listOf("minutes", "seconds").forEach { unit -> + DropdownMenuItem( + text = { + Text( + when (unit) { + "minutes" -> "Minutes" + else -> "Seconds" + } + ) + }, + onClick = { + selectedTimeUnit.value = unit + settingsRepository.saveNotificationTimeUnit(unit) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } } } + + Text( + text = if (!isUsingCustomServer) { + "To prevent excessive data requests to mempool.space, " + + "this option is only available while using a custom server." + } else if (!serverRestartComplete) { + "Save a custom server to enable this option." + } else { + when (selectedTimeUnit.value) { + "seconds" -> "Check interval will be expressed in seconds." + else -> "Check interval will be expressed in minutes." + } + }, + style = MaterialTheme.typography.bodySmall, + color = AppColors.DataGray, + modifier = Modifier.padding(top = 4.dp, start = 12.dp) + ) } } @@ -2010,8 +2357,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { ) Spacer(modifier = Modifier.width(4.dp)) TooltipButton( - tooltip = "Widgets can be manually updated by tapping them once. " + - "You can also double tap any widget to open the Mempal app." + tooltip = "Any widget can be manually updated by tapping it once. You can also double tap any widget to open the Mempal app." ) } @@ -2029,7 +2375,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { }, onValueChange = {}, readOnly = true, - label = { Text("Update Frequency") }, + label = { Text("Update Interval") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier .fillMaxWidth() @@ -2046,13 +2392,12 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { expanded = expanded, onDismissRequest = { expanded = false } ) { - listOf(15L, 30L, 60L, 180L).forEach { minutes -> + listOf(5L, 15L, 30L, 60L).forEach { minutes -> DropdownMenuItem( text = { Text( when (minutes) { 60L -> "1 hour" - 180L -> "3 hours" else -> "$minutes minutes" } ) @@ -2072,12 +2417,11 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { Text( text = when (updateFrequency) { 60L -> "Widgets will update every hour." - 180L -> "Widgets will update every 3 hours." else -> "Widgets will update every $updateFrequency minutes." }, style = MaterialTheme.typography.bodySmall, color = AppColors.DataGray, - modifier = Modifier.padding(top = 4.dp, start = 16.dp) + modifier = Modifier.padding(top = 4.dp, start = 12.dp) ) } } @@ -2088,7 +2432,8 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), + .padding(top = 20.dp) + .padding(bottom = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) ) { @@ -2123,7 +2468,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { Text("Restart Required") }, text = { - Text("Please restart the app to save your settings.") + Text("Please restart the app to save your custom server settings.") }, confirmButton = { TextButton( @@ -2138,11 +2483,6 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { ) { Text("Restart Now") } - }, - dismissButton = { - TextButton(onClick = { showRestartDialog = false }) { - Text("Later") - } } ) } @@ -2152,10 +2492,11 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { private fun RadioOption( text: String, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(40.dp) .selectable(selected = selected, onClick = onClick), @@ -2190,7 +2531,7 @@ private fun ThresholdToggle( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Notify when", + text = "Notify when:", style = MaterialTheme.typography.titleMedium, color = AppColors.DataGray ) @@ -2221,4 +2562,98 @@ private fun ThresholdToggle( ) } } +} + +@Composable +private fun NumericTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val settingsRepository = remember { SettingsRepository.getInstance(context) } + var fieldValue by remember(value) { mutableStateOf(value) } + val timeUnit = settingsRepository.getNotificationTimeUnit() + val isCheckIntervalField = label.startsWith("Check Interval") + + // Only apply time unit conversion for check interval fields + val displayValue = if (isCheckIntervalField) { + if (fieldValue.isNotEmpty()) { + fieldValue + } else { + "" + } + } else { + fieldValue + } + + OutlinedTextField( + value = displayValue, + onValueChange = { newValue -> + if (newValue.isEmpty() || newValue.all { char -> char.isDigit() }) { + val numericValue = newValue.toIntOrNull() ?: 0 + if (newValue.isEmpty() || numericValue >= NotificationSettings.MIN_CHECK_FREQUENCY) { + fieldValue = newValue + onValueChange(newValue) + } + } + }, + label = { + Text( + if (isCheckIntervalField) { + "Check Interval (${if (timeUnit == "seconds") "seconds" else "minutes"})" + } else { + label + }, + color = AppColors.DataGray + ) + }, + modifier = modifier + .fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = AppColors.DataGray, + focusedBorderColor = AppColors.Orange, + unfocusedTextColor = AppColors.DataGray, + focusedTextColor = AppColors.Orange + ) + ) +} + +@Composable +private fun ErrorDisplay( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (message.contains("Connecting") || message.contains("Reconnecting") || message.contains("Fetching")) { + CircularProgressIndicator( + color = AppColors.Orange, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = if (message.contains("Connecting") || message.contains("Reconnecting") || message.contains("Fetching")) + AppColors.DataGray + else + MaterialTheme.colorScheme.error + ) + if (!message.contains("Connecting") && !message.contains("Reconnecting") && !message.contains("Fetching")) { + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text("Retry") + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/api/Models.kt b/app/src/main/java/com/example/mempal/api/Models.kt index 58b7c5c..82dc367 100644 --- a/app/src/main/java/com/example/mempal/api/Models.kt +++ b/app/src/main/java/com/example/mempal/api/Models.kt @@ -1,23 +1,33 @@ -package com.example.mempal.api - -import com.google.gson.annotations.SerializedName - -data class FeeRates( - @SerializedName("fastestFee") val fastestFee: Int = 0, - @SerializedName("halfHourFee") val halfHourFee: Int = 0, - @SerializedName("hourFee") val hourFee: Int = 0, - @SerializedName("economyFee") val economyFee: Int = 0 -) - -data class MempoolInfo( - @SerializedName("vsize") val vsize: Long = 0L, - @SerializedName("total_fee") val totalFee: Double = 0.0, - @SerializedName("unconfirmed_count") val unconfirmedCount: Int = 0, - @SerializedName("fee_histogram") val feeHistogram: List> = emptyList() -) - -sealed class Result { - data class Success(val data: T) : Result() - data class Error(val message: String) : Result() - object Loading : Result() +package com.example.mempal.api + +import com.google.gson.annotations.SerializedName + +data class FeeRates( + @SerializedName("fastestFee") val fastestFee: Int = 0, + @SerializedName("halfHourFee") val halfHourFee: Int = 0, + @SerializedName("hourFee") val hourFee: Int = 0, + @SerializedName("economyFee") val economyFee: Int = 0 +) + +data class MempoolInfo( + @SerializedName("vsize") val vsize: Long = 0L, + @SerializedName("total_fee") val totalFee: Double = 0.0, + @SerializedName("unconfirmed_count") val unconfirmedCount: Int = 0, + @SerializedName("fee_histogram") val feeHistogram: List> = emptyList(), + val isUsingFallbackHistogram: Boolean = false +) { + fun needsHistogramFallback(): Boolean = feeHistogram.isEmpty() + + fun withFallbackHistogram(fallbackHistogram: List>): MempoolInfo { + return copy( + feeHistogram = fallbackHistogram, + isUsingFallbackHistogram = true + ) + } +} + +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val message: String) : Result() + object Loading : Result() } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/api/NetworkClient.kt b/app/src/main/java/com/example/mempal/api/NetworkClient.kt index 9e055e2..b31048d 100644 --- a/app/src/main/java/com/example/mempal/api/NetworkClient.kt +++ b/app/src/main/java/com/example/mempal/api/NetworkClient.kt @@ -1,6 +1,10 @@ package com.example.mempal.api import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import com.example.mempal.repository.SettingsRepository import com.example.mempal.tor.TorManager import com.example.mempal.tor.TorStatus @@ -22,6 +26,27 @@ object NetworkClient { private val _isInitialized = MutableStateFlow(false) val isInitialized: StateFlow = _isInitialized private var coroutineScope: CoroutineScope? = null + private var connectivityManager: ConnectivityManager? = null + private val _isNetworkAvailable = MutableStateFlow(false) + val isNetworkAvailable: StateFlow = _isNetworkAvailable + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + coroutineScope?.launch { + _isNetworkAvailable.value = true + if (_isInitialized.value) { + // Reinitialize the client when network becomes available + setupRetrofit(TorManager.getInstance().torStatus.value == TorStatus.CONNECTED) + } + } + } + + override fun onLost(network: Network) { + super.onLost(network) + _isNetworkAvailable.value = false + } + } private val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY @@ -35,6 +60,17 @@ object NetworkClient { contextRef = WeakReference(context.applicationContext) coroutineScope?.cancel() // Cancel any existing scope coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // Setup connectivity monitoring + connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager?.registerNetworkCallback(networkRequest, networkCallback) + + // Check initial network state + _isNetworkAvailable.value = isNetworkCurrentlyAvailable() + val torManager = TorManager.getInstance() coroutineScope?.launch { @@ -42,20 +78,32 @@ object NetworkClient { println("Tor status changed: $status") if (status == TorStatus.CONNECTED || status == TorStatus.DISCONNECTED) { println("Setting up Retrofit with useProxy=${status == TorStatus.CONNECTED}") - setupRetrofit(status == TorStatus.CONNECTED) - _isInitialized.value = true - println("NetworkClient initialization complete") + if (isNetworkCurrentlyAvailable()) { + setupRetrofit(status == TorStatus.CONNECTED) + _isInitialized.value = true + println("NetworkClient initialization complete") + } } } } } + private fun isNetworkCurrentlyAvailable(): Boolean { + val cm = connectivityManager ?: return false + val activeNetwork = cm.activeNetwork ?: return false + val capabilities = cm.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + fun cleanup() { + connectivityManager?.unregisterNetworkCallback(networkCallback) + connectivityManager = null coroutineScope?.cancel() coroutineScope = null contextRef = null retrofit = null _isInitialized.value = false + _isNetworkAvailable.value = false } private fun setupRetrofit(useProxy: Boolean) { diff --git a/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt b/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt new file mode 100644 index 0000000..6159f7e --- /dev/null +++ b/app/src/main/java/com/example/mempal/api/WidgetNetworkClient.kt @@ -0,0 +1,52 @@ +package com.example.mempal.api + +import android.content.Context +import com.example.mempal.repository.SettingsRepository +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object WidgetNetworkClient { + private const val TIMEOUT_SECONDS = 30L + private const val DEFAULT_API_URL = "https://mempool.space" + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + fun getMempoolApi(context: Context): MempoolApi { + val settingsRepository = SettingsRepository.getInstance(context) + val userApiUrl = settingsRepository.getApiUrl() + + // If the user's custom server is a .onion address, use the default mempool.space + val baseUrl = if (userApiUrl.contains(".onion")) { + DEFAULT_API_URL + } else { + userApiUrl + }.let { url -> + if (!url.endsWith("/")) "$url/" else url + } + + val clientBuilder = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + + val gson = GsonBuilder() + .setLenient() + .create() + + val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(clientBuilder.build()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + return retrofit.create(MempoolApi::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/cache/DashboardCache.kt b/app/src/main/java/com/example/mempal/cache/DashboardCache.kt new file mode 100644 index 0000000..2c5f6fa --- /dev/null +++ b/app/src/main/java/com/example/mempal/cache/DashboardCache.kt @@ -0,0 +1,61 @@ +package com.example.mempal.cache + +import com.example.mempal.api.FeeRates +import com.example.mempal.api.MempoolInfo + +// Singleton object to store dashboard data in memory +object DashboardCache { + private var blockHeight: Int? = null + private var blockTimestamp: Long? = null + private var mempoolInfo: MempoolInfo? = null + private var feeRates: FeeRates? = null + private var lastUpdateTime: Long? = null + + // Save all dashboard data at once + fun saveState( + blockHeight: Int?, + blockTimestamp: Long?, + mempoolInfo: MempoolInfo?, + feeRates: FeeRates? + ) { + this.blockHeight = blockHeight + this.blockTimestamp = blockTimestamp + this.mempoolInfo = mempoolInfo + this.feeRates = feeRates + this.lastUpdateTime = System.currentTimeMillis() + } + + // Get cached state + fun getCachedState(): DashboardState { + return DashboardState( + blockHeight = blockHeight, + blockTimestamp = blockTimestamp, + mempoolInfo = mempoolInfo, + feeRates = feeRates, + lastUpdateTime = lastUpdateTime + ) + } + + // Check if we have any cached data + fun hasCachedData(): Boolean { + return blockHeight != null || mempoolInfo != null || feeRates != null + } + + // Clear cache (useful when app is closing) + fun clearCache() { + blockHeight = null + blockTimestamp = null + mempoolInfo = null + feeRates = null + lastUpdateTime = null + } +} + +// Data class to hold all dashboard state +data class DashboardState( + val blockHeight: Int?, + val blockTimestamp: Long?, + val mempoolInfo: MempoolInfo?, + val feeRates: FeeRates?, + val lastUpdateTime: Long? +) \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/model/NotificationSettings.kt b/app/src/main/java/com/example/mempal/model/NotificationSettings.kt index ffdfec6..9dee5ea 100644 --- a/app/src/main/java/com/example/mempal/model/NotificationSettings.kt +++ b/app/src/main/java/com/example/mempal/model/NotificationSettings.kt @@ -2,35 +2,39 @@ package com.example.mempal.model enum class FeeRateType { NEXT_BLOCK, - TWO_BLOCKS, - FOUR_BLOCKS, + THREE_BLOCKS, + SIX_BLOCKS, DAY_BLOCKS } data class NotificationSettings( val blockNotificationsEnabled: Boolean = false, - val blockCheckFrequency: Int = 10, + val blockCheckFrequency: Int = 15, val newBlockNotificationEnabled: Boolean = false, - val newBlockCheckFrequency: Int = 10, + val newBlockCheckFrequency: Int = 15, val hasNotifiedForNewBlock: Boolean = false, val specificBlockNotificationEnabled: Boolean = false, - val specificBlockCheckFrequency: Int = 10, + val specificBlockCheckFrequency: Int = 15, val targetBlockHeight: Int? = null, val hasNotifiedForTargetBlock: Boolean = false, val mempoolSizeNotificationsEnabled: Boolean = false, - val mempoolCheckFrequency: Int = 10, - val mempoolSizeThreshold: Float = 10f, + val mempoolCheckFrequency: Int = 15, + val mempoolSizeThreshold: Float = 0f, val mempoolSizeAboveThreshold: Boolean = false, val feeRatesNotificationsEnabled: Boolean = false, - val feeRatesCheckFrequency: Int = 10, + val feeRatesCheckFrequency: Int = 15, val selectedFeeRateType: FeeRateType = FeeRateType.NEXT_BLOCK, - val feeRateThreshold: Int = 1, + val feeRateThreshold: Int = 0, val feeRateAboveThreshold: Boolean = false, val isServiceEnabled: Boolean = false, val txConfirmationEnabled: Boolean = false, - val txConfirmationFrequency: Int = 10, + val txConfirmationFrequency: Int = 15, val transactionId: String = "", val hasNotifiedForCurrentTx: Boolean = false, val hasNotifiedForMempoolSize: Boolean = false, val hasNotifiedForFeeRate: Boolean = false -) \ No newline at end of file +) { + companion object { + const val MIN_CHECK_FREQUENCY = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/repository/SettingsRepository.kt b/app/src/main/java/com/example/mempal/repository/SettingsRepository.kt index a96c72e..6fe5f78 100644 --- a/app/src/main/java/com/example/mempal/repository/SettingsRepository.kt +++ b/app/src/main/java/com/example/mempal/repository/SettingsRepository.kt @@ -24,7 +24,11 @@ class SettingsRepository private constructor(context: Context) { private const val KEY_API_URL = "api_url" private const val KEY_UPDATE_FREQUENCY = "widget_update_frequency" private const val DEFAULT_API_URL = "https://mempool.space" - private const val DEFAULT_UPDATE_FREQUENCY = 30L // 30 minutes + private const val DEFAULT_UPDATE_FREQUENCY = 15L // 15 minutes + private const val KEY_NOTIFICATION_TIME_UNIT = "notification_time_unit" + private const val DEFAULT_TIME_UNIT = "minutes" + private const val KEY_SERVER_NEEDS_RESTART = "server_needs_restart" + private const val KEY_SAVED_SERVERS = "saved_servers" // Notification Settings Keys private const val KEY_BLOCK_NOTIFICATIONS_ENABLED = "block_notifications_enabled" @@ -73,7 +77,10 @@ class SettingsRepository private constructor(context: Context) { } fun saveApiUrl(url: String) { - prefs.edit().putString(KEY_API_URL, url).apply() + prefs.edit() + .putString(KEY_API_URL, url) + .putBoolean(KEY_SERVER_NEEDS_RESTART, true) + .apply() } fun getUpdateFrequency(): Long { @@ -88,26 +95,34 @@ class SettingsRepository private constructor(context: Context) { prefs.edit().putLong(KEY_UPDATE_FREQUENCY, minutes).apply() } + fun getNotificationTimeUnit(): String { + return prefs.getString(KEY_NOTIFICATION_TIME_UNIT, DEFAULT_TIME_UNIT) ?: DEFAULT_TIME_UNIT + } + + fun saveNotificationTimeUnit(timeUnit: String) { + prefs.edit().putString(KEY_NOTIFICATION_TIME_UNIT, timeUnit).apply() + } + private fun loadNotificationSettings(): NotificationSettings { return NotificationSettings( blockNotificationsEnabled = prefs.getBoolean(KEY_BLOCK_NOTIFICATIONS_ENABLED, false), - blockCheckFrequency = prefs.getInt(KEY_BLOCK_CHECK_FREQUENCY, 10), + blockCheckFrequency = if (prefs.contains(KEY_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_BLOCK_CHECK_FREQUENCY, 0) else 0, newBlockNotificationEnabled = prefs.getBoolean(KEY_NEW_BLOCK_NOTIFICATIONS_ENABLED, false), - newBlockCheckFrequency = prefs.getInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, 10), + newBlockCheckFrequency = if (prefs.contains(KEY_NEW_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, 0) else 0, specificBlockNotificationEnabled = prefs.getBoolean(KEY_SPECIFIC_BLOCK_NOTIFICATIONS_ENABLED, false), - specificBlockCheckFrequency = prefs.getInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, 10), + specificBlockCheckFrequency = if (prefs.contains(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY)) prefs.getInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, 0) else 0, targetBlockHeight = if (prefs.contains(KEY_TARGET_BLOCK_HEIGHT)) prefs.getInt(KEY_TARGET_BLOCK_HEIGHT, -1) else null, mempoolSizeNotificationsEnabled = prefs.getBoolean(KEY_MEMPOOL_SIZE_NOTIFICATIONS_ENABLED, false), - mempoolCheckFrequency = prefs.getInt(KEY_MEMPOOL_CHECK_FREQUENCY, 10), - mempoolSizeThreshold = prefs.getFloat(KEY_MEMPOOL_SIZE_THRESHOLD, 10f), + mempoolCheckFrequency = if (prefs.contains(KEY_MEMPOOL_CHECK_FREQUENCY)) prefs.getInt(KEY_MEMPOOL_CHECK_FREQUENCY, 0) else 0, + mempoolSizeThreshold = if (prefs.contains(KEY_MEMPOOL_SIZE_THRESHOLD)) prefs.getFloat(KEY_MEMPOOL_SIZE_THRESHOLD, 0f) else 0f, mempoolSizeAboveThreshold = prefs.getBoolean(KEY_MEMPOOL_SIZE_ABOVE_THRESHOLD, false), feeRatesNotificationsEnabled = prefs.getBoolean(KEY_FEE_RATES_NOTIFICATIONS_ENABLED, false), - feeRatesCheckFrequency = prefs.getInt(KEY_FEE_RATES_CHECK_FREQUENCY, 10), + feeRatesCheckFrequency = if (prefs.contains(KEY_FEE_RATES_CHECK_FREQUENCY)) prefs.getInt(KEY_FEE_RATES_CHECK_FREQUENCY, 0) else 0, selectedFeeRateType = FeeRateType.entries[prefs.getInt(KEY_SELECTED_FEE_RATE_TYPE, 0)], - feeRateThreshold = prefs.getInt(KEY_FEE_RATE_THRESHOLD, 1), + feeRateThreshold = if (prefs.contains(KEY_FEE_RATE_THRESHOLD)) prefs.getInt(KEY_FEE_RATE_THRESHOLD, 0) else 0, feeRateAboveThreshold = prefs.getBoolean(KEY_FEE_RATE_ABOVE_THRESHOLD, false), txConfirmationEnabled = prefs.getBoolean(KEY_TX_CONFIRMATION_ENABLED, false), - txConfirmationFrequency = prefs.getInt(KEY_TX_CONFIRMATION_FREQUENCY, 10), + txConfirmationFrequency = if (prefs.contains(KEY_TX_CONFIRMATION_FREQUENCY)) prefs.getInt(KEY_TX_CONFIRMATION_FREQUENCY, 0) else 0, transactionId = prefs.getString(KEY_TRANSACTION_ID, "") ?: "" ) } @@ -118,24 +133,48 @@ class SettingsRepository private constructor(context: Context) { // Save all notification settings except service state prefs.edit().apply { putBoolean(KEY_BLOCK_NOTIFICATIONS_ENABLED, settings.blockNotificationsEnabled) - putInt(KEY_BLOCK_CHECK_FREQUENCY, settings.blockCheckFrequency) + if (settings.blockCheckFrequency == 0) remove(KEY_BLOCK_CHECK_FREQUENCY) else putInt(KEY_BLOCK_CHECK_FREQUENCY, settings.blockCheckFrequency) putBoolean(KEY_NEW_BLOCK_NOTIFICATIONS_ENABLED, settings.newBlockNotificationEnabled) - putInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, settings.newBlockCheckFrequency) + if (settings.newBlockCheckFrequency == 0) remove(KEY_NEW_BLOCK_CHECK_FREQUENCY) else putInt(KEY_NEW_BLOCK_CHECK_FREQUENCY, settings.newBlockCheckFrequency) putBoolean(KEY_SPECIFIC_BLOCK_NOTIFICATIONS_ENABLED, settings.specificBlockNotificationEnabled) - putInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, settings.specificBlockCheckFrequency) + if (settings.specificBlockCheckFrequency == 0) remove(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY) else putInt(KEY_SPECIFIC_BLOCK_CHECK_FREQUENCY, settings.specificBlockCheckFrequency) settings.targetBlockHeight?.let { putInt(KEY_TARGET_BLOCK_HEIGHT, it) } ?: remove(KEY_TARGET_BLOCK_HEIGHT) putBoolean(KEY_MEMPOOL_SIZE_NOTIFICATIONS_ENABLED, settings.mempoolSizeNotificationsEnabled) - putInt(KEY_MEMPOOL_CHECK_FREQUENCY, settings.mempoolCheckFrequency) - putFloat(KEY_MEMPOOL_SIZE_THRESHOLD, settings.mempoolSizeThreshold) + if (settings.mempoolCheckFrequency == 0) remove(KEY_MEMPOOL_CHECK_FREQUENCY) else putInt(KEY_MEMPOOL_CHECK_FREQUENCY, settings.mempoolCheckFrequency) + if (settings.mempoolSizeThreshold == 0f) remove(KEY_MEMPOOL_SIZE_THRESHOLD) else putFloat(KEY_MEMPOOL_SIZE_THRESHOLD, settings.mempoolSizeThreshold) putBoolean(KEY_MEMPOOL_SIZE_ABOVE_THRESHOLD, settings.mempoolSizeAboveThreshold) putBoolean(KEY_FEE_RATES_NOTIFICATIONS_ENABLED, settings.feeRatesNotificationsEnabled) - putInt(KEY_FEE_RATES_CHECK_FREQUENCY, settings.feeRatesCheckFrequency) + if (settings.feeRatesCheckFrequency == 0) remove(KEY_FEE_RATES_CHECK_FREQUENCY) else putInt(KEY_FEE_RATES_CHECK_FREQUENCY, settings.feeRatesCheckFrequency) putInt(KEY_SELECTED_FEE_RATE_TYPE, settings.selectedFeeRateType.ordinal) - putInt(KEY_FEE_RATE_THRESHOLD, settings.feeRateThreshold) + if (settings.feeRateThreshold == 0) remove(KEY_FEE_RATE_THRESHOLD) else putInt(KEY_FEE_RATE_THRESHOLD, settings.feeRateThreshold) putBoolean(KEY_FEE_RATE_ABOVE_THRESHOLD, settings.feeRateAboveThreshold) putBoolean(KEY_TX_CONFIRMATION_ENABLED, settings.txConfirmationEnabled) - putInt(KEY_TX_CONFIRMATION_FREQUENCY, settings.txConfirmationFrequency) + if (settings.txConfirmationFrequency == 0) remove(KEY_TX_CONFIRMATION_FREQUENCY) else putInt(KEY_TX_CONFIRMATION_FREQUENCY, settings.txConfirmationFrequency) putString(KEY_TRANSACTION_ID, settings.transactionId) }.apply() } + + fun clearServerRestartFlag() { + prefs.edit().putBoolean(KEY_SERVER_NEEDS_RESTART, false).apply() + } + + fun needsRestartForServer(): Boolean { + return prefs.getBoolean(KEY_SERVER_NEEDS_RESTART, false) + } + + fun getSavedServers(): Set { + return prefs.getStringSet(KEY_SAVED_SERVERS, setOf()) ?: setOf() + } + + fun addSavedServer(url: String) { + val currentServers = getSavedServers().toMutableSet() + currentServers.add(url.trimEnd('/')) + prefs.edit().putStringSet(KEY_SAVED_SERVERS, currentServers).apply() + } + + fun removeSavedServer(url: String) { + val currentServers = getSavedServers().toMutableSet() + currentServers.remove(url) + prefs.edit().putStringSet(KEY_SAVED_SERVERS, currentServers).apply() + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/service/NotificationService.kt b/app/src/main/java/com/example/mempal/service/NotificationService.kt index 4a2d6ea..493b33f 100644 --- a/app/src/main/java/com/example/mempal/service/NotificationService.kt +++ b/app/src/main/java/com/example/mempal/service/NotificationService.kt @@ -10,20 +10,24 @@ import com.example.mempal.R import com.example.mempal.api.NetworkClient import com.example.mempal.model.FeeRateType import com.example.mempal.repository.SettingsRepository +import com.example.mempal.tor.TorManager +import com.example.mempal.tor.TorStatus import kotlinx.coroutines.* import kotlinx.coroutines.flow.first class NotificationService : Service() { companion object { const val NOTIFICATION_ID = 1 + const val CHANNEL_ID = "mempal_notifications" + private const val TAG = "NotificationService" } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var settingsRepository: SettingsRepository private val api = NetworkClient.mempoolApi - private val channelId = "mempal_notifications" private var lastBlockHeight: Int? = null private val monitoringJobs = mutableMapOf() + private var lastCheckTimes = mutableMapOf() override fun onBind(intent: Intent?): IBinder? = null @@ -33,6 +37,12 @@ class NotificationService : Service() { NetworkClient.initialize(applicationContext) createNotificationChannel() + // Start Tor foreground service if Tor is connected + val torManager = TorManager.getInstance() + if (torManager.torStatus.value == TorStatus.CONNECTED) { + torManager.startForegroundService(applicationContext) + } + // Update settings to show service is enabled when starting serviceScope.launch { settingsRepository.updateSettings( @@ -42,6 +52,17 @@ class NotificationService : Service() { startForeground(NOTIFICATION_ID, createForegroundNotification()) startMonitoring() + + // Monitor Tor status changes to manage foreground service + serviceScope.launch { + torManager.torStatus.collect { status -> + when (status) { + TorStatus.CONNECTED -> torManager.startForegroundService(applicationContext) + TorStatus.DISCONNECTED, TorStatus.ERROR -> torManager.stopForegroundService(applicationContext) + TorStatus.CONNECTING -> {} // Do nothing for CONNECTING state + } + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -51,51 +72,108 @@ class NotificationService : Service() { private fun startMonitoring() { serviceScope.launch { + // Monitor both settings and time unit changes settingsRepository.settings.collect { settings -> + android.util.Log.d(TAG, "Settings updated, restarting monitoring jobs") monitoringJobs.values.forEach { it.cancel() } monitoringJobs.clear() + lastCheckTimes.clear() + + val timeUnit = settingsRepository.getNotificationTimeUnit() + val delayMultiplier = if (timeUnit == "seconds") 1000L else 60000L + android.util.Log.d(TAG, "Time unit: $timeUnit, Delay multiplier: $delayMultiplier") if (settings.newBlockNotificationEnabled) { monitoringJobs["newBlock"] = launch { + // Initial delay before first check + delay(settings.newBlockCheckFrequency * delayMultiplier) while (isActive) { - checkNewBlocks() - delay(settings.newBlockCheckFrequency * 60 * 1000L) + val now = System.currentTimeMillis() + val lastCheck = lastCheckTimes["newBlock"] ?: 0L + val interval = settings.newBlockCheckFrequency * delayMultiplier + + if (now - lastCheck >= interval) { + android.util.Log.d(TAG, "Checking new blocks. Interval: ${settings.newBlockCheckFrequency} $timeUnit") + checkNewBlocks() + lastCheckTimes["newBlock"] = now + } + delay(1000) } } } if (settings.specificBlockNotificationEnabled) { monitoringJobs["specificBlock"] = launch { + // Initial delay before first check + delay(settings.specificBlockCheckFrequency * delayMultiplier) while (isActive) { - checkNewBlocks() - delay(settings.specificBlockCheckFrequency * 60 * 1000L) + val now = System.currentTimeMillis() + val lastCheck = lastCheckTimes["specificBlock"] ?: 0L + val interval = settings.specificBlockCheckFrequency * delayMultiplier + + if (now - lastCheck >= interval) { + android.util.Log.d(TAG, "Checking specific block. Interval: ${settings.specificBlockCheckFrequency} $timeUnit") + checkNewBlocks() + lastCheckTimes["specificBlock"] = now + } + delay(1000) } } } if (settings.mempoolSizeNotificationsEnabled) { monitoringJobs["mempoolSize"] = launch { + // Initial delay before first check + delay(settings.mempoolCheckFrequency * delayMultiplier) while (isActive) { - checkMempoolSize(settings.mempoolSizeThreshold) - delay(settings.mempoolCheckFrequency * 60 * 1000L) + val now = System.currentTimeMillis() + val lastCheck = lastCheckTimes["mempoolSize"] ?: 0L + val interval = settings.mempoolCheckFrequency * delayMultiplier + + if (now - lastCheck >= interval) { + android.util.Log.d(TAG, "Checking mempool size. Interval: ${settings.mempoolCheckFrequency} $timeUnit") + checkMempoolSize(settings.mempoolSizeThreshold) + lastCheckTimes["mempoolSize"] = now + } + delay(1000) } } } if (settings.feeRatesNotificationsEnabled) { monitoringJobs["feeRates"] = launch { + // Initial delay before first check + delay(settings.feeRatesCheckFrequency * delayMultiplier) while (isActive) { - checkFeeRates(settings.selectedFeeRateType, settings.feeRateThreshold) - delay(settings.feeRatesCheckFrequency * 60 * 1000L) + val now = System.currentTimeMillis() + val lastCheck = lastCheckTimes["feeRates"] ?: 0L + val interval = settings.feeRatesCheckFrequency * delayMultiplier + + if (now - lastCheck >= interval) { + android.util.Log.d(TAG, "Checking fee rates. Interval: ${settings.feeRatesCheckFrequency} $timeUnit") + checkFeeRates(settings.selectedFeeRateType, settings.feeRateThreshold) + lastCheckTimes["feeRates"] = now + } + delay(1000) } } } if (settings.txConfirmationEnabled && settings.transactionId.isNotEmpty()) { monitoringJobs["txConfirmation"] = launch { + // Initial delay before first check + delay(settings.txConfirmationFrequency * delayMultiplier) while (isActive) { - checkTransactionConfirmation(settings.transactionId) - delay(settings.txConfirmationFrequency * 60 * 1000L) + val now = System.currentTimeMillis() + val lastCheck = lastCheckTimes["txConfirmation"] ?: 0L + val interval = settings.txConfirmationFrequency * delayMultiplier + + if (now - lastCheck >= interval) { + android.util.Log.d(TAG, "Checking transaction confirmation. Interval: ${settings.txConfirmationFrequency} $timeUnit") + checkTransactionConfirmation(settings.transactionId) + lastCheckTimes["txConfirmation"] = now + } + delay(1000) } } } @@ -106,83 +184,103 @@ class NotificationService : Service() { @SuppressLint("DefaultLocale") private suspend fun checkMempoolSize(threshold: Float) { try { + android.util.Log.d(TAG, "Making mempool size API call...") val settings = settingsRepository.settings.first() - if (settings.hasNotifiedForMempoolSize) { - return - } + + // Remove this early return + // if (settings.hasNotifiedForMempoolSize) { + // return + // } val mempoolInfo = api.getMempoolInfo() if (mempoolInfo.isSuccessful) { - val currentSize = mempoolInfo.body()?.vsize?.toFloat()?.div(1_000_000f) ?: return - val shouldNotify = if (settings.mempoolSizeAboveThreshold) { - currentSize > threshold - } else { - currentSize < threshold - } + val currentSize = mempoolInfo.body()?.vsize?.toFloat()?.div(1_000_000f) + android.util.Log.d(TAG, "Current mempool size: $currentSize vMB, Threshold: $threshold") + + if (currentSize != null) { + val shouldNotify = if (settings.mempoolSizeAboveThreshold) { + currentSize > threshold + } else { + currentSize < threshold + } - if (shouldNotify) { - val condition = if (settings.mempoolSizeAboveThreshold) "risen above" else "fallen below" - showNotification( - "Mempool Size Alert", - "Mempool size has $condition $threshold vMB and is now ${String.format("%.2f", currentSize)} vMB" - ) - settingsRepository.updateSettings( - settings.copy(hasNotifiedForMempoolSize = true) - ) + if (shouldNotify && !settings.hasNotifiedForMempoolSize) { + val condition = if (settings.mempoolSizeAboveThreshold) "risen above" else "fallen below" + showNotification( + "Mempool Size Alert", + "Mempool size has $condition $threshold vMB and is now ${String.format("%.2f", currentSize)} vMB" + ) + settingsRepository.updateSettings( + settings.copy(hasNotifiedForMempoolSize = true) + ) + } } + } else { + android.util.Log.e(TAG, "Mempool size API call failed: ${mempoolInfo.errorBody()?.string()}") } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e(TAG, "Error checking mempool size: ${e.message}", e) } } private suspend fun checkFeeRates(feeRateType: FeeRateType, threshold: Int) { try { + android.util.Log.d(TAG, "Making fee rates API call...") val settings = settingsRepository.settings.first() - if (settings.hasNotifiedForFeeRate) { - return - } + + // Remove this early return as it prevents checking new rates + // if (settings.hasNotifiedForFeeRate) { + // return + // } val feeRates = api.getFeeRates() if (feeRates.isSuccessful) { - val rates = feeRates.body() ?: return - val currentRate = when (feeRateType) { - FeeRateType.NEXT_BLOCK -> rates.fastestFee - FeeRateType.TWO_BLOCKS -> rates.halfHourFee - FeeRateType.FOUR_BLOCKS -> rates.hourFee - FeeRateType.DAY_BLOCKS -> rates.economyFee - } + val rates = feeRates.body() + android.util.Log.d(TAG, "Fee rates API response: ${rates?.toString()}") + + if (rates != null) { + val currentRate = when (feeRateType) { + FeeRateType.NEXT_BLOCK -> rates.fastestFee + FeeRateType.THREE_BLOCKS -> rates.halfHourFee + FeeRateType.SIX_BLOCKS -> rates.hourFee + FeeRateType.DAY_BLOCKS -> rates.economyFee + } + android.util.Log.d(TAG, "Current rate for $feeRateType: $currentRate, Threshold: $threshold") - val shouldNotify = if (settings.feeRateAboveThreshold) { - currentRate > threshold - } else { - currentRate < threshold - } + val shouldNotify = if (settings.feeRateAboveThreshold) { + currentRate > threshold + } else { + currentRate < threshold + } - if (shouldNotify) { - val feeRateTypeString = when (feeRateType) { - FeeRateType.NEXT_BLOCK -> "Next Block" - FeeRateType.TWO_BLOCKS -> "3 Block" - FeeRateType.FOUR_BLOCKS -> "6 Block" - FeeRateType.DAY_BLOCKS -> "1 Day" + if (shouldNotify && !settings.hasNotifiedForFeeRate) { + val feeRateTypeString = when (feeRateType) { + FeeRateType.NEXT_BLOCK -> "Next Block" + FeeRateType.THREE_BLOCKS -> "3 Block" + FeeRateType.SIX_BLOCKS -> "6 Block" + FeeRateType.DAY_BLOCKS -> "1 Day" + } + val condition = if (settings.feeRateAboveThreshold) "risen above" else "fallen below" + showNotification( + "Fee Rate Alert", + "$feeRateTypeString fee rate has $condition $threshold sat/vB and is currently at $currentRate sat/vB" + ) + settingsRepository.updateSettings( + settings.copy(hasNotifiedForFeeRate = true) + ) } - val condition = if (settings.feeRateAboveThreshold) "risen above" else "fallen below" - showNotification( - "Fee Rate Alert", - "$feeRateTypeString fee rate has $condition $threshold sat/vB and is currently at $currentRate sat/vB" - ) - settingsRepository.updateSettings( - settings.copy(hasNotifiedForFeeRate = true) - ) } + } else { + android.util.Log.e(TAG, "Fee rates API call failed: ${feeRates.errorBody()?.string()}") } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e(TAG, "Error checking fee rates: ${e.message}", e) } } private suspend fun checkNewBlocks() { try { + android.util.Log.d(TAG, "Making block height API call...") val settings = settingsRepository.settings.first() // Skip if neither notification is enabled @@ -193,6 +291,8 @@ class NotificationService : Service() { val blockHeight = api.getBlockHeight() if (blockHeight.isSuccessful) { val currentHeight = blockHeight.body() + android.util.Log.d(TAG, "Current block height: $currentHeight, Last height: $lastBlockHeight") + if (currentHeight != null) { // Check for new block notification if (settings.newBlockNotificationEnabled && @@ -222,9 +322,11 @@ class NotificationService : Service() { lastBlockHeight = currentHeight } + } else { + android.util.Log.e(TAG, "Block height API call failed: ${blockHeight.errorBody()?.string()}") } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e(TAG, "Error checking block height: ${e.message}", e) } } @@ -254,9 +356,9 @@ class NotificationService : Service() { } private fun createForegroundNotification(): Notification { - return NotificationCompat.Builder(this, channelId) + return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Mempal") - .setContentText("Monitoring Bitcoin network") + .setContentText("Monitoring Bitcoin Network") .setSmallIcon(R.drawable.ic_cube) .build() } @@ -266,7 +368,7 @@ class NotificationService : Service() { val name = "Mempal Notifications" val descriptionText = "Bitcoin network monitoring notifications" val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(channelId, name, importance).apply { + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { description = descriptionText } val notificationManager: NotificationManager = @@ -276,7 +378,7 @@ class NotificationService : Service() { } private fun showNotification(title: String, content: String) { - val notification = NotificationCompat.Builder(this, channelId) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) .setContentText(content) .setSmallIcon(R.drawable.ic_cube) @@ -296,6 +398,9 @@ class NotificationService : Service() { serviceScope.cancel() NetworkClient.cleanup() + // Stop Tor foreground service + TorManager.getInstance().stopForegroundService(applicationContext) + // Only update settings if service is actually being destroyed (not restarted) if (!isServiceRestarting()) { settingsRepository.settings.value.let { currentSettings -> diff --git a/app/src/main/java/com/example/mempal/tor/TorForegroundService.kt b/app/src/main/java/com/example/mempal/tor/TorForegroundService.kt new file mode 100644 index 0000000..ae217f5 --- /dev/null +++ b/app/src/main/java/com/example/mempal/tor/TorForegroundService.kt @@ -0,0 +1,65 @@ +package com.example.mempal.tor + +import android.app.* +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import com.example.mempal.R +import com.example.mempal.service.NotificationService + +class TorForegroundService : Service() { + private var wakeLock: PowerManager.WakeLock? = null + private val wakelockTag = "mempal:torServiceWakelock" + + override fun onCreate() { + super.onCreate() + acquireWakeLock() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NotificationService.NOTIFICATION_ID, createSilentNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NotificationService.NOTIFICATION_ID, createSilentNotification()) + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + releaseWakeLock() + super.onDestroy() + } + + private fun createSilentNotification(): Notification { + // Create a silent notification that won't be visible to the user + return NotificationCompat.Builder(this, NotificationService.CHANNEL_ID) + .setContentTitle("Mempal") + .setContentText("Monitoring Bitcoin Network") + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .build() + } + + private fun acquireWakeLock() { + wakeLock = (getSystemService(POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, wakelockTag).apply { + acquire() + } + } + } + + private fun releaseWakeLock() { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/tor/TorManager.kt b/app/src/main/java/com/example/mempal/tor/TorManager.kt index 4befa4f..456ac28 100644 --- a/app/src/main/java/com/example/mempal/tor/TorManager.kt +++ b/app/src/main/java/com/example/mempal/tor/TorManager.kt @@ -3,9 +3,12 @@ package com.example.mempal.tor import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.os.Build import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import org.torproject.jni.TorService import java.io.File import java.lang.ref.WeakReference @@ -25,6 +28,16 @@ class TorManager private constructor() { private var prefsRef: WeakReference? = null private val _proxyReady = MutableStateFlow(false) private var scope: CoroutineScope? = null + private var isForegroundServiceRunning = false + private var shouldBeTorEnabled = false + private var connectionJob: Job? = null + private val _torConnectionEvent = MutableSharedFlow() + val torConnectionEvent: SharedFlow = _torConnectionEvent + private var lastConnectionAttempt = 0L + private var connectionAttempts = 0 + private val maxConnectionAttempts = 5 + private val minRetryDelay = 2000L // 2 seconds + private val maxRetryDelay = 30000L // 30 seconds companion object { @Volatile @@ -39,7 +52,6 @@ class TorManager private constructor() { instance ?: TorManager().also { instance = it } } } - } fun initialize(context: Context) { @@ -57,8 +69,9 @@ class TorManager private constructor() { dataDir = dir torService = TorService() - // Restore previous state - if (prefs.getBoolean(KEY_TOR_ENABLED, false)) { + shouldBeTorEnabled = prefs.getBoolean(KEY_TOR_ENABLED, false) + + if (shouldBeTorEnabled) { startTor(context) } } catch (e: Exception) { @@ -67,34 +80,86 @@ class TorManager private constructor() { } } + private suspend fun emitConnectionEvent(connected: Boolean) { + _torConnectionEvent.emit(connected) + } + fun startTor(context: Context) { try { + shouldBeTorEnabled = true _torStatus.value = TorStatus.CONNECTING + val intent = Intent(context, TorService::class.java).apply { action = ACTION_START putExtra("directory", dataDir?.absolutePath) } context.startService(intent) - scope?.launch { - delay(5100) - _torStatus.value = TorStatus.CONNECTED - _proxyReady.value = true - prefsRef?.get()?.edit()?.putBoolean(KEY_TOR_ENABLED, true)?.apply() + connectionJob?.cancel() + connectionJob = scope?.launch { + var currentDelay = minRetryDelay + connectionAttempts = 0 + + while (isActive && connectionAttempts < maxConnectionAttempts) { + if (connectionAttempts > 0) { + delay(currentDelay) + // Exponential backoff with max delay cap + currentDelay = (currentDelay * 1.5).toLong().coerceAtMost(maxRetryDelay) + } else { + delay(5100) // Initial delay for first attempt + } + + try { + withContext(Dispatchers.IO) { + val socket = java.net.Socket() + try { + socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 2000) + socket.close() + _torStatus.value = TorStatus.CONNECTED + _proxyReady.value = true + prefsRef?.get()?.edit()?.putBoolean(KEY_TOR_ENABLED, true)?.apply() + emitConnectionEvent(true) + connectionAttempts = 0 // Reset attempts on success + lastConnectionAttempt = System.currentTimeMillis() + return@withContext + } catch (e: Exception) { + socket.close() + throw e + } + } + break // Connection successful + } catch (_: Exception) { + connectionAttempts++ + if (connectionAttempts >= maxConnectionAttempts) { + _torStatus.value = TorStatus.ERROR + _proxyReady.value = false + emitConnectionEvent(false) + } + } + } } } catch (e: Exception) { _torStatus.value = TorStatus.ERROR _proxyReady.value = false + scope?.launch { emitConnectionEvent(false) } e.printStackTrace() } } fun stopTor(context: Context) { try { + shouldBeTorEnabled = false + + connectionJob?.cancel() + connectionJob = null + val intent = Intent(context, TorService::class.java).apply { action = ACTION_STOP } context.stopService(intent) + + stopForegroundService(context) + _torStatus.value = TorStatus.DISCONNECTED _proxyReady.value = false prefsRef?.get()?.edit()?.putBoolean(KEY_TOR_ENABLED, false)?.apply() @@ -104,7 +169,74 @@ class TorManager private constructor() { } } + fun startForegroundService(context: Context) { + if (!isForegroundServiceRunning && torStatus.value == TorStatus.CONNECTED) { + val foregroundIntent = Intent(context, TorForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(foregroundIntent) + } else { + context.startService(foregroundIntent) + } + isForegroundServiceRunning = true + } + } + + fun stopForegroundService(context: Context) { + if (isForegroundServiceRunning) { + val foregroundIntent = Intent(context, TorForegroundService::class.java) + context.stopService(foregroundIntent) + isForegroundServiceRunning = false + } + } + fun isTorEnabled(): Boolean { return prefsRef?.get()?.getBoolean(KEY_TOR_ENABLED, false) == true } + + fun checkAndRestoreTorConnection(context: Context) { + if (!shouldBeTorEnabled) return + + // Prevent rapid reconnection attempts + val now = System.currentTimeMillis() + if (now - lastConnectionAttempt < minRetryDelay) return + + scope?.launch { + try { + // First check if Tor is actually running + val torRunning = withContext(Dispatchers.IO) { + try { + val socket = java.net.Socket() + socket.connect(java.net.InetSocketAddress("127.0.0.1", 9050), 1000) + socket.close() + true + } catch (_: Exception) { + false + } + } + + if (!torRunning) { + if (torStatus.value != TorStatus.CONNECTING) { + // Tor is not running, start it + startTor(context) + } + } else if (torStatus.value != TorStatus.CONNECTED) { + // Tor is running but our status doesn't reflect it + _torStatus.value = TorStatus.CONNECTED + _proxyReady.value = true + emitConnectionEvent(true) + } + } catch (_: Exception) { + if (torStatus.value != TorStatus.CONNECTING) { + startTor(context) + } + } + } + } + + fun cleanup() { + connectionJob?.cancel() + connectionJob = null + scope?.cancel() + scope = null + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt b/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt index d1b631a..31520f7 100644 --- a/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/example/mempal/viewmodel/MainViewModel.kt @@ -5,18 +5,36 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.mempal.api.FeeRates +import com.example.mempal.api.MempoolApi import com.example.mempal.api.MempoolInfo import com.example.mempal.api.NetworkClient -import com.example.mempal.api.Result +import com.example.mempal.cache.DashboardCache +import com.example.mempal.tor.TorManager +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import retrofit2.Response import java.io.IOException +sealed class DashboardUiState { + object Loading : DashboardUiState() + data class Success( + val isCache: Boolean = false + ) : DashboardUiState() + data class Error( + val message: String, + val isReconnecting: Boolean = false + ) : DashboardUiState() +} + class MainViewModel : ViewModel() { - private val _uiState = MutableStateFlow>(Result.Success(Unit)) - val uiState = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(DashboardUiState.Loading) + val uiState: StateFlow = _uiState + + private val _isMainRefreshing = MutableStateFlow(false) + val isMainRefreshing: StateFlow = _isMainRefreshing private val _blockHeight = MutableLiveData(null) val blockHeight: LiveData = _blockHeight @@ -33,59 +51,123 @@ class MainViewModel : ViewModel() { var hasInitialData = false private set + // Track which tabs have been loaded + private var dashboardLoaded = false + init { + // Check if we have cached data immediately + if (DashboardCache.hasCachedData()) { + val cachedState = DashboardCache.getCachedState() + _blockHeight.value = cachedState.blockHeight + _blockTimestamp.value = cachedState.blockTimestamp + _feeRates.value = cachedState.feeRates + _mempoolInfo.value = cachedState.mempoolInfo + // Don't set success state if we're not initialized, show reconnecting instead + if (!NetworkClient.isInitialized.value) { + _uiState.value = DashboardUiState.Error( + message = "Reconnecting to Tor network...", + isReconnecting = true + ) + } else { + _uiState.value = DashboardUiState.Success(isCache = true) + } + hasInitialData = true + } + + // Monitor network initialization state viewModelScope.launch { NetworkClient.isInitialized.collect { initialized -> if (initialized) { - refreshData() + // Clear any error state before refreshing + if (_uiState.value is DashboardUiState.Error) { + _uiState.value = DashboardUiState.Loading + } + refreshDashboardData() + dashboardLoaded = true + } else { + // Reset dashboard loaded state when network is not initialized + dashboardLoaded = false + } + } + } + + // Monitor Tor connection state + viewModelScope.launch { + TorManager.getInstance().torConnectionEvent.collect { connected -> + if (connected) { + // When Tor connects, show loading and refresh data + _uiState.value = DashboardUiState.Loading + refreshDashboardData() + } else { + // Check if we have cache to determine if we're reconnecting + val isReconnecting = DashboardCache.hasCachedData() + val message = if (isReconnecting) "Reconnecting to Tor network..." else "Connecting to Tor network..." + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = isReconnecting) } } } } - fun refreshData() { + // Function to handle tab changes + fun onTabSelected(tab: Int) { + when (tab) { + 0 -> if (!dashboardLoaded && NetworkClient.isInitialized.value) { + refreshDashboardData() + dashboardLoaded = true + } + } + } + + // Load dashboard data only + private fun refreshDashboardData() { if (!NetworkClient.isInitialized.value) { - println("NetworkClient not initialized, skipping refresh") + val isReconnecting = DashboardCache.hasCachedData() + val message = if (isReconnecting) "Reconnecting to Tor network..." else "Connecting to Tor network..." + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = isReconnecting) return } viewModelScope.launch { - _uiState.value = Result.Loading + // Show loading state if we're reconnecting or don't have initial data + if (!hasInitialData || _uiState.value is DashboardUiState.Error) { + _uiState.value = DashboardUiState.Loading + } + try { - println("Starting API calls...") - - val blockHeightResponse = NetworkClient.mempoolApi.getBlockHeight() - println("Block height response: ${blockHeightResponse.code()}") - - val feeRatesResponse = NetworkClient.mempoolApi.getFeeRates() - println("Fee rates response: ${feeRatesResponse.code()}") - - val mempoolInfoResponse = NetworkClient.mempoolApi.getMempoolInfo() - println("Mempool info response: ${mempoolInfoResponse.code()}") - - // Get block timestamp - val blockHashResponse = NetworkClient.mempoolApi.getLatestBlockHash() - println("Block hash response: ${blockHashResponse.code()}") - - var timestamp: Long? = null - if (blockHashResponse.isSuccessful) { - val hash = blockHashResponse.body() - if (hash != null) { - val blockInfoResponse = NetworkClient.mempoolApi.getBlockInfo(hash) - println("Block info response: ${blockInfoResponse.code()}") - if (blockInfoResponse.isSuccessful) { - timestamp = blockInfoResponse.body()?.timestamp - } else { - println("Failed to get block info: ${blockInfoResponse.errorBody()?.string()}") + println("Starting parallel API calls...") + + coroutineScope { + // Launch all API calls in parallel using async + val blockHeightDeferred = async { NetworkClient.mempoolApi.getBlockHeight() } + val feeRatesDeferred = async { NetworkClient.mempoolApi.getFeeRates() } + val mempoolInfoDeferred = async { NetworkClient.mempoolApi.getMempoolInfo() } + val blockHashDeferred = async { NetworkClient.mempoolApi.getLatestBlockHash() } + + // Wait for all responses + val blockHeightResponse = blockHeightDeferred.await() + val feeRatesResponse = feeRatesDeferred.await() + val mempoolInfoResponse = mempoolInfoDeferred.await() + val blockHashResponse = blockHashDeferred.await() + + var timestamp: Long? = null + if (blockHashResponse.isSuccessful) { + blockHashResponse.body()?.let { hash -> + val blockInfoResponse = NetworkClient.mempoolApi.getBlockInfo(hash) + if (blockInfoResponse.isSuccessful) { + timestamp = blockInfoResponse.body()?.timestamp + } } - } else { - println("Block hash response body was null") } - } else { - println("Failed to get block hash: ${blockHashResponse.errorBody()?.string()}") + + processResponses(blockHeightResponse, feeRatesResponse, mempoolInfoResponse, timestamp) } - processResponses(blockHeightResponse, feeRatesResponse, mempoolInfoResponse, timestamp) + // Ensure we have mempool info even if the parallel call failed + if (_mempoolInfo.value == null) { + refreshMempoolInfo() + } + + hasInitialData = true } catch (e: Exception) { println("Error refreshing data: ${e.message}") e.printStackTrace() @@ -94,50 +176,236 @@ class MainViewModel : ViewModel() { } } + // Manual refresh for dashboard + fun refreshData() { + _isMainRefreshing.value = true + refreshDashboardData() + } + private fun processResponses( - blockHeight: Response, - feeRates: Response, - mempoolInfo: Response, + blockHeightResponse: Response, + feeRatesResponse: Response, + mempoolInfoResponse: Response, timestamp: Long? ) { - // Update each value independently - if (blockHeight.isSuccessful) { - _blockHeight.value = blockHeight.body() + var hasAnySuccessfulResponse = false + + // Process block height + if (blockHeightResponse.isSuccessful) { + val blockHeight = blockHeightResponse.body() + _blockHeight.value = blockHeight + _blockTimestamp.value = timestamp + hasAnySuccessfulResponse = true + DashboardCache.saveState( + blockHeight = blockHeight, + blockTimestamp = timestamp, + mempoolInfo = _mempoolInfo.value, + feeRates = _feeRates.value + ) } - if (feeRates.isSuccessful) { - _feeRates.value = feeRates.body() + + // Process fee rates + if (feeRatesResponse.isSuccessful) { + val feeRates = feeRatesResponse.body() + _feeRates.value = feeRates + hasAnySuccessfulResponse = true + DashboardCache.saveState( + blockHeight = _blockHeight.value, + blockTimestamp = _blockTimestamp.value, + mempoolInfo = _mempoolInfo.value, + feeRates = feeRates + ) } - if (mempoolInfo.isSuccessful) { - _mempoolInfo.value = mempoolInfo.body() + + // Process mempool info + if (mempoolInfoResponse.isSuccessful) { + val mempoolInfo = mempoolInfoResponse.body() + hasAnySuccessfulResponse = true + + // Update mempool info immediately for size display + _mempoolInfo.value = mempoolInfo + + // Save state immediately with current mempool info + DashboardCache.saveState( + blockHeight = _blockHeight.value, + blockTimestamp = _blockTimestamp.value, + mempoolInfo = mempoolInfo, + feeRates = _feeRates.value + ) + + // Check if mempool info needs fallback for fee distribution + if (mempoolInfo != null && mempoolInfo.needsHistogramFallback()) { + viewModelScope.launch { + try { + val fallbackClient = NetworkClient.createTestClient(MempoolApi.BASE_URL) + val fallbackResponse = fallbackClient.getMempoolInfo() + if (fallbackResponse.isSuccessful && fallbackResponse.body() != null) { + val fallbackInfo = fallbackResponse.body()!! + if (!fallbackInfo.needsHistogramFallback()) { + _mempoolInfo.value = mempoolInfo.withFallbackHistogram(fallbackInfo.feeHistogram) + // Update cache with fallback data + DashboardCache.saveState( + blockHeight = _blockHeight.value, + blockTimestamp = _blockTimestamp.value, + mempoolInfo = _mempoolInfo.value, + feeRates = _feeRates.value + ) + } + } + } catch (_: Exception) { + // Fallback failed, but we already have the mempool size displayed + } + } + } } - _blockTimestamp.value = timestamp - // Set hasInitialData if we have at least one successful response - if (blockHeight.isSuccessful || feeRates.isSuccessful || mempoolInfo.isSuccessful) { + if (hasAnySuccessfulResponse) { + // Always set success state with fresh data if we have any successful response + _uiState.value = DashboardUiState.Success(isCache = false) hasInitialData = true - _uiState.value = Result.Success(Unit) + _isMainRefreshing.value = false + } else { + // If we have cached data, use it instead of showing an error + if (DashboardCache.hasCachedData()) { + val cachedState = DashboardCache.getCachedState() + _blockHeight.value = cachedState.blockHeight + _blockTimestamp.value = cachedState.blockTimestamp + _feeRates.value = cachedState.feeRates + _mempoolInfo.value = cachedState.mempoolInfo + + // Only show cached state if we're not in an error state + if (_uiState.value !is DashboardUiState.Error) { + _uiState.value = DashboardUiState.Success(isCache = true) + } + } else { + handleError(Exception("No API calls were successful")) + } + _isMainRefreshing.value = false + } + } + + private fun handleError(e: Exception) { + // If we have cached data, use it instead of showing an error + if (DashboardCache.hasCachedData()) { + val cachedState = DashboardCache.getCachedState() + _blockHeight.value = cachedState.blockHeight + _blockTimestamp.value = cachedState.blockTimestamp + _feeRates.value = cachedState.feeRates + _mempoolInfo.value = cachedState.mempoolInfo + + _uiState.value = DashboardUiState.Success(isCache = true) } else { - _uiState.value = Result.Error("Server error. Please try again.") + val message = when (e) { + is IOException -> "Connecting to Tor network..." + else -> e.message ?: "Unknown error occurred" + } + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = false) } + _isMainRefreshing.value = false + } - // Log any failures for debugging - if (!blockHeight.isSuccessful) { - println("Block height request failed: ${blockHeight.code()} - ${blockHeight.errorBody()?.string()}") + override fun onCleared() { + super.onCleared() + // Clear cache when ViewModel is destroyed + DashboardCache.clearCache() + } + + // Individual refresh functions for each card + fun refreshBlockData() { + if (!NetworkClient.isInitialized.value) { + val isReconnecting = DashboardCache.hasCachedData() + val message = if (isReconnecting) "Reconnecting to Tor network..." else "Connecting to Tor network..." + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = isReconnecting) + return } - if (!feeRates.isSuccessful) { - println("Fee rates request failed: ${feeRates.code()} - ${feeRates.errorBody()?.string()}") + viewModelScope.launch { + try { + val blockHeightResponse = NetworkClient.mempoolApi.getBlockHeight() + val blockHashResponse = NetworkClient.mempoolApi.getLatestBlockHash() + + if (blockHeightResponse.isSuccessful) { + _blockHeight.value = blockHeightResponse.body() + } + + if (blockHashResponse.isSuccessful) { + blockHashResponse.body()?.let { hash -> + val blockInfoResponse = NetworkClient.mempoolApi.getBlockInfo(hash) + if (blockInfoResponse.isSuccessful) { + _blockTimestamp.value = blockInfoResponse.body()?.timestamp + } + } + } + } catch (e: Exception) { + handleError(e) + } } - if (!mempoolInfo.isSuccessful) { - println("Mempool info request failed: ${mempoolInfo.code()} - ${mempoolInfo.errorBody()?.string()}") + } + + fun refreshFeeRates() { + if (!NetworkClient.isInitialized.value) { + val isReconnecting = DashboardCache.hasCachedData() + val message = if (isReconnecting) "Reconnecting to Tor network..." else "Connecting to Tor network..." + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = isReconnecting) + return + } + viewModelScope.launch { + try { + val response = NetworkClient.mempoolApi.getFeeRates() + if (response.isSuccessful) { + _feeRates.value = response.body() + } + } catch (e: Exception) { + handleError(e) + } } } - private fun handleError(e: Throwable) { - _uiState.value = Result.Error( - when (e) { - is IOException -> "Network error. Please check your connection." - else -> "An unexpected error occurred: ${e.message}" + fun refreshMempoolInfo() { + if (!NetworkClient.isInitialized.value) { + val isReconnecting = DashboardCache.hasCachedData() + val message = if (isReconnecting) "Reconnecting to Tor network..." else "Connecting to Tor network..." + _uiState.value = DashboardUiState.Error(message = message, isReconnecting = isReconnecting) + return + } + viewModelScope.launch { + try { + // Try primary server first + val response = NetworkClient.mempoolApi.getMempoolInfo() + var mempoolInfo = response.body() + + // Check if we need histogram fallback + if (response.isSuccessful && mempoolInfo != null && mempoolInfo.needsHistogramFallback()) { + try { + println("Primary server missing histogram data, trying fallback...") + val fallbackClient = NetworkClient.createTestClient(MempoolApi.BASE_URL) + val fallbackResponse = fallbackClient.getMempoolInfo() + + if (fallbackResponse.isSuccessful && fallbackResponse.body() != null) { + val fallbackInfo = fallbackResponse.body()!! + if (!fallbackInfo.needsHistogramFallback()) { + println("Got histogram data from fallback server") + // Keep original data but use fallback histogram + _mempoolInfo.value = mempoolInfo.withFallbackHistogram(fallbackInfo.feeHistogram) + return@launch + } + } + } catch (e: Exception) { + println("Fallback server failed: ${e.message}") + } + } + + // If we get here, either: + // 1. Primary server succeeded with histogram data + // 2. Primary server succeeded without histogram but fallback failed + // 3. Primary server failed + if (response.isSuccessful && mempoolInfo != null) { + _mempoolInfo.value = mempoolInfo + } else { + handleError(IOException("Failed to fetch mempool info")) + } + } catch (e: Exception) { + handleError(e) } - ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt b/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt index c5327df..66eee53 100644 --- a/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/BlockHeightWidget.kt @@ -8,9 +8,9 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.example.mempal.R -import com.example.mempal.api.NetworkClient +import com.example.mempal.api.WidgetNetworkClient import kotlinx.coroutines.* -import java.util.Locale +import java.util.* class BlockHeightWidget : AppWidgetProvider() { companion object { @@ -48,61 +48,66 @@ class BlockHeightWidget : AppWidgetProvider() { super.onReceive(context, intent) if (intent.action == REFRESH_ACTION) { if (WidgetUtils.isDoubleTap()) { + // Launch app on double tap val launchIntent = WidgetUtils.getLaunchAppIntent(context) - try { - launchIntent.send() - return - } catch (e: Exception) { - e.printStackTrace() - } + launchIntent.send() + } else { + // Single tap - refresh widget + val appWidgetManager = AppWidgetManager.getInstance(context) + val thisWidget = ComponentName(context, BlockHeightWidget::class.java) + onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) } - - val appWidgetManager = AppWidgetManager.getInstance(context) - val componentName = ComponentName(context, BlockHeightWidget::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - onUpdate(context, appWidgetManager, appWidgetIds) } } - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - for (appWidgetId in appWidgetIds) { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + // Update each widget + appWidgetIds.forEach { appWidgetId -> updateAppWidget(context, appWidgetManager, appWidgetId) } } - private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { val views = RemoteViews(context.packageName, R.layout.block_height_widget) - + + // Create refresh intent val refreshIntent = Intent(context, BlockHeightWidget::class.java).apply { action = REFRESH_ACTION - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } val refreshPendingIntent = PendingIntent.getBroadcast( - context, - appWidgetId, - refreshIntent, + context, 0, refreshIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) - - views.setTextViewText(R.id.block_height, "...") - views.setTextViewText(R.id.elapsed_time, "") + + // Set loading state first + setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) + // Fetch latest data getOrCreateScope().launch { try { - val blockHeightResponse = NetworkClient.mempoolApi.getBlockHeight() + val mempoolApi = WidgetNetworkClient.getMempoolApi(context) + val blockHeightResponse = mempoolApi.getBlockHeight() if (blockHeightResponse.isSuccessful) { blockHeightResponse.body()?.let { blockHeight -> views.setTextViewText(R.id.block_height, String.format(Locale.US, "%,d", blockHeight)) // Get block timestamp - val blockHashResponse = NetworkClient.mempoolApi.getLatestBlockHash() + val blockHashResponse = mempoolApi.getLatestBlockHash() if (blockHashResponse.isSuccessful) { val hash = blockHashResponse.body() if (hash != null) { - val blockInfoResponse = NetworkClient.mempoolApi.getBlockInfo(hash) + val blockInfoResponse = mempoolApi.getBlockInfo(hash) if (blockInfoResponse.isSuccessful) { blockInfoResponse.body()?.timestamp?.let { timestamp -> val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 @@ -121,4 +126,9 @@ class BlockHeightWidget : AppWidgetProvider() { } } } + + private fun setLoadingState(views: RemoteViews) { + views.setTextViewText(R.id.block_height, "...") + views.setTextViewText(R.id.elapsed_time, "") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt b/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt index 7d6e97e..fed878e 100644 --- a/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/CombinedStatsWidget.kt @@ -8,7 +8,7 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.example.mempal.R -import com.example.mempal.api.NetworkClient +import com.example.mempal.api.WidgetNetworkClient import kotlinx.coroutines.* import java.util.Locale import kotlin.math.ceil @@ -35,9 +35,11 @@ class CombinedStatsWidget : AppWidgetProvider() { val appWidgetManager = AppWidgetManager.getInstance(context) val blockHeightWidget = ComponentName(context, BlockHeightWidget::class.java) val mempoolSizeWidget = ComponentName(context, MempoolSizeWidget::class.java) + val feeRatesWidget = ComponentName(context, FeeRatesWidget::class.java) if (appWidgetManager.getAppWidgetIds(blockHeightWidget).isEmpty() && - appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty()) { + appWidgetManager.getAppWidgetIds(mempoolSizeWidget).isEmpty() && + appWidgetManager.getAppWidgetIds(feeRatesWidget).isEmpty()) { WidgetUpdater.cancelUpdates(context) // Cancel any ongoing coroutines widgetScope?.cancel() @@ -49,39 +51,42 @@ class CombinedStatsWidget : AppWidgetProvider() { super.onReceive(context, intent) if (intent.action == REFRESH_ACTION) { if (WidgetUtils.isDoubleTap()) { + // Launch app on double tap val launchIntent = WidgetUtils.getLaunchAppIntent(context) - try { - launchIntent.send() - return - } catch (e: Exception) { - e.printStackTrace() - } + launchIntent.send() + } else { + // Single tap - refresh widget + val appWidgetManager = AppWidgetManager.getInstance(context) + val thisWidget = ComponentName(context, CombinedStatsWidget::class.java) + onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) } - - val appWidgetManager = AppWidgetManager.getInstance(context) - val componentName = ComponentName(context, CombinedStatsWidget::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - onUpdate(context, appWidgetManager, appWidgetIds) } } - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - for (appWidgetId in appWidgetIds) { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + // Update each widget + appWidgetIds.forEach { appWidgetId -> updateAppWidget(context, appWidgetManager, appWidgetId) } } - private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { val views = RemoteViews(context.packageName, R.layout.combined_stats_widget) - + + // Create refresh intent val refreshIntent = Intent(context, CombinedStatsWidget::class.java).apply { action = REFRESH_ACTION - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } val refreshPendingIntent = PendingIntent.getBroadcast( - context, - appWidgetId, - refreshIntent, + context, 0, refreshIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) @@ -91,55 +96,58 @@ class CombinedStatsWidget : AppWidgetProvider() { getOrCreateScope().launch { try { - val blockHeightResponse = NetworkClient.mempoolApi.getBlockHeight() - val mempoolInfoResponse = NetworkClient.mempoolApi.getMempoolInfo() - val feeRatesResponse = NetworkClient.mempoolApi.getFeeRates() + val mempoolApi = WidgetNetworkClient.getMempoolApi(context) - // Get block timestamp - var timestamp: Long? = null - val blockHashResponse = NetworkClient.mempoolApi.getLatestBlockHash() - if (blockHashResponse.isSuccessful) { - val hash = blockHashResponse.body() - if (hash != null) { - val blockInfoResponse = NetworkClient.mempoolApi.getBlockInfo(hash) - if (blockInfoResponse.isSuccessful) { - timestamp = blockInfoResponse.body()?.timestamp + // Get block height and timestamp + val blockHeightResponse = mempoolApi.getBlockHeight() + if (blockHeightResponse.isSuccessful) { + blockHeightResponse.body()?.let { blockHeight -> + views.setTextViewText(R.id.block_height, + String.format(Locale.US, "%,d", blockHeight)) + + // Get block timestamp + val blockHashResponse = mempoolApi.getLatestBlockHash() + if (blockHashResponse.isSuccessful) { + val hash = blockHashResponse.body() + if (hash != null) { + val blockInfoResponse = mempoolApi.getBlockInfo(hash) + if (blockInfoResponse.isSuccessful) { + blockInfoResponse.body()?.timestamp?.let { timestamp -> + val elapsedMinutes = (System.currentTimeMillis() / 1000 - timestamp) / 60 + views.setTextViewText(R.id.elapsed_time, + "(${elapsedMinutes} minutes ago)") + } + } + } } } } - if (blockHeightResponse.isSuccessful && - mempoolInfoResponse.isSuccessful && - feeRatesResponse.isSuccessful) { - - val blockHeight = blockHeightResponse.body() - val mempoolInfo = mempoolInfoResponse.body() - val feeRates = feeRatesResponse.body() - - if (blockHeight != null && mempoolInfo != null && feeRates != null) { - views.setTextViewText(R.id.block_height, - String.format(Locale.US, "%,d", blockHeight)) - - timestamp?.let { - val elapsedMinutes = (System.currentTimeMillis() / 1000 - it) / 60 - views.setTextViewText(R.id.elapsed_time, - "(${elapsedMinutes} minutes ago)") - } - + // Get mempool size + val mempoolResponse = mempoolApi.getMempoolInfo() + if (mempoolResponse.isSuccessful) { + mempoolResponse.body()?.let { mempoolInfo -> + val sizeInMB = mempoolInfo.vsize / 1_000_000.0 views.setTextViewText(R.id.mempool_size, - String.format(Locale.US, "%.2f vMB", mempoolInfo.vsize / 1_000_000.0)) + String.format(Locale.US, "%.2f vMB", sizeInMB)) - val blocksToClean = ceil(mempoolInfo.vsize / 1_000_000.0 / 1.5).toInt() + val blocksToClean = ceil(sizeInMB / 1.5).toInt() views.setTextViewText(R.id.mempool_blocks_to_clear, "(${blocksToClean} blocks to clear)") - + } + } + + // Get fee rates + val feeResponse = mempoolApi.getFeeRates() + if (feeResponse.isSuccessful) { + feeResponse.body()?.let { feeRates -> views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") views.setTextViewText(R.id.standard_fee, "${feeRates.halfHourFee}") views.setTextViewText(R.id.economy_fee, "${feeRates.hourFee}") - - appWidgetManager.updateAppWidget(appWidgetId, views) } } + + appWidgetManager.updateAppWidget(appWidgetId, views) } catch (e: Exception) { e.printStackTrace() } diff --git a/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt b/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt index 890d726..2e5f4a8 100644 --- a/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/FeeRatesWidget.kt @@ -8,7 +8,7 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.example.mempal.R -import com.example.mempal.api.NetworkClient +import com.example.mempal.api.WidgetNetworkClient import kotlinx.coroutines.* class FeeRatesWidget : AppWidgetProvider() { @@ -49,19 +49,15 @@ class FeeRatesWidget : AppWidgetProvider() { super.onReceive(context, intent) if (intent.action == REFRESH_ACTION) { if (WidgetUtils.isDoubleTap()) { + // Launch app on double tap val launchIntent = WidgetUtils.getLaunchAppIntent(context) - try { - launchIntent.send() - return - } catch (e: Exception) { - e.printStackTrace() - } + launchIntent.send() + } else { + // Single tap - refresh widget + val appWidgetManager = AppWidgetManager.getInstance(context) + val thisWidget = ComponentName(context, FeeRatesWidget::class.java) + onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) } - - val appWidgetManager = AppWidgetManager.getInstance(context) - val componentName = ComponentName(context, FeeRatesWidget::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - onUpdate(context, appWidgetManager, appWidgetIds) } } @@ -70,7 +66,8 @@ class FeeRatesWidget : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - for (appWidgetId in appWidgetIds) { + // Update each widget + appWidgetIds.forEach { appWidgetId -> updateAppWidget(context, appWidgetManager, appWidgetId) } } @@ -81,31 +78,26 @@ class FeeRatesWidget : AppWidgetProvider() { appWidgetId: Int ) { val views = RemoteViews(context.packageName, R.layout.fee_rates_widget) - - // Set up refresh click action + + // Create refresh intent val refreshIntent = Intent(context, FeeRatesWidget::class.java).apply { action = REFRESH_ACTION - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } val refreshPendingIntent = PendingIntent.getBroadcast( - context, - appWidgetId, - refreshIntent, + context, 0, refreshIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) - - // Initial loading state - views.setTextViewText(R.id.priority_fee, "...") - views.setTextViewText(R.id.standard_fee, "...") - views.setTextViewText(R.id.economy_fee, "...") - + + // Set loading state first + setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) // Fetch latest data getOrCreateScope().launch { try { - val response = NetworkClient.mempoolApi.getFeeRates() + val mempoolApi = WidgetNetworkClient.getMempoolApi(context) + val response = mempoolApi.getFeeRates() if (response.isSuccessful) { response.body()?.let { feeRates -> views.setTextViewText(R.id.priority_fee, "${feeRates.fastestFee}") @@ -119,4 +111,10 @@ class FeeRatesWidget : AppWidgetProvider() { } } } + + private fun setLoadingState(views: RemoteViews) { + views.setTextViewText(R.id.priority_fee, "...") + views.setTextViewText(R.id.standard_fee, "...") + views.setTextViewText(R.id.economy_fee, "...") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt b/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt index afcc85c..ff9d86f 100644 --- a/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt +++ b/app/src/main/java/com/example/mempal/widget/MempoolSizeWidget.kt @@ -8,7 +8,7 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.example.mempal.R -import com.example.mempal.api.NetworkClient +import com.example.mempal.api.WidgetNetworkClient import kotlinx.coroutines.* import java.util.Locale import kotlin.math.ceil @@ -49,50 +49,55 @@ class MempoolSizeWidget : AppWidgetProvider() { super.onReceive(context, intent) if (intent.action == REFRESH_ACTION) { if (WidgetUtils.isDoubleTap()) { + // Launch app on double tap val launchIntent = WidgetUtils.getLaunchAppIntent(context) - try { - launchIntent.send() - return - } catch (e: Exception) { - e.printStackTrace() - } + launchIntent.send() + } else { + // Single tap - refresh widget + val appWidgetManager = AppWidgetManager.getInstance(context) + val thisWidget = ComponentName(context, MempoolSizeWidget::class.java) + onUpdate(context, appWidgetManager, appWidgetManager.getAppWidgetIds(thisWidget)) } - - val appWidgetManager = AppWidgetManager.getInstance(context) - val componentName = ComponentName(context, MempoolSizeWidget::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - onUpdate(context, appWidgetManager, appWidgetIds) } } - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - for (appWidgetId in appWidgetIds) { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + // Update each widget + appWidgetIds.forEach { appWidgetId -> updateAppWidget(context, appWidgetManager, appWidgetId) } } - private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { val views = RemoteViews(context.packageName, R.layout.mempool_size_widget) - + + // Create refresh intent val refreshIntent = Intent(context, MempoolSizeWidget::class.java).apply { action = REFRESH_ACTION - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } val refreshPendingIntent = PendingIntent.getBroadcast( - context, - appWidgetId, - refreshIntent, + context, 0, refreshIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_layout, refreshPendingIntent) - - views.setTextViewText(R.id.mempool_size, "...") - views.setTextViewText(R.id.mempool_blocks_to_clear, "") + + // Set loading state first + setLoadingState(views) appWidgetManager.updateAppWidget(appWidgetId, views) + // Fetch latest data getOrCreateScope().launch { try { - val response = NetworkClient.mempoolApi.getMempoolInfo() + val mempoolApi = WidgetNetworkClient.getMempoolApi(context) + val response = mempoolApi.getMempoolInfo() if (response.isSuccessful) { response.body()?.let { mempoolInfo -> val sizeInMB = mempoolInfo.vsize / 1_000_000.0 @@ -111,4 +116,9 @@ class MempoolSizeWidget : AppWidgetProvider() { } } } + + private fun setLoadingState(views: RemoteViews) { + views.setTextViewText(R.id.mempool_size, "...") + views.setTextViewText(R.id.mempool_blocks_to_clear, "") + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/mempal/widget/WidgetUtils.kt b/app/src/main/java/com/example/mempal/widget/WidgetUtils.kt index 5d22dac..ed5c188 100644 --- a/app/src/main/java/com/example/mempal/widget/WidgetUtils.kt +++ b/app/src/main/java/com/example/mempal/widget/WidgetUtils.kt @@ -19,8 +19,8 @@ object WidgetUtils { fun getLaunchAppIntent(context: Context): PendingIntent { val launchIntent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP action = Intent.ACTION_MAIN addCategory(Intent.CATEGORY_LAUNCHER) diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 5aa57c3..a4cb1da 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,41 +1,185 @@ - + android:viewportWidth="512" + android:viewportHeight="512"> + + android:pathData="M256,256m-240,0a240,240 0,1 1,480 0a240,240 0,1 1,-480 0"> + + + + + + + + + android:pathData="M256,66L426,156L426,356L256,446L86,356L86,156Z" + android:strokeAlpha="0.2" + android:strokeWidth="14" + android:fillColor="#00000000" + android:strokeColor="#EC910C" + android:fillAlpha="0.2"/> + android:pathData="M256,66L426,156L426,356L256,446L86,356L86,156Z" + android:strokeWidth="10" + android:fillColor="#00000000" + android:strokeColor="#EC910C"/> + android:pathData="M256,156L346,196L346,316L256,356L166,316L166,196Z" + android:strokeWidth="10" + android:fillColor="#00000000"> + + + + + + + + android:pathData="M166,196L166,316" + android:strokeWidth="10" + android:fillColor="#00000000" + android:strokeColor="#F4F4F4"/> + android:pathData="M346,196L346,316" + android:strokeWidth="10" + android:fillColor="#00000000" + android:strokeColor="#F4F4F4"/> + android:pathData="M166,196L256,236L346,196" + android:strokeWidth="10" + android:fillColor="#00000000" + android:strokeColor="#F4F4F4"/> + android:pathData="M256,236L256,356" + android:strokeWidth="10" + android:fillColor="#00000000" + android:strokeColor="#F4F4F4"/> + android:pathData="M256,156L256,66" + android:strokeAlpha="0.2" + android:strokeWidth="14" + android:fillColor="#00000000" + android:strokeColor="#EC910C" + android:fillAlpha="0.2"/> + android:pathData="M346,196L426,156" + android:strokeAlpha="0.2" + android:strokeWidth="14" + android:fillColor="#00000000" + android:strokeColor="#EC910C" + android:fillAlpha="0.2"/> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/combined_stats_widget.xml b/app/src/main/res/layout/combined_stats_widget.xml index 0d953c2..f7b2158 100644 --- a/app/src/main/res/layout/combined_stats_widget.xml +++ b/app/src/main/res/layout/combined_stats_widget.xml @@ -6,7 +6,7 @@ android:layout_height="wrap_content" android:background="@drawable/widget_background" android:orientation="vertical" - android:padding="16dp" + android:padding="12dp" android:clickable="true" android:focusable="true"> @@ -15,7 +15,7 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false" - android:layout_marginBottom="16dp"> + android:layout_marginBottom="12dp"> + android:layout_marginEnd="6dp"> @@ -54,10 +54,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFFFF" - android:textSize="12sp" + android:textSize="11sp" android:alpha="0.7" android:gravity="center" - android:layout_marginTop="2dp"/> + android:layout_marginTop="1dp"/> @@ -96,10 +96,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFFFF" - android:textSize="12sp" + android:textSize="11sp" android:alpha="0.7" android:gravity="center" - android:layout_marginTop="2dp"/> + android:layout_marginTop="1dp"/> @@ -109,7 +109,7 @@ android:orientation="horizontal" android:baselineAligned="false" android:background="@drawable/stats_card_background" - android:padding="12dp"> + android:padding="8dp"> + android:textSize="12sp"/> @@ -162,7 +162,7 @@ android:layout_height="wrap_content" android:text="@string/three_blocks_label" android:textColor="#FF9800" - android:textSize="14sp"/> + android:textSize="12sp"/> @@ -201,7 +201,7 @@ android:layout_height="wrap_content" android:text="@string/six_blocks_label" android:textColor="#FF9800" - android:textSize="14sp"/> + android:textSize="12sp"/> diff --git a/app/src/main/res/layout/fee_rates_widget.xml b/app/src/main/res/layout/fee_rates_widget.xml index 3c1d4dd..91a3f22 100644 --- a/app/src/main/res/layout/fee_rates_widget.xml +++ b/app/src/main/res/layout/fee_rates_widget.xml @@ -4,6 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/widget_background" + android:baselineAligned="false" android:orientation="horizontal" android:padding="12dp" android:clickable="true" diff --git a/app/src/main/res/mempal-icon.svg b/app/src/main/res/mempal-icon.svg new file mode 100644 index 0000000..03bf89b --- /dev/null +++ b/app/src/main/res/mempal-icon.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 98e8066..cea74d0 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 22c7614..a61f08b 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index b9896ad..711bcee 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 6795c66..a9ed9e2 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 5ed5c56..6fffb97 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index f41d33d..32c41e8 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 2f259ea..90a8679 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index ab4e4a6..07c2f0c 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 02e2e3c..74bf64b 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index a1c42d1..6b289d3 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-v33/themes.xml b/app/src/main/res/values-v33/themes.xml new file mode 100644 index 0000000..c4f6b89 --- /dev/null +++ b/app/src/main/res/values-v33/themes.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 11faef2..5b24746 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,6 +8,7 @@ \ No newline at end of file diff --git a/app/src/main/res/xml-v31/block_height_widget_info.xml b/app/src/main/res/xml-v31/block_height_widget_info.xml new file mode 100644 index 0000000..83f3fe8 --- /dev/null +++ b/app/src/main/res/xml-v31/block_height_widget_info.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml-v31/combined_stats_widget_info.xml b/app/src/main/res/xml-v31/combined_stats_widget_info.xml new file mode 100644 index 0000000..28012d1 --- /dev/null +++ b/app/src/main/res/xml-v31/combined_stats_widget_info.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml-v31/fee_rates_widget_info.xml b/app/src/main/res/xml-v31/fee_rates_widget_info.xml new file mode 100644 index 0000000..3ad8ebe --- /dev/null +++ b/app/src/main/res/xml-v31/fee_rates_widget_info.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml-v31/mempool_size_widget_info.xml b/app/src/main/res/xml-v31/mempool_size_widget_info.xml new file mode 100644 index 0000000..7161900 --- /dev/null +++ b/app/src/main/res/xml-v31/mempool_size_widget_info.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml/block_height_widget_info.xml b/app/src/main/res/xml/block_height_widget_info.xml index 4119002..a098b99 100644 --- a/app/src/main/res/xml/block_height_widget_info.xml +++ b/app/src/main/res/xml/block_height_widget_info.xml @@ -5,5 +5,4 @@ android:updatePeriodMillis="1800000" android:initialLayout="@layout/block_height_widget" android:resizeMode="horizontal" - android:widgetCategory="home_screen" - android:previewLayout="@layout/block_height_widget"/> \ No newline at end of file + android:widgetCategory="home_screen"/> \ No newline at end of file diff --git a/app/src/main/res/xml/combined_stats_widget_info.xml b/app/src/main/res/xml/combined_stats_widget_info.xml index 98a7897..26c7d59 100644 --- a/app/src/main/res/xml/combined_stats_widget_info.xml +++ b/app/src/main/res/xml/combined_stats_widget_info.xml @@ -1,11 +1,10 @@ \ No newline at end of file + android:widgetCategory="home_screen"/> \ No newline at end of file diff --git a/app/src/main/res/xml/fee_rates_widget_info.xml b/app/src/main/res/xml/fee_rates_widget_info.xml index 4f81c84..ab3c64c 100644 --- a/app/src/main/res/xml/fee_rates_widget_info.xml +++ b/app/src/main/res/xml/fee_rates_widget_info.xml @@ -5,5 +5,4 @@ android:updatePeriodMillis="1800000" android:initialLayout="@layout/fee_rates_widget" android:resizeMode="horizontal" - android:widgetCategory="home_screen" - android:previewLayout="@layout/fee_rates_widget"/> \ No newline at end of file + android:widgetCategory="home_screen"/> \ No newline at end of file diff --git a/app/src/main/res/xml/mempool_size_widget_info.xml b/app/src/main/res/xml/mempool_size_widget_info.xml index 5210b23..032a927 100644 --- a/app/src/main/res/xml/mempool_size_widget_info.xml +++ b/app/src/main/res/xml/mempool_size_widget_info.xml @@ -5,5 +5,4 @@ android:updatePeriodMillis="1800000" android:initialLayout="@layout/mempool_size_widget" android:resizeMode="horizontal" - android:widgetCategory="home_screen" - android:previewLayout="@layout/mempool_size_widget"/> \ No newline at end of file + android:widgetCategory="home_screen"/> \ No newline at end of file