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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -144,7 +144,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bocken"
|
name = "bocken"
|
||||||
version = "0.4.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bocken"
|
name = "bocken"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<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 -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import java.util.Locale
|
|||||||
|
|
||||||
class AndroidBridge(private val context: Context) {
|
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
|
@JavascriptInterface
|
||||||
fun startLocationService(ttsConfigJson: String, startPaused: Boolean) {
|
fun startLocationService(ttsConfigJson: String, startPaused: Boolean) {
|
||||||
if (context is Activity) {
|
if (context is Activity) {
|
||||||
@@ -31,7 +37,7 @@ class AndroidBridge(private val context: Context) {
|
|||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
context,
|
context,
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
1003
|
REQ_NOTIFICATIONS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +50,20 @@ class AndroidBridge(private val context: Context) {
|
|||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
context,
|
context,
|
||||||
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
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()
|
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.
|
* Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes.
|
||||||
* Why: default web Vibration API uses USAGE_TOUCH which Android silences.
|
* Why: default web Vibration API uses USAGE_TOUCH which Android silences.
|
||||||
|
|||||||
+24
-1
@@ -4,9 +4,11 @@ import android.app.Notification
|
|||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.Manifest
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
@@ -24,6 +26,7 @@ import android.os.Looper
|
|||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
@@ -696,8 +699,22 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor
|
|||||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
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() {
|
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)
|
stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
|
||||||
if (stepDetector != null) {
|
if (stepDetector != null) {
|
||||||
sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST)
|
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")
|
@Suppress("MissingPermission")
|
||||||
private fun startLocationUpdates() {
|
private fun startLocationUpdates() {
|
||||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.bocken.app
|
package org.bocken.app
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -13,4 +14,17 @@ class MainActivity : TauriActivity() {
|
|||||||
override fun onWebViewCreate(webView: WebView) {
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge")
|
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"productName": "Bocken",
|
"productName": "Bocken",
|
||||||
"identifier": "org.bocken.app",
|
"identifier": "org.bocken.app",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://192.168.1.4:5173",
|
"devUrl": "http://192.168.1.4:5173",
|
||||||
"frontendDist": "https://bocken.org"
|
"frontendDist": "https://bocken.org"
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ const translations: Translations = {
|
|||||||
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
||||||
cadence: { en: 'Cadence', de: 'Kadenz' },
|
cadence: { en: 'Cadence', de: 'Kadenz' },
|
||||||
cadence_unit: { en: 'spm', de: 'spm' },
|
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' },
|
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
||||||
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
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?' },
|
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface AndroidBridge {
|
|||||||
pauseTracking(): void;
|
pauseTracking(): void;
|
||||||
resumeTracking(): void;
|
resumeTracking(): void;
|
||||||
getIntervalState(): string;
|
getIntervalState(): string;
|
||||||
|
hasActivityRecognitionPermission?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkTauri(): 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 {
|
return {
|
||||||
get track() { return track; },
|
get track() { return track; },
|
||||||
get isTracking() { return isTracking; },
|
get isTracking() { return isTracking; },
|
||||||
@@ -304,6 +312,7 @@ export function createGpsTracker() {
|
|||||||
get available() { return checkTauri(); },
|
get available() { return checkTauri(); },
|
||||||
get debug() { return _debugMsg; },
|
get debug() { return _debugMsg; },
|
||||||
get intervalState() { return _intervalState; },
|
get intervalState() { return _intervalState; },
|
||||||
|
cadenceAvailable,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
reset,
|
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 { 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 { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||||
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
const sl = $derived(fitnessSlugs(lang));
|
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);
|
let gpsToggling = $state(false);
|
||||||
async function toggleGps() {
|
async function toggleGps() {
|
||||||
if (gpsToggling) return;
|
if (gpsToggling) return;
|
||||||
@@ -369,6 +384,7 @@
|
|||||||
useGps = true;
|
useGps = true;
|
||||||
} else {
|
} else {
|
||||||
useGps = await gps.start(getVoiceGuidanceConfig());
|
useGps = await gps.start(getVoiceGuidanceConfig());
|
||||||
|
if (useGps) maybeWarnCadence();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await gps.stop();
|
await gps.stop();
|
||||||
@@ -471,6 +487,7 @@
|
|||||||
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
|
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
|
||||||
_prestartGps = true;
|
_prestartGps = true;
|
||||||
gps.start(undefined, true);
|
gps.start(undefined, true);
|
||||||
|
maybeWarnCadence();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,6 +527,7 @@
|
|||||||
const exercise = getExerciseById(exerciseId);
|
const exercise = getExerciseById(exerciseId);
|
||||||
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
|
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
|
||||||
useGps = await gps.start(getVoiceGuidanceConfig());
|
useGps = await gps.start(getVoiceGuidanceConfig());
|
||||||
|
if (useGps) maybeWarnCadence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +547,7 @@
|
|||||||
gpsStarted = true;
|
gpsStarted = true;
|
||||||
useGps = true;
|
useGps = true;
|
||||||
workout.resumeTimer();
|
workout.resumeTimer();
|
||||||
|
maybeWarnCadence();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
gpsStarting = false;
|
gpsStarting = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user