diff --git a/.gitignore b/.gitignore index 0c15016..35925c0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ .externalNativeBuild .cxx local.properties -app/build \ No newline at end of file +app/build +.kotlin diff --git a/README.md b/README.md index 3658535..a247fd7 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,195 @@ # Speed Volume [![License](https://img.shields.io/github/license/sohamvjadhav/SpeedVolume)](https://github.com/sohamvjadhav/SpeedVolume/blob/main/LICENSE) -[![Release](https://img.shields.io/github/v/release/sohamvjadhav/SpeedVolume)](https://github.com/sohamvjadhav/SpeedVolume/releases) [![Build](https://img.shields.io/github/actions/workflow/status/sohamvjadhav/SpeedVolume/build.yml)](https://github.com/sohamvjadhav/SpeedVolume/actions) -[![Platform](https://img.shields.io/badge/Android-8.0%2B-orange.svg)](https://developer.android.com/studio) +[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://developer.android.com/) [![Min SDK](https://img.shields.io/badge/minSdk-26-brightgreen)](https://developer.android.com/studio) -[![Target SDK](https://img.shields.io/badge/targetSdk-36-blue)](https://developer.android.com/studio) -[![Kotlin](https://img.shields.io/badge/Kotlin-✓-purple)](https://kotlinlang.org) - -Android app that reads the device's GPS speed and automatically adjusts media -volume to match — the faster you go, the louder the music. Built for the car -(and for runs, thanks to motion-sensor fallback). - -- Standing still → low "idle" volume -- 80 km/h → max volume -- Everything between scales on a sqrt curve (fast, audible response at - everyday speeds) -- Works as a Quick Settings tile: tap to start/stop while driving, no - screen-peeking required - -## Features - -- **GPS-driven volume**: foreground service polls GPS (500 ms) and maps - filtered speed to media volume -- **Motion-sensor fusion (v1.6)**: when GPS has no fix (indoor runs, tunnels, - cold start), speed is estimated from step cadence × stride, so the - speedometer and volume never sit at 0 while you're moving -- **Self-calibrating stride**: every GPS-verified walk/run re-measures your - real stride length, so the sensor estimate converges on your actual gait - (±10–15% accuracy, improving with use) -- **Accuracy-first filter pipeline**: deadband + spike rejection + median + EMA - keep the raw GPS noise out while preserving real speed changes -- **Instant response (v1.7)**: volume approaches its target exponentially - (~250 ms settle), speed changes pass through in 1–2 s -- **Quick Settings tile**: tap to start/stop, shows live speed + volume - subtitle; long-press opens the app -- **Battery & OEM hardened**: START_STICKY restarts, 15 s watchdog re-registers - location, battery-optimization whitelist prompt, volume-block detection - (ColorOS/MIUI quirk) with an explicit UI warning -- **Zero dependencies**: pure Android platform APIs — no AndroidX, no Material, - no third-party libraries - -## How it works -``` -GPS fix (500 ms) ──▶ filter pipeline ──▶ filtered speed ──▶ sqrt map ──▶ target level - │ │ -step sensor (no fix) ─────┘ ▼ - VolumeRamp: exponential approach - (halves gap every 50 ms) +Speed Volume is an Android app that adjusts `STREAM_MUSIC` volume from the +device's current speed. It is designed for driving and supports a walking or +running fallback when GPS temporarily has no fresh fix. + +The app runs as a location foreground service. It is not a replacement for a +vehicle's safety systems, and it cannot make GPS work indoors, inside tunnels, +or when the phone manufacturer stops background execution. + +## What it does + +- Maps filtered speed to media volume with a responsive square-root curve. +- Uses GPS as the authoritative source for vehicle speed. +- Falls back to step cadence and a calibrated stride for walking/running when + GPS has been stale for more than five seconds. +- Smooths noisy GPS using a standstill deadband, acceleration rejection, + displacement cross-checking, median filtering, and adaptive EMA smoothing. +- Moves volume toward its target every 50 ms and detects OEMs that silently + refuse volume changes. +- Provides an ongoing notification, a Quick Settings tile, and a live status + screen. +- Recovers location requests after screen lock or Doze when callbacks become + stale, subject to device and OEM policy. + +## How the engine works + +```text +GPS fixes (500 ms) ──┐ + ├─ source-aware filter ── speed ── curve ── target volume +step detector ───────┘ └─ exponential volume ramp ``` -1. `SpeedVolumeService` (a foreground service with - `FOREGROUND_SERVICE_TYPE_LOCATION`) listens to `GPS_PROVIDER`. -2. `SpeedFilter` cleans the raw speed: samples < 3 km/h are deadbanded to 0, - implausible accelerations (> 12 m/s²) are rejected, weak fixes are - cross-checked against displacement-derived speed, then a median-of-3 and - adaptive EMA smooth it (α 0.6 confident / 0.35 weak). -3. When no fix is fresh (`> 5 s`), step-detector cadence × calibrated stride - (capped at 18 km/h) feeds the same filter, and the UI marks the source as - "motion sensors". -4. The filtered speed maps to a target level via a sqrt curve between - **standstill volume** (default 15% of max) and **full-volume speed** - (default 80 km/h) — both configurable live in the app. -5. `VolumeRamp` moves the stream toward the target exponentially (halves the - gap every 50 ms): large changes settle in ~250 ms, small corrections are - inaudible. It also detects when the OEM silently refuses changes and - flags a warning. - -## Setup - -### Permissions - -| Permission | Why | +1. `SpeedVolumeService` promotes itself to a location foreground service and + requests `GPS_PROVIDER` updates every 500 ms. +2. `SpeedFilter` treats speeds below 3 km/h as stationary, rejects samples + implying more than 12 m/s² acceleration, cross-checks weak fixes against + displacement speed, then applies median-of-3 and adaptive EMA smoothing. +3. If the last GPS fix is older than five seconds, `SensorMotion` estimates + walking/running speed from recent step cadence. The estimate is capped at + 18 km/h and is not intended to replace GPS for driving. +4. GPS-verified walking or running calibrates stride length gradually. The + filter history is reset whenever the source changes, so stale GPS speed does + not bleed into a motion estimate. +5. Speed is mapped between the configured standstill volume and full volume. + The default full-volume threshold is 80 km/h; the default standstill level + is approximately 15% of the media stream's maximum. +6. `VolumeRamp` approaches the target exponentially and reports the actual + stream level back to the activity, tile, and notification. + +## Reliable setup for a locked screen + +Android and phone manufacturers restrict background work. Complete all of the +following on the test device: + +1. Open Speed Volume and grant precise location. +2. Grant **Allow all the time** location access. This app requires it for its + Quick Settings and locked-screen workflow. +3. Grant notifications on Android 13 and newer. The ongoing notification is + how Android recognizes and exposes the foreground service. +4. Set the app's battery usage to **Unrestricted** or disable battery + optimization for it. +5. Enable the manufacturer's **Auto-start**, **Allow background activity**, or + equivalent setting. Common battery killers include ColorOS, MIUI, EMUI, + One UI power saving, and aggressive third-party task managers. +6. Start tracking once while the app is visibly open. Confirm that the + ongoing **Speed Volume active** notification appears before locking the + phone. +7. Lock the phone and test in an open-sky location. GPS speed needs a usable + satellite fix; the step fallback is for pedestrians, not vehicles. + +Android's foreground-service and background-location rules change across API +levels. See the official guidance for [foreground-service background starts](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start) +and [background location](https://developer.android.com/develop/sensors-and-location/location/permissions/background). + +If the service still stops only on one phone model, that is usually an OEM +power-management policy. The app can re-register its location request, but it +cannot override a manufacturer force-stop or task-killer policy. + +## Permissions and device access + +| Permission or setting | Purpose | |---|---| -| Location (fine) | GPS speed | -| Location (always/"all the time") | Starting the service from the background (QS tile) — Android refuses background location-foreground-service starts without it | -| Notifications (Android 13+) | Required for the foreground service notification | -| Battery-optimization exemption | ColorOS/MIUI kill background services aggressively; whitelisting makes it reliable | - -The app requests each on first use; grant "**Allow all the time**" for -location or the tile won't start the service from the background. +| Fine location | GPS speed and displacement | +| Background location | Locked-screen and Quick Settings operation | +| Notifications | Visible foreground-service notification on Android 13+ | +| Modify audio settings | Adjusts the `STREAM_MUSIC` volume | +| Battery optimization exemption | Prevents OEM/Doze termination where supported | +| Step detector hardware | Optional walking/running fallback; no permission is required | -### Quick Settings tile (one-time) +## Quick Settings tile -Long-press a tile slot in the notification shade edit grid and pick -**Speed Volume**, or add via: +Add **Speed Volume** from the notification shade's tile editor. Alternatively, +on a connected development device: ```bash -adb shell cmd statusbar add-tile com.example.speedvolume/com.example.speedvolume.SpeedVolumeTile +adb shell cmd statusbar add-tile \ + com.example.speedvolume/com.example.speedvolume.SpeedVolumeTile ``` -### Build & install +The tile starts and stops the service. If required permissions are missing, +it tells you to open the app and complete setup. Long-pressing the tile opens +the app. + +## Build, test, and install -No AndroidX/Material dependencies — compiles with any modern AGP. The wrapper -pins Gradle 9.2.1 / AGP 9.0.1 (Java 21). +The app has no runtime AndroidX, Material, or third-party dependencies. JUnit +is used for local engine tests. The project uses AGP 9.0.1, Gradle 9.2.1, +compile SDK 36, and Java 17 source compatibility. ```bash -./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk -./gradlew installDebug # install on a connected device +./gradlew testDebugUnitTest # filter and motion-engine tests +./gradlew lintDebug # Android static analysis +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew installDebug # install on a connected device ``` -Or open in Android Studio and Run. Use a physical device — an emulator has no -real GPS speed. Then grant location permission and press **Start**. +Use a physical device for functional testing. An emulator generally does not +provide realistic GPS speed, sensor cadence, Bluetooth audio, or OEM power +management. -## In the app +## In-app controls -- **Hero speed readout** — the filtered speed that drives the volume -- **Status pill** — `fix locked` / `motion sensors` / `looking for fix…` and - GPS-toggle + volume-blocked warnings -- **Mapping settings** — full-volume speed (km/h) and standstill volume, - applied live -- **Stop** — stops the service (or tap the tile, or the notification action) +- **Full volume above speed**: speed at which the media stream reaches maximum. +- **Standstill volume level**: minimum volume used at 0–3 km/h. +- **Status pill**: whether the service is running. +- **Source row**: GPS fix, motion sensors, or looking for a fix. +- **Warnings**: GPS disabled, volume changes blocked, or service start failure. +- **Stop**: stops the foreground service and prevents a sticky restart. ## Troubleshooting -- **Volume doesn't move** → the "volume changes blocked" warning appears when - the ROM silently refuses changes; check ColorOS "set media volume" / - device-admin-like restrictions. -- **Works sometimes, not others** → battery killer. Whitelist the app on first - start and don't force-stop it. -- **Speed stuck at 0 indoors** → GPS needs sky view; the step-sensor fallback - covers walking/running without a fix, but vehicles always need real GPS. -- **Other apps change the volume too** → normal Android behavior (navigation, - calls); the volume adjusted is the same `STREAM_MUSIC` stream that car - Bluetooth uses. +### It works only while the app is open -## Project layout +Check **Allow all the time**, notifications, unrestricted battery usage, and +the manufacturer's auto-start/background setting. Start the service while the +app is visible and verify the persistent notification before locking. -``` -app/src/main/java/com/example/speedvolume/ -├── MainActivity.kt # UI: status, start/stop, mapping settings -├── SpeedVolumeService.kt # GPS + sensor fusion → volume foreground service -├── SpeedFilter.kt # noise filtering (deadband, spikes, median, EMA) -├── VolumeRamp.kt # exponential volume approach + block detection -├── SensorMotion.kt # step cadence × self-calibrating stride estimate -├── SpeedVolumeTile.kt # Quick Settings tile (start/stop, live subtitle) -└── ServiceState.kt # listener-bridged state between service/UI/tile - -app/src/main/res/ -├── layout/activity_main.xml # card-based minimal UI (light + dark themes) -├── values/, values-night/ # colors & platform themes -├── drawable/ # cards, seekbars, tile & notification icons -└── mipmap-anydpi-v26/ # adaptive launcher icon -``` +### It stops when the phone locks -## Version history & reverting +The service may have been killed by Doze or an OEM task killer. Re-enable +unrestricted battery usage and auto-start. Do not force-stop the app; Android +does not reliably restart force-stopped applications. -The project is versioned with a tag per release: +### Speed stays at `--` or zero -```bash -git tag # v1.0 … v1.7 -git log --oneline --decorate -``` +Move outdoors and wait for a GPS fix. For walking/running, confirm that the +phone has a step-detector sensor and that recent steps are being detected. A +vehicle still requires GPS; motion fallback is intentionally limited to +pedestrian speeds. -To try a previous version over the working tree: +### Volume does not change -```bash -git checkout v1.4 -- . # restore v1.4 files, keep git history -./gradlew installDebug -``` +The app adjusts the same media stream used by Bluetooth car audio. If the +warning appears, the ROM may be blocking programmatic media-volume changes. +Check device-admin, car-mode, audio-policy, or OEM restrictions. -To permanently go back (reflog keeps everything): +### The tile does nothing -```bash -git revert # or: git reset --hard v1.4 +Open the app, grant background location, and try starting from the app once. +If the tile starts and then immediately becomes inactive, inspect the ongoing +notification and the device's background-execution settings. + +## Project layout + +```text +app/src/main/java/com/example/speedvolume/ +├── MainActivity.kt # setup UI, permissions, and controls +├── SpeedVolumeService.kt # foreground service, GPS, sensors, recovery +├── SpeedFilter.kt # GPS/displacement filtering +├── VolumeRamp.kt # exponential volume approach and block detection +├── SensorMotion.kt # cadence and stride calibration +├── SpeedVolumeTile.kt # Quick Settings integration +└── ServiceState.kt # activity/tile/service state bridge + +app/src/test/java/com/example/speedvolume/ +├── SpeedFilterTest.kt # filter edge cases +└── SensorMotionTest.kt # cadence and stale-sensor behavior ``` -Every change is logged in [CHANGELOG.md](CHANGELOG.md). +## Versioning and license + +Release history is recorded in [CHANGELOG.md](CHANGELOG.md). Inspect tags with: -## License +```bash +git tag +git log --oneline --decorate +``` -[MIT](LICENSE) © 2026 Soham Jadhav \ No newline at end of file +The project is licensed under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f69e6e7..7686dcb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,4 +28,8 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } -} \ No newline at end of file +} + +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/app/src/main/java/com/example/speedvolume/MainActivity.kt b/app/src/main/java/com/example/speedvolume/MainActivity.kt index fda4e7f..563488d 100644 --- a/app/src/main/java/com/example/speedvolume/MainActivity.kt +++ b/app/src/main/java/com/example/speedvolume/MainActivity.kt @@ -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() @@ -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 { val fineGranted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED val needed = mutableListOf() - 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) != @@ -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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/speedvolume/SpeedFilter.kt b/app/src/main/java/com/example/speedvolume/SpeedFilter.kt index 5b3dc47..d18cba6 100644 --- a/app/src/main/java/com/example/speedvolume/SpeedFilter.kt +++ b/app/src/main/java/com/example/speedvolume/SpeedFilter.kt @@ -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 */ @@ -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 } @@ -67,4 +67,13 @@ class SpeedFilter(private val maxAccelMs2: Float = 12f) { lastSampleMs = nowMs return smoothed } -} \ No newline at end of file + + /** Clears history when switching between GPS and motion-derived sources. */ + fun reset() { + window.clear() + smoothed = 0f + initialized = false + prevValidKmh = -1f + lastSampleMs = 0L + } +} diff --git a/app/src/main/java/com/example/speedvolume/SpeedVolumeService.kt b/app/src/main/java/com/example/speedvolume/SpeedVolumeService.kt index af049ca..4b43a54 100644 --- a/app/src/main/java/com/example/speedvolume/SpeedVolumeService.kt +++ b/app/src/main/java/com/example/speedvolume/SpeedVolumeService.kt @@ -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" @@ -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 @@ -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 } @@ -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 @@ -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)) { @@ -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() } @@ -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 @@ -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 @@ -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 @@ -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() @@ -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 -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/speedvolume/SpeedVolumeTile.kt b/app/src/main/java/com/example/speedvolume/SpeedVolumeTile.kt index 378e52e..416c46b 100644 --- a/app/src/main/java/com/example/speedvolume/SpeedVolumeTile.kt +++ b/app/src/main/java/com/example/speedvolume/SpeedVolumeTile.kt @@ -24,24 +24,33 @@ class SpeedVolumeTile : TileService() { override fun onClick() { if (ServiceState.running) { + getSharedPreferences(SpeedVolumeService.PREFS_NAME, MODE_PRIVATE) + .edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, false).apply() stopService(Intent(this, SpeedVolumeService::class.java)) return } val tile = qsTile val granted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - if (!granted) { - tile?.subtitle = getString(R.string.tile_permission_hint) + val backgroundGranted = android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q || + checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!granted || !backgroundGranted) { + setSubtitle(getString(R.string.tile_permission_hint)) tile?.updateTile() return } - tile?.subtitle = getString(R.string.tile_starting) + setSubtitle(getString(R.string.tile_starting)) tile?.updateTile() try { + getSharedPreferences(SpeedVolumeService.PREFS_NAME, MODE_PRIVATE) + .edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, true).apply() startForegroundService(Intent(this, SpeedVolumeService::class.java)) } catch (e: Exception) { Log.w("SpeedVolumeTile", "foreground start blocked: $e") - tile?.subtitle = getString(R.string.tile_permission_hint) + getSharedPreferences(SpeedVolumeService.PREFS_NAME, MODE_PRIVATE) + .edit().putBoolean(SpeedVolumeService.PREF_SERVICE_ENABLED, false).apply() + setSubtitle(getString(R.string.tile_permission_hint)) tile?.updateTile() } } @@ -53,7 +62,7 @@ class SpeedVolumeTile : TileService() { tile.icon = Icon.createWithResource( this, if (running) R.drawable.ic_speed_active else R.drawable.ic_speed_inactive ) - tile.subtitle = if (running) { + val subtitle = if (running) { val speed = if ((ServiceState.hasFix || ServiceState.speedKmh > 0f) && !ServiceState.speedKmh.isNaN() ) @@ -62,6 +71,13 @@ class SpeedVolumeTile : TileService() { } else { getString(R.string.tile_off) } + setSubtitle(subtitle) tile.updateTile() } -} \ No newline at end of file + + private fun setSubtitle(value: String) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + qsTile?.subtitle = value + } + } +} diff --git a/app/src/main/java/com/example/speedvolume/VolumeRamp.kt b/app/src/main/java/com/example/speedvolume/VolumeRamp.kt index d452e80..7d8470e 100644 --- a/app/src/main/java/com/example/speedvolume/VolumeRamp.kt +++ b/app/src/main/java/com/example/speedvolume/VolumeRamp.kt @@ -24,13 +24,21 @@ class VolumeRamp( private var running = false private var targetProvider: (() -> Int)? = null private var mismatches = 0 + private var lastReported = 0 private var onBlockedChanged: ((Boolean) -> Unit)? = null + private var onVolumeChanged: ((Int) -> Unit)? = null - fun start(targetProvider: () -> Int, onBlockedChanged: (Boolean) -> Unit) { + fun start( + targetProvider: () -> Int, + onBlockedChanged: (Boolean) -> Unit, + onVolumeChanged: (Int) -> Unit = {} + ) { if (running) return running = true this.targetProvider = targetProvider this.onBlockedChanged = onBlockedChanged + this.onVolumeChanged = onVolumeChanged + lastReported = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) handler.post(tick) } @@ -55,6 +63,10 @@ class VolumeRamp( } audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, next, 0) val after = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (after != lastReported) { + lastReported = after + this@VolumeRamp.onVolumeChanged?.invoke(after) + } if (after == current) { mismatches++ if (mismatches == 10) onBlockedChanged?.invoke(true) @@ -69,4 +81,4 @@ class VolumeRamp( handler.postDelayed(this, stepIntervalMs) } } -} \ 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 b9baf2e..4e0aaad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Stopped Running — volume follows speed Location permission denied + Could not start background tracking — open the app and check permissions GPS disabled — enable it for speed updates Current speed @@ -31,7 +32,7 @@ Standstill volume level %1$d - Volume scales linearly between the standstill level and max volume as your GPS speed climbs. + Volume follows a responsive curve between the standstill level and max volume as your speed climbs. Speed Volume service Speed Volume active @@ -45,4 +46,4 @@ Starting… Open app & allow location always Open app to start - \ No newline at end of file + diff --git a/app/src/test/java/com/example/speedvolume/SensorMotionTest.kt b/app/src/test/java/com/example/speedvolume/SensorMotionTest.kt new file mode 100644 index 0000000..ac0f416 --- /dev/null +++ b/app/src/test/java/com/example/speedvolume/SensorMotionTest.kt @@ -0,0 +1,26 @@ +package com.example.speedvolume + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SensorMotionTest { + + @Test + fun estimatesSpeedFromRecentCadence() { + val motion = SensorMotion() + for (step in 1..20) motion.onStep(step * 500L) + + val estimate = motion.speedEstimateKmh(10_000L) + + assertEquals(5.4f, estimate, 0.01f) + } + + @Test + fun stopsEstimatingAfterStepsGoStale() { + val motion = SensorMotion() + motion.onStep(1_000L) + + assertTrue(motion.speedEstimateKmh(31_001L).isNaN()) + } +} diff --git a/app/src/test/java/com/example/speedvolume/SpeedFilterTest.kt b/app/src/test/java/com/example/speedvolume/SpeedFilterTest.kt new file mode 100644 index 0000000..e08e5c9 --- /dev/null +++ b/app/src/test/java/com/example/speedvolume/SpeedFilterTest.kt @@ -0,0 +1,37 @@ +package com.example.speedvolume + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeedFilterTest { + + @Test + fun deadbandsStationaryGpsJitter() { + val filter = SpeedFilter() + + val result = filter.process(2.9f, Float.NaN, true, 1_000L) + + assertEquals(0f, result, 0.001f) + } + + @Test + fun fallsBackToDisplacementWhenGpsSpeedIsInvalid() { + val filter = SpeedFilter() + + val result = filter.process(Float.POSITIVE_INFINITY, 12f, false, 1_000L) + + assertEquals(12f, result, 0.001f) + } + + @Test + fun resetDropsHistoryBeforeASourceChange() { + val filter = SpeedFilter() + filter.process(60f, Float.NaN, true, 1_000L) + + filter.reset() + val result = filter.process(6f, Float.NaN, true, 2_000L) + + assertTrue(result in 5.9f..6.1f) + } +}