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;