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.
This commit is contained in:
2026-04-21 19:16:03 +02:00
parent cf5ac96fc3
commit a056618696
11 changed files with 106 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.44.0",
"version": "1.44.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -144,7 +144,7 @@ dependencies = [
[[package]]
name = "bocken"
version = "0.4.0"
version = "0.5.1"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "bocken"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
[lib]
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Step detector sensor (cadence during GPS workouts); runtime-requested on API 29+ -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@@ -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.
@@ -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
@@ -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<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == AndroidBridge.REQ_ACTIVITY_RECOGNITION &&
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
LocationForegroundService.instance?.onActivityRecognitionGranted()
}
}
}
+1 -1
View File
@@ -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"
+4
View File
@@ -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?' },
+9
View File
@@ -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,
@@ -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;