From a056618696332b8e8a01546203d1bf6f112e208d Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 21 Apr 2026 19:16:03 +0200 Subject: [PATCH] fix(fitness): request ACTIVITY_RECOGNITION for cadence Android step detector silently returns no events on API 29+ when ACTIVITY_RECOGNITION is ungranted, so cadence was always absent from recorded tracks. Declare the permission, request it at GPS start, guard sensor registration and retry it from MainActivity.onRequestPermissionsResult when the user grants mid-session, and toast a hint if they deny. --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- .../android/app/src/main/AndroidManifest.xml | 2 ++ .../main/java/org/bocken/app/AndroidBridge.kt | 32 +++++++++++++++++-- .../bocken/app/LocationForegroundService.kt | 25 ++++++++++++++- .../main/java/org/bocken/app/MainActivity.kt | 14 ++++++++ src-tauri/tauri.conf.json | 2 +- src/lib/js/fitnessI18n.ts | 4 +++ src/lib/js/gps.svelte.ts | 9 ++++++ .../[active=fitnessActive]/+page.svelte | 19 +++++++++++ 11 files changed, 106 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 8c052c69..5585ab5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.44.0", + "version": "1.44.1", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d2dc460..52690d17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "bocken" -version = "0.4.0" +version = "0.5.1" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d6fe2b4a..d3770a19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bocken" -version = "0.5.0" +version = "0.5.1" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index a8c23331..cac61391 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt index bad7c1eb..6cb6e8fa 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt @@ -20,6 +20,12 @@ import java.util.Locale class AndroidBridge(private val context: Context) { + companion object { + const val REQ_BACKGROUND_LOCATION = 1002 + const val REQ_NOTIFICATIONS = 1003 + const val REQ_ACTIVITY_RECOGNITION = 1004 + } + @JavascriptInterface fun startLocationService(ttsConfigJson: String, startPaused: Boolean) { if (context is Activity) { @@ -31,7 +37,7 @@ class AndroidBridge(private val context: Context) { ActivityCompat.requestPermissions( context, arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1003 + REQ_NOTIFICATIONS ) } } @@ -44,7 +50,20 @@ class AndroidBridge(private val context: Context) { ActivityCompat.requestPermissions( context, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), - 1002 + REQ_BACKGROUND_LOCATION + ) + } + } + + // Request activity recognition on Android 10+ (required for step detector / cadence) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + context, + arrayOf(Manifest.permission.ACTIVITY_RECOGNITION), + REQ_ACTIVITY_RECOGNITION ) } } @@ -104,6 +123,15 @@ class AndroidBridge(private val context: Context) { return LocationForegroundService.getIntervalState() } + /** True if cadence (step detector) is usable — permission granted or not required (pre-Q). */ + @JavascriptInterface + fun hasActivityRecognitionPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true + return ContextCompat.checkSelfPermission( + context, Manifest.permission.ACTIVITY_RECOGNITION + ) == PackageManager.PERMISSION_GRANTED + } + /** * Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes. * Why: default web Vibration API uses USAGE_TOUCH which Android silences. diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt index d9b817b1..b1eb7355 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt @@ -4,9 +4,11 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.Manifest import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -24,6 +26,7 @@ import android.os.Looper import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.util.Log +import androidx.core.content.ContextCompat import org.json.JSONArray import org.json.JSONObject import java.util.Collections @@ -696,8 +699,22 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor notificationManager?.notify(NOTIFICATION_ID, notification) } + private fun hasActivityRecognitionPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true + return ContextCompat.checkSelfPermission( + this, Manifest.permission.ACTIVITY_RECOGNITION + ) == PackageManager.PERMISSION_GRANTED + } + private fun startStepDetector() { - sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager + if (!hasActivityRecognitionPermission()) { + Log.d(TAG, "Step detector skipped — ACTIVITY_RECOGNITION not granted") + return + } + if (stepDetector != null) return // already registered + if (sensorManager == null) { + sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager + } stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) if (stepDetector != null) { sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST) @@ -707,6 +724,12 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor } } + /** Called from MainActivity when ACTIVITY_RECOGNITION is granted mid-session. */ + fun onActivityRecognitionGranted() { + Log.d(TAG, "ACTIVITY_RECOGNITION granted — retrying step detector registration") + startStepDetector() + } + @Suppress("MissingPermission") private fun startLocationUpdates() { locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/MainActivity.kt index 4d654a27..98b7092b 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/MainActivity.kt @@ -1,5 +1,6 @@ package org.bocken.app +import android.content.pm.PackageManager import android.os.Bundle import android.webkit.WebView import androidx.activity.enableEdgeToEdge @@ -13,4 +14,17 @@ class MainActivity : TauriActivity() { override fun onWebViewCreate(webView: WebView) { webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge") } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == AndroidBridge.REQ_ACTIVITY_RECOGNITION && + grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LocationForegroundService.instance?.onActivityRecognitionGranted() + } + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 10dacb60..8e2b0cbd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Bocken", "identifier": "org.bocken.app", - "version": "0.5.0", + "version": "0.5.1", "build": { "devUrl": "http://192.168.1.4:5173", "frontendDist": "https://bocken.org" diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index c16fdddb..23032c03 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -111,6 +111,10 @@ const translations: Translations = { elevation_loss: { en: 'Loss', de: 'Abstieg' }, cadence: { en: 'Cadence', de: 'Kadenz' }, cadence_unit: { en: 'spm', de: 'spm' }, + cadence_permission_missing: { + en: 'Cadence disabled — grant Activity Recognition in system settings', + de: 'Kadenz deaktiviert — Aktivitätserkennung in den Einstellungen erlauben' + }, personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' }, delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' }, remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' }, diff --git a/src/lib/js/gps.svelte.ts b/src/lib/js/gps.svelte.ts index 9f3c57e6..d5adeb01 100644 --- a/src/lib/js/gps.svelte.ts +++ b/src/lib/js/gps.svelte.ts @@ -79,6 +79,7 @@ interface AndroidBridge { pauseTracking(): void; resumeTracking(): void; getIntervalState(): string; + hasActivityRecognitionPermission?: () => boolean; } function checkTauri(): boolean { @@ -294,6 +295,13 @@ export function createGpsTracker() { } } + function cadenceAvailable(): boolean { + const bridge = getAndroidBridge(); + // No bridge (e.g. browser) or older build lacking the check: assume ok, don't nag. + if (!bridge || typeof bridge.hasActivityRecognitionPermission !== 'function') return true; + try { return bridge.hasActivityRecognitionPermission(); } catch { return true; } + } + return { get track() { return track; }, get isTracking() { return isTracking; }, @@ -304,6 +312,7 @@ export function createGpsTracker() { get available() { return checkTauri(); }, get debug() { return _debugMsg; }, get intervalState() { return _intervalState; }, + cadenceAvailable, start, stop, reset, diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index 18ea7145..5ccb0fec 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -4,6 +4,7 @@ import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical, Repeat } from '@lucide/svelte'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { confirm } from '$lib/js/confirmDialog.svelte'; + import { toast } from '$lib/js/toast.svelte'; const lang = $derived(detectFitnessLang($page.url.pathname)); const sl = $derived(fitnessSlugs(lang)); @@ -359,6 +360,20 @@ } } + let _cadenceWarned = false; + /** After the user resolves the permission dialog, check if cadence will work. + * We wait a few seconds to let the system dialog settle before toasting. */ + function maybeWarnCadence() { + if (_cadenceWarned) return; + setTimeout(() => { + if (_cadenceWarned) return; + if (!gps.cadenceAvailable()) { + _cadenceWarned = true; + toast.info(t('cadence_permission_missing', lang)); + } + }, 4000); + } + let gpsToggling = $state(false); async function toggleGps() { if (gpsToggling) return; @@ -369,6 +384,7 @@ useGps = true; } else { useGps = await gps.start(getVoiceGuidanceConfig()); + if (useGps) maybeWarnCadence(); } } else { await gps.stop(); @@ -471,6 +487,7 @@ if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) { _prestartGps = true; gps.start(undefined, true); + maybeWarnCadence(); } }); @@ -510,6 +527,7 @@ const exercise = getExerciseById(exerciseId); if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) { useGps = await gps.start(getVoiceGuidanceConfig()); + if (useGps) maybeWarnCadence(); } } @@ -529,6 +547,7 @@ gpsStarted = true; useGps = true; workout.resumeTimer(); + maybeWarnCadence(); } } finally { gpsStarting = false;