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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
.externalNativeBuild
.cxx
local.properties
app/build
app/build
.kotlin
293 changes: 157 additions & 136 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}

dependencies {
testImplementation("junit:junit:4.13.2")
}
43 changes: 34 additions & 9 deletions app/src/main/java/com/example/speedvolume/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,10 @@ class MainActivity : Activity() {

private fun onToggle() {
if (ServiceState.running) {
prefs.edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, false).apply()
stopService(Intent(this, SpeedVolumeService::class.java))
refreshUi()
} else {
maybeAskIgnoreBatteryOptimizations()
val needed = permissionList()
if (needed.isEmpty()) {
startVolumeService()
Expand Down Expand Up @@ -208,21 +208,37 @@ class MainActivity : Activity() {
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != REQUEST_PERMISSIONS) return
val granted = grantResults.any { it == PackageManager.PERMISSION_GRANTED }
if (granted) startVolumeService()
else statusText.text = getString(R.string.status_permission_denied)
val remaining = permissionList()
if (remaining.isEmpty()) {
startVolumeService()
} else if (permissions.indices.any { index ->
grantResults[index] == PackageManager.PERMISSION_GRANTED &&
permissions[index] !in remaining
}) {
// Foreground location, background location, and notifications are
// separate Android permission steps. Continue only after progress.
requestPermissions(remaining.toTypedArray(), REQUEST_PERMISSIONS)
} else {
statusText.text = getString(R.string.status_permission_denied)
}
}

private fun permissionList(): List<String> {
val fineGranted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val needed = mutableListOf<String>()
if (!fineGranted) needed += Manifest.permission.ACCESS_FINE_LOCATION
if (fineGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
if (!fineGranted) {
// Android requires foreground location to be granted before the
// separate "all the time" background-location step.
needed += Manifest.permission.ACCESS_FINE_LOCATION
return needed
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) !=
PackageManager.PERMISSION_GRANTED
) {
needed += Manifest.permission.ACCESS_BACKGROUND_LOCATION
return needed
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
Expand All @@ -234,11 +250,20 @@ class MainActivity : Activity() {
}

private fun startVolumeService() {
startForegroundService(Intent(this, SpeedVolumeService::class.java))
refreshUi()
try {
prefs.edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, true).apply()
startForegroundService(Intent(this, SpeedVolumeService::class.java))
// Do this after the service launch. Opening Settings first can
// make the launch look background-originated to OEM restrictions.
maybeAskIgnoreBatteryOptimizations()
refreshUi()
} catch (e: Exception) {
prefs.edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, false).apply()
statusText.text = getString(R.string.status_start_failed)
}
}

companion object {
private const val REQUEST_PERMISSIONS = 1
}
}
}
17 changes: 13 additions & 4 deletions app/src/main/java/com/example/speedvolume/SpeedFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import kotlin.math.min
* (a car's maximum plausible acceleration) is discarded
* - cross-check: when the fix is low-confidence, a sample that wildly
* disagrees with displacement-derived speed is discarded
* - median-of-5: removes isolated outliers while keeping sharp real changes
* - median-of-3: removes isolated outliers while keeping sharp real changes
* - adaptive EMA: aggressive smoothing on low-confidence samples, light
* smoothing on good fixes, so real speed changes stay responsive
*/
Expand All @@ -34,8 +34,8 @@ class SpeedFilter(private val maxAccelMs2: Float = 12f) {
*/
fun process(rawKmh: Float, derivedKmh: Float, confident: Boolean, nowMs: Long): Float {
val sample = when {
!rawKmh.isNaN() -> if (rawKmh < 3f) 0f else rawKmh
!derivedKmh.isNaN() -> derivedKmh
rawKmh.isFinite() -> rawKmh.coerceAtLeast(0f).let { if (it < 3f) 0f else it }
derivedKmh.isFinite() -> derivedKmh.coerceAtLeast(0f)
else -> return Float.NaN
}

Expand Down Expand Up @@ -67,4 +67,13 @@ class SpeedFilter(private val maxAccelMs2: Float = 12f) {
lastSampleMs = nowMs
return smoothed
}
}

/** Clears history when switching between GPS and motion-derived sources. */
fun reset() {
window.clear()
smoothed = 0f
initialized = false
prevValidKmh = -1f
lastSampleMs = 0L
}
}
114 changes: 91 additions & 23 deletions app/src/main/java/com/example/speedvolume/SpeedVolumeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {

private var lastLocation: Location? = null
private var lastFixMs = 0L
private var filterSourceMotion: Boolean? = null

companion object {
private const val TAG = "SpeedVolumeService"
Expand All @@ -53,6 +54,7 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
const val PREFS_NAME = "speed_volume_settings"
const val PREF_MAX_SPEED = "max_speed_kmh"
const val PREF_IDLE_VOLUME = "idle_volume"
const val PREF_SERVICE_ENABLED = "service_enabled"
const val DEFAULT_MAX_SPEED = 80
private const val WATCHDOG_INTERVAL_MS = 15_000L
private const val NO_FIX_TIMEOUT_MS = 5_000L
Expand All @@ -68,6 +70,15 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "stop requested")
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(PREF_SERVICE_ENABLED, false).apply()
stopSelf()
return START_NOT_STICKY
}

if (intent == null && !getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean(PREF_SERVICE_ENABLED, false)
) {
stopSelf()
return START_NOT_STICKY
}
Expand All @@ -86,26 +97,41 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (e: SecurityException) {
} catch (e: Exception) {
Log.w(TAG, "startForeground failed, permission lost: $e")
started = false
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(PREF_SERVICE_ENABLED, false).apply()
ServiceState.running = false
ServiceState.changed()
stopSelf()
return START_NOT_STICKY
}
if (!ok) return START_NOT_STICKY

ServiceState.running = true
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(PREF_SERVICE_ENABLED, true).apply()
ServiceState.volume = currentVolume()
ServiceState.changed()

ensureLocationUpdates()
ensureSensors()
ramp.start({ mapSpeedToVolume(ServiceState.speedKmh) }) { blocked ->
if (ServiceState.volumeBlocked != blocked) {
ServiceState.volumeBlocked = blocked
ServiceState.changed()
ramp.start(
targetProvider = { mapSpeedToVolume(ServiceState.speedKmh) },
onBlockedChanged = { blocked ->
if (ServiceState.volumeBlocked != blocked) {
ServiceState.volumeBlocked = blocked
ServiceState.changed()
}
},
onVolumeChanged = { volume ->
if (ServiceState.volume != volume) {
ServiceState.volume = volume
ServiceState.changed()
}
}
}
)
handler.post(watchdogRunnable)
Log.i(TAG, "service started")
return START_STICKY
Expand All @@ -114,25 +140,47 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
private val watchdogRunnable = object : Runnable {
override fun run() {
if (!started) return
val lm = locationManager ?: return
val gpsOn = lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
if (gpsOn != ServiceState.gpsEnabled) {
ServiceState.gpsEnabled = gpsOn
ServiceState.changed()
notificationManager().notify(NOTIFICATION_ID, buildNotification())
}
if (gpsOn && !updatesRequested) ensureLocationUpdates()

val noFix = lastFixMs == 0L || SystemClock.elapsedRealtime() - lastFixMs > NO_FIX_TIMEOUT_MS
if (noFix != !ServiceState.hasFix) {
ServiceState.hasFix = !noFix
ServiceState.changed()
try {
val lm = locationManager
if (lm == null) {
ensureLocationUpdates()
} else {
val gpsOn = lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
if (gpsOn != ServiceState.gpsEnabled) {
ServiceState.gpsEnabled = gpsOn
ServiceState.changed()
notificationManager().notify(NOTIFICATION_ID, buildNotification())
}

val noFix = lastFixMs == 0L ||
SystemClock.elapsedRealtime() - lastFixMs > NO_FIX_TIMEOUT_MS
if (noFix != !ServiceState.hasFix) {
ServiceState.hasFix = !noFix
ServiceState.changed()
}

if (gpsOn && (!updatesRequested || noFix)) {
ensureLocationUpdates(force = true)
}
}
} catch (e: Exception) {
Log.w(TAG, "watchdog recovery failed: $e")
updatesRequested = false
} finally {
if (started) handler.postDelayed(this, WATCHDOG_INTERVAL_MS)
}
handler.postDelayed(this, WATCHDOG_INTERVAL_MS)
}
}

private fun ensureLocationUpdates() {
private fun ensureLocationUpdates(force: Boolean = false) {
val fineGranted = checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (!fineGranted) {
updatesRequested = false
ServiceState.gpsEnabled = false
ServiceState.changed()
return
}
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
locationManager = lm
if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
Expand All @@ -141,12 +189,17 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
return
}
try {
if (force) {
lm.removeUpdates(this)
updatesRequested = false
}
lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 500L, 0f, this)
updatesRequested = true
ServiceState.gpsEnabled = true
ServiceState.changed()
} catch (e: SecurityException) {
} catch (e: Exception) {
Log.w(TAG, "location permission lost: $e")
updatesRequested = false
ServiceState.gpsEnabled = false
ServiceState.changed()
}
Expand All @@ -169,6 +222,7 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {

val estimate = motion.speedEstimateKmh(now)
if (estimate.isNaN()) return
resetFilterIfSourceChanged(motionSource = true)
val filtered = filter.process(estimate, Float.NaN, false, now)
if (filtered.isNaN() || filtered == ServiceState.speedKmh) return
ServiceState.speedKmh = filtered
Expand Down Expand Up @@ -203,6 +257,7 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
val rawKmh = if (location.hasSpeed()) location.speed * 3.6f else Float.NaN
val confident = location.hasSpeed() && location.accuracy > 0f && location.accuracy <= 40f

resetFilterIfSourceChanged(motionSource = false)
val filtered = filter.process(rawKmh, derivedKmh, confident, now)
if (filtered.isNaN().not()) {
ServiceState.speedKmh = filtered
Expand Down Expand Up @@ -244,6 +299,13 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
private fun currentVolume(): Int =
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)

private fun resetFilterIfSourceChanged(motionSource: Boolean) {
if (filterSourceMotion != motionSource) {
filter.reset()
filterSourceMotion = motionSource
}
}

private fun notificationManager(): NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

Expand Down Expand Up @@ -291,8 +353,14 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
locationManager?.removeUpdates(this)
stepSensor?.let { sensorManager?.unregisterListener(this, it) }
ServiceState.running = false
ServiceState.speedKmh = Float.NaN
ServiceState.volume = currentVolume()
ServiceState.hasFix = false
ServiceState.motionOnly = false
ServiceState.volumeBlocked = false
ServiceState.gpsEnabled = true
filterSourceMotion = null
filter.reset()
ServiceState.changed()
Log.i(TAG, "service stopped")
super.onDestroy()
Expand All @@ -317,4 +385,4 @@ class SpeedVolumeService : Service(), LocationListener, SensorEventListener {
override fun onStatusChanged(provider: String, status: Int, extras: Bundle) = Unit

override fun onFlushComplete(requestCode: Int) = Unit
}
}
Loading
Loading