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;