Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/org/cssnr/parking/data/AutomaticRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class AutomaticRepository(private val context: Context) {
preferences[SELECTED_BLUETOOTH_DEVICES] ?: emptySet()
}

val failureNotificationsEnabled: Flow<Boolean> = context.parkingDataStore.data.map { preferences ->
preferences[FAILURE_NOTIFICATIONS_ENABLED] ?: false
}

suspend fun setAutomaticEnabled(enabled: Boolean) {
context.parkingDataStore.edit { preferences ->
preferences[AUTOMATIC_ENABLED] = enabled
Expand All @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions app/src/main/java/org/cssnr/parking/data/ParkingNotifier.kt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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}") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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),
Expand All @@ -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()
}
Expand Down
Loading
Loading