From 28680fe853ffa6fe96fedb5646d1a731007c86f1 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:59:53 -0700 Subject: [PATCH] Add Failure Notifications --- README.md | 12 +- .../cssnr/parking/data/AutomaticRepository.kt | 12 ++ .../data/BluetoothDisconnectReceiver.kt | 1 + .../org/cssnr/parking/data/ParkingNotifier.kt | 92 +++++++++ .../cssnr/parking/data/ParkingWatchService.kt | 3 + .../parking/ui/screens/AutomaticScreen.kt | 178 ++++++++++++++---- .../ui/viewmodel/AutomaticViewModel.kt | 14 ++ app/src/main/res/values/strings.xml | 51 ++++- 8 files changed, 320 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 041a87e..1fefa33 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,15 @@ Parking Android application, built with Kotlin, Jetpack Compose and Material 3. Automatically detect **where** you parked your car... +_So far this app has worked perfectly for me in real-world testing..._ + +> [!NOTE] +> This app is in early release for testing. Expect frequent and possibly breaking changes. +> Please report any [Issues](https://github.com/cssnr/todolist-android/issues) you find. + > [!WARNING] > Upgrading from <`0.0.7` requires re-installing the application. -> A new certificated was generated due to an incorrect CN. -> Please report any [Issues](https://github.com/cssnr/parking-android/issues) you find. +> A new certificated was generated due to an incorrect CN. ## Install @@ -104,10 +109,11 @@ _The GitHub APK has been registered with Google's [Android developer verificatio - Automatic Parking Detection - Supports Multiple Bluetooth Devices - Full History with Location Maps +- Alerts on Location Failure ### Planned -- Start the Application... +- Ability to drop pin on map for manual parking (currently uses GPS) [![Request Feature](https://img.shields.io/badge/request_feature-brightgreen?style=for-the-badge&logo=rocket&logoColor=white)](https://github.com/cssnr/parking-android/issues/new) [![Report Issue](https://img.shields.io/badge/report_issue-red?style=for-the-badge&logo=southwestairlines&logoColor=white)](https://github.com/cssnr/parking-android/issues) diff --git a/app/src/main/java/org/cssnr/parking/data/AutomaticRepository.kt b/app/src/main/java/org/cssnr/parking/data/AutomaticRepository.kt index 16394b3..84a3837 100644 --- a/app/src/main/java/org/cssnr/parking/data/AutomaticRepository.kt +++ b/app/src/main/java/org/cssnr/parking/data/AutomaticRepository.kt @@ -22,6 +22,10 @@ class AutomaticRepository(private val context: Context) { preferences[SELECTED_BLUETOOTH_DEVICES] ?: emptySet() } + val failureNotificationsEnabled: Flow = context.parkingDataStore.data.map { preferences -> + preferences[FAILURE_NOTIFICATIONS_ENABLED] ?: false + } + suspend fun setAutomaticEnabled(enabled: Boolean) { context.parkingDataStore.edit { preferences -> preferences[AUTOMATIC_ENABLED] = enabled @@ -40,10 +44,18 @@ class AutomaticRepository(private val context: Context) { } } + suspend fun setFailureNotificationsEnabled(enabled: Boolean) { + context.parkingDataStore.edit { preferences -> + preferences[FAILURE_NOTIFICATIONS_ENABLED] = enabled + } + } + private companion object { val AUTOMATIC_ENABLED = booleanPreferencesKey("automatic.enabled") val BACKGROUND_PERMISSION_REQUESTED = booleanPreferencesKey("automatic.backgroundPermissionRequested") val SELECTED_BLUETOOTH_DEVICES = stringSetPreferencesKey("automatic.bluetoothDevices") + val FAILURE_NOTIFICATIONS_ENABLED = + booleanPreferencesKey("automatic.failureNotificationsEnabled") } } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/data/BluetoothDisconnectReceiver.kt b/app/src/main/java/org/cssnr/parking/data/BluetoothDisconnectReceiver.kt index 013c6ab..b1cf045 100644 --- a/app/src/main/java/org/cssnr/parking/data/BluetoothDisconnectReceiver.kt +++ b/app/src/main/java/org/cssnr/parking/data/BluetoothDisconnectReceiver.kt @@ -79,6 +79,7 @@ class BluetoothDisconnectReceiver : BroadcastReceiver() { // is only about the position, so a refusal costs nothing. if (!ParkingWatchService.start(applicationContext, record.id)) { Log.w(TAG, "record ${record.id} keeps no position, the service was refused") + ParkingNotifier.notifyFailure(applicationContext) } } catch (e: Exception) { Log.e(TAG, "could not handle ${intent.action} for $address: ${e.message}", e) diff --git a/app/src/main/java/org/cssnr/parking/data/ParkingNotifier.kt b/app/src/main/java/org/cssnr/parking/data/ParkingNotifier.kt index 03629a3..b22bd4e 100644 --- a/app/src/main/java/org/cssnr/parking/data/ParkingNotifier.kt +++ b/app/src/main/java/org/cssnr/parking/data/ParkingNotifier.kt @@ -1,12 +1,20 @@ package org.cssnr.parking.data +import android.Manifest +import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.first import org.cssnr.parking.MainActivity import org.cssnr.parking.R @@ -23,6 +31,11 @@ object ParkingNotifier { const val TRACKING_CHANNEL_ID = "parking_tracking" const val TRACKING_NOTIFICATION_ID = 1 + const val FAILURE_CHANNEL_ID = "parking_failures" + const val FAILURE_NOTIFICATION_ID = 2 + + private const val TAG = "ParkingNotifier" + /** * Safe to call on every service start. Creating a channel that already exists * updates its name and description and leaves the user's importance choice @@ -40,8 +53,32 @@ object ParkingNotifier { setShowBadge(false) }, ) + manager.createNotificationChannel( + NotificationChannel( + FAILURE_CHANNEL_ID, + context.getString(R.string.failure_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.failure_channel_description) + }, + ) } + /** + * Whether a failure alert would actually be shown. On API 33+ this is the + * POST_NOTIFICATIONS runtime grant; below that notifications are opt-out, + * so it reflects the system toggle instead. + */ + fun hasNotificationPermission(context: Context): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + fun trackingNotification(context: Context, text: String): Notification = NotificationCompat.Builder(context, TRACKING_CHANNEL_ID) .setSmallIcon(R.drawable.md_parking_sign_24px) @@ -61,4 +98,59 @@ object ParkingNotifier { ), ) .build() + + /** + * Alert shown when a disconnect was recorded but no position could be + * attached (no GPS underground, fix timed out, service refused). Uses its + * own id so removing the foreground tracking notification never clears it. + */ + fun failureNotification(context: Context): Notification = + NotificationCompat.Builder(context, FAILURE_CHANNEL_ID) + .setSmallIcon(R.drawable.md_parking_sign_24px) + .setContentTitle(context.getString(R.string.notification_failure_title)) + .setContentText(context.getString(R.string.notification_failure_text)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.notification_failure_text)), + ) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + .build() + + /** + * Posts the failure alert if the user opted into failure notifications and + * granted the notification permission. Never throws: a disabled toggle, a + * missing permission or a blocked channel just means silence. + * + * Guarded by `hasNotificationPermission()`; lint cannot see through that + * helper, hence the suppression. The [runCatching] below also absorbs a + * [SecurityException] if the grant is revoked between the check and the post. + */ + @SuppressLint("MissingPermission") + suspend fun notifyFailure(context: Context) { + if (!AutomaticRepository(context).failureNotificationsEnabled.first()) { + Log.d(TAG, "failure notifications are off, skipping failure alert") + return + } + if (!hasNotificationPermission(context)) { + Log.d(TAG, "notifications not granted, skipping failure alert") + return + } + runCatching { + createChannel(context) + NotificationManagerCompat.from(context) + .notify(FAILURE_NOTIFICATION_ID, failureNotification(context)) + }.onFailure { Log.w(TAG, "could not post failure alert: ${it.message}") } + } } diff --git a/app/src/main/java/org/cssnr/parking/data/ParkingWatchService.kt b/app/src/main/java/org/cssnr/parking/data/ParkingWatchService.kt index b11469c..cde4847 100644 --- a/app/src/main/java/org/cssnr/parking/data/ParkingWatchService.kt +++ b/app/src/main/java/org/cssnr/parking/data/ParkingWatchService.kt @@ -60,6 +60,7 @@ class ParkingWatchService : Service() { // is never resolved after the fact because a later fix would be the // user's current position rather than the car's. Log.e(TAG, "could not enter the foreground, record $recordId keeps no position") + scope.launch { ParkingNotifier.notifyFailure(this@ParkingWatchService) } stopSelf() return START_NOT_STICKY } @@ -103,6 +104,7 @@ class ParkingWatchService : Service() { // Not retried anywhere. A fix obtained after the fact would // be where the user is standing now, not where the car is. Log.w(TAG, "no fix, record $recordId keeps no position") + ParkingNotifier.notifyFailure(applicationContext) } else { ParkingRecorder( AutomaticRepository(applicationContext), @@ -114,6 +116,7 @@ class ParkingWatchService : Service() { } } catch (e: Exception) { Log.e(TAG, "failed to place record $recordId: ${e.message}", e) + ParkingNotifier.notifyFailure(applicationContext) } finally { shutdown() } diff --git a/app/src/main/java/org/cssnr/parking/ui/screens/AutomaticScreen.kt b/app/src/main/java/org/cssnr/parking/ui/screens/AutomaticScreen.kt index 9b22f0c..092f13f 100644 --- a/app/src/main/java/org/cssnr/parking/ui/screens/AutomaticScreen.kt +++ b/app/src/main/java/org/cssnr/parking/ui/screens/AutomaticScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.ShareLocation import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -53,6 +54,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -61,7 +63,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import org.cssnr.parking.R import org.cssnr.parking.data.LocationProvider +import org.cssnr.parking.data.ParkingNotifier import org.cssnr.parking.ui.AutomaticTrackingHeader import org.cssnr.parking.ui.AutomaticTrackingStatus import org.cssnr.parking.ui.components.SettingsGroup @@ -79,6 +83,7 @@ fun AutomaticRoute( val automaticEnabled by viewModel.automaticEnabled.collectAsStateWithLifecycle() val selectedBluetoothDevices by viewModel.selectedBluetoothDevices.collectAsStateWithLifecycle() + val failureNotificationsEnabled by viewModel.failureNotificationsEnabled.collectAsStateWithLifecycle() var fineLocationGranted by remember { mutableStateOf( @@ -91,11 +96,15 @@ fun AutomaticRoute( mutableStateOf(context.hasBackgroundLocationPermission()) } var bluetoothConnectGranted by remember { mutableStateOf(context.hasBluetoothConnectPermission()) } + var notificationsGranted by remember { + mutableStateOf(ParkingNotifier.hasNotificationPermission(context)) + } var showBackgroundRationaleDialog by remember { mutableStateOf(false) } var showDevicePicker by remember { mutableStateOf(false) } var pairedDevices by remember { mutableStateOf(emptyList()) } var pendingBackgroundRequest by remember { mutableStateOf(false) } var autoEnablePending by remember { mutableStateOf(false) } + var pendingFailureEnable by remember { mutableStateOf(false) } val openBackgroundLocationSettings = { val intent = Intent( @@ -105,6 +114,13 @@ fun AutomaticRoute( context.startActivity(intent) } + val openNotificationSettings = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + val backgroundPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> @@ -159,6 +175,35 @@ fun AutomaticRoute( } } + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + notificationsGranted = granted + if (!granted && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !context.shouldShowPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + ) { + pendingFailureEnable = false + openNotificationSettings() + } else { + notificationsGranted = ParkingNotifier.hasNotificationPermission(context) + if (notificationsGranted && pendingFailureEnable) { + pendingFailureEnable = false + viewModel.setFailureNotificationsEnabled(true) + } else if (!notificationsGranted) { + pendingFailureEnable = false + } + } + } + + val requestNotificationPermission = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openNotificationSettings() + } + } + LaunchedEffect(showDevicePicker, bluetoothConnectGranted) { if (bluetoothConnectGranted) { pairedDevices = context.getPairedDevices() @@ -177,17 +222,23 @@ fun AutomaticRoute( fineLocationGranted = LocationProvider.hasLocationPermission(context) backgroundLocationGranted = context.hasBackgroundLocationPermission() bluetoothConnectGranted = context.hasBluetoothConnectPermission() + notificationsGranted = ParkingNotifier.hasNotificationPermission(context) + if (pendingFailureEnable && notificationsGranted) { + pendingFailureEnable = false + viewModel.setFailureNotificationsEnabled(true) + } } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - val backgroundPermissionLabel = remember(context) { + val backgroundFallback = stringResource(R.string.automatic_background_allow_all) + val backgroundPermissionLabel = remember(context, backgroundFallback) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { context.packageManager.backgroundPermissionOptionLabel.toString() } else { - "Allow all the time" + backgroundFallback } } @@ -276,6 +327,26 @@ fun AutomaticRoute( requestBackgroundLocation() } }, + notificationsGranted = notificationsGranted, + onRequestNotifications = { + if (ParkingNotifier.hasNotificationPermission(context)) { + notificationsGranted = true + } else { + requestNotificationPermission() + } + }, + failureNotificationsEnabled = failureNotificationsEnabled, + onFailureNotificationsToggle = { enabled -> + if (!enabled) { + pendingFailureEnable = false + viewModel.setFailureNotificationsEnabled(false) + } else if (ParkingNotifier.hasNotificationPermission(context)) { + viewModel.setFailureNotificationsEnabled(true) + } else { + pendingFailureEnable = true + requestNotificationPermission() + } + }, trackingStatus = trackingStatus, onTrackingStatusClick = onTrackingStatusClick, ) @@ -312,13 +383,17 @@ fun AutomaticScreen( onEditDevices: () -> Unit, onRequestFineLocation: () -> Unit, onRequestBackgroundLocation: () -> Unit, + notificationsGranted: Boolean, + onRequestNotifications: () -> Unit, + failureNotificationsEnabled: Boolean, + onFailureNotificationsToggle: (Boolean) -> Unit, trackingStatus: AutomaticTrackingStatus? = null, onTrackingStatusClick: () -> Unit = {}, ) { Scaffold( topBar = { TopAppBar( - title = { Text("Automatic") }, + title = { Text(stringResource(R.string.automatic_title)) }, actions = { AutomaticTrackingHeader( status = trackingStatus, @@ -346,65 +421,95 @@ fun AutomaticScreen( ) } SettingsGroup( - title = "Automatic Parking", + title = stringResource(R.string.automatic_parking_title), tiles = listOf( SettingsTile.Toggle( icon = rememberVectorPainter(Icons.Filled.LocationOn), - title = "Automatic Parking", + title = stringResource(R.string.automatic_parking_title), summary = when { - !fineLocationGranted -> "Location permission not granted." + !fineLocationGranted -> stringResource(R.string.automatic_parking_no_location) !backgroundLocationGranted -> - "Needs background location. Enable \"$backgroundPermissionLabel\" in Settings." + stringResource( + R.string.automatic_parking_needs_background, + backgroundPermissionLabel, + ) - else -> "Records your parking spot when your car disconnects." + else -> stringResource(R.string.automatic_parking_summary) }, checked = automaticEnabled, onCheckedChange = onAutomaticToggle, ), SettingsTile.Action( icon = rememberVectorPainter(Icons.Filled.Bluetooth), - title = "Devices", + title = stringResource(R.string.automatic_devices_title), summary = when { - !bluetoothConnectGranted -> "Bluetooth access required to list paired devices." - selectedDeviceNames.isEmpty() -> "Choose which Bluetooth devices this works with." + !bluetoothConnectGranted -> stringResource(R.string.automatic_devices_no_permission) + selectedDeviceNames.isEmpty() -> stringResource(R.string.automatic_devices_empty) else -> selectedDeviceNames.joinToString(", ") }, - actionLabel = if (bluetoothConnectGranted) "Edit" else "Allow", + actionLabel = if (bluetoothConnectGranted) { + stringResource(R.string.automatic_devices_edit) + } else { + stringResource(R.string.automatic_devices_allow) + }, onClick = if (bluetoothConnectGranted) { onEditDevices } else { onRequestBluetoothPermission }, ), + SettingsTile.Toggle( + icon = rememberVectorPainter(Icons.Filled.Notifications), + title = stringResource(R.string.automatic_failure_title), + summary = stringResource(R.string.automatic_failure_summary), + checked = failureNotificationsEnabled, + onCheckedChange = onFailureNotificationsToggle, + ), ), ) SettingsGroup( - title = "Permissions", + title = stringResource(R.string.automatic_required_permissions), tiles = listOf( SettingsTile.Link( icon = rememberVectorPainter(Icons.Filled.ShareLocation), - title = "Grant Location Permissions", + title = stringResource(R.string.automatic_grant_location), summary = if (fineLocationGranted) { - "Location permission granted." + stringResource(R.string.automatic_location_granted) } else { - "Required for Automatic Parking." + stringResource(R.string.automatic_required_summary) }, enabled = !fineLocationGranted, onClick = onRequestFineLocation, ), SettingsTile.Link( icon = rememberVectorPainter(Icons.Filled.Layers), - title = "Grant Background Location", + title = stringResource(R.string.automatic_grant_background), summary = if (backgroundLocationGranted && fineLocationGranted) { - "Background location granted." + stringResource(R.string.automatic_background_granted) } else { - "Required for Automatic Parking." + stringResource(R.string.automatic_required_summary) }, enabled = !(backgroundLocationGranted && fineLocationGranted), onClick = onRequestBackgroundLocation, ), ), ) + SettingsGroup( + title = stringResource(R.string.automatic_optional_permissions), + tiles = listOf( + SettingsTile.Link( + icon = rememberVectorPainter(Icons.Filled.Notifications), + title = stringResource(R.string.automatic_grant_notifications), + summary = if (notificationsGranted) { + stringResource(R.string.automatic_notifications_granted) + } else { + stringResource(R.string.automatic_notifications_summary) + }, + enabled = !notificationsGranted, + onClick = onRequestNotifications, + ), + ), + ) Spacer(modifier = Modifier.height(16.dp)) } } @@ -412,22 +517,18 @@ fun AutomaticScreen( if (showPermissionRationaleDialog) { AlertDialog( onDismissRequest = onPermissionRationaleDismiss, - title = { Text("Background Location Access") }, + title = { Text(stringResource(R.string.automatic_rationale_title)) }, text = { - Text( - "Automatic records your parking spot when your car disconnects, " + - "even when the app is in the background. Choose \"All the time\" " + - "when prompted for location access." - ) + Text(stringResource(R.string.automatic_rationale_text)) }, confirmButton = { TextButton(onClick = onPermissionRationaleGrant) { - Text("Grant Permissions") + Text(stringResource(R.string.automatic_rationale_grant)) } }, dismissButton = { TextButton(onClick = onPermissionRationaleDismiss) { - Text("Cancel") + Text(stringResource(R.string.automatic_cancel)) } }, ) @@ -442,13 +543,13 @@ private fun PermissionWarningBanner( ) { val warning = when { !fineLocationGranted && !backgroundLocationGranted -> - "Location and background location permissions are missing." + stringResource(R.string.automatic_warning_both) !fineLocationGranted -> - "Location permission is missing. Automatic parking can't record your spot." + stringResource(R.string.automatic_warning_location) else -> - "Background location permission is missing. Automatic parking can't record in the background." + stringResource(R.string.automatic_warning_background) } Card( modifier = modifier.fillMaxWidth(), @@ -486,13 +587,10 @@ private fun DevicePickerDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Select Devices") }, + title = { Text(stringResource(R.string.automatic_picker_title)) }, text = { when { - pairedDevices.isEmpty() -> Text( - "No paired Bluetooth devices found. " + - "Pair a device in Settings, then try again." - ) + pairedDevices.isEmpty() -> Text(stringResource(R.string.automatic_picker_empty)) else -> Column( modifier = Modifier @@ -547,12 +645,12 @@ private fun DevicePickerDialog( onClick = { onSave(selection) }, enabled = pairedDevices.isNotEmpty(), ) { - Text("Save") + Text(stringResource(R.string.automatic_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.automatic_cancel)) } }, ) @@ -599,7 +697,7 @@ fun AutomaticScreenPreview() { backgroundLocationGranted = false, bluetoothConnectGranted = false, selectedDeviceNames = emptyList(), - backgroundPermissionLabel = "Allow all the time", + backgroundPermissionLabel = stringResource(R.string.automatic_background_allow_all), showPermissionRationaleDialog = true, onPermissionRationaleGrant = {}, onPermissionRationaleDismiss = {}, @@ -608,6 +706,10 @@ fun AutomaticScreenPreview() { onEditDevices = {}, onRequestFineLocation = {}, onRequestBackgroundLocation = {}, + notificationsGranted = false, + onRequestNotifications = {}, + failureNotificationsEnabled = false, + onFailureNotificationsToggle = {}, ) } } diff --git a/app/src/main/java/org/cssnr/parking/ui/viewmodel/AutomaticViewModel.kt b/app/src/main/java/org/cssnr/parking/ui/viewmodel/AutomaticViewModel.kt index 45f5b5f..a526d5e 100644 --- a/app/src/main/java/org/cssnr/parking/ui/viewmodel/AutomaticViewModel.kt +++ b/app/src/main/java/org/cssnr/parking/ui/viewmodel/AutomaticViewModel.kt @@ -36,6 +36,14 @@ class AutomaticViewModel(application: Application) : AndroidViewModel(applicatio initialValue = emptySet(), ) + val failureNotificationsEnabled: StateFlow = + automaticRepository.failureNotificationsEnabled + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + fun setAutomaticEnabled(enabled: Boolean) { viewModelScope.launch { automaticRepository.setAutomaticEnabled(enabled) @@ -53,4 +61,10 @@ class AutomaticViewModel(application: Application) : AndroidViewModel(applicatio automaticRepository.setSelectedBluetoothDevices(addresses) } } + + fun setFailureNotificationsEnabled(enabled: Boolean) { + viewModelScope.launch { + automaticRepository.setFailureNotificationsEnabled(enabled) + } + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d23579..554b5b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Application Prune History - How long to keep history. + How often to prune history Disabled 1 week 2 weeks @@ -15,7 +15,7 @@ Debug Enable Crash Reporting - Send crash reports. + Send crash reports About @@ -49,6 +49,53 @@ Finding where you parked… + + Parking alerts + Alerts you when automatic parking could not record a location. + + Could not record parking location + Bluetooth disconnected but no location was available. Open ParKing to save it manually. + Location not recorded + + + Automatic + Location and background location permissions are missing. + Location permission is missing. Automatic parking can\'t record your spot. + Background location permission is missing. Automatic parking can\'t record in the background. + + Automatic Parking + Location permission not granted. + Needs background location. Enable \"%1$s\" in Settings. + Records your parking spot when your car disconnects. + Devices + Bluetooth access required to list paired devices. + Choose which Bluetooth devices this works with. + Edit + Allow + Failure Notifications + Get an alert if location fails. + + Required Permissions + Grant Location Permissions + Location permission granted. + Required for Automatic Parking. + Grant Background Location + Background location granted. + Allow all the time + + Optional Permissions + Grant Notification Permissions + Notifications permission granted. + Required for failure notifications. + + Background Location Access + Automatic records your parking spot when your car disconnects, even when the app is in the background. Choose \"All the time\" when prompted for location access. + Grant Permissions + Select Devices + No paired Bluetooth devices found. Pair a device in Settings, then try again. + Save + Cancel