Compare commits

..

41 Commits

Author SHA1 Message Date
karol 63e175c76a chore: rename applicationId and app name to isolate fork from official app
CI Pipeline / Android Lint (push) Successful in 1m34s
CI Pipeline / Unit Tests (push) Successful in 1m10s
CI Pipeline / Build and Upload APK (push) Successful in 2m24s
2026-09-18 20:31:23 +02:00
karol 8142073abd chore: trigger Gitea Actions
CI Pipeline / Android Lint (push) Successful in 2m4s
CI Pipeline / Unit Tests (push) Successful in 1m8s
CI Pipeline / Build and Upload APK (push) Successful in 2m30s
2026-09-18 19:38:36 +02:00
karol 149c620525 ci: use PERSONAL_ACCESS_TOKEN for auto-update to trigger downstream pipelines 2026-09-18 18:57:04 +02:00
karol c96b0e3617 ci: add scheduled workflow to auto-sync upstream changes 2026-09-18 18:55:15 +02:00
karol 268c27dc4a build: output both signed release and debug APKs 2026-09-18 18:52:19 +02:00
karol 3146785498 ci: use dynamic run_number for release tags to avoid conflicts 2026-09-18 18:43:02 +02:00
karol 11589dfdd3 ci: use native gitea-release-action for uploading apks 2026-09-18 18:36:11 +02:00
karol 96828febdb ci: fix apk artifacts path for release action 2026-09-18 14:38:57 +02:00
karol 98ae6fe0d2 ci: merge workflows into a single pipeline with proper job dependencies 2026-09-11 16:30:13 +02:00
karol 2e27b06c01 fix(lint): add missing default translations to fix CI 2026-09-11 16:25:38 +02:00
karol 8db8b58020 trigger ci for ncipollo release 2026-09-11 16:09:08 +02:00
karol 9a9e1e6947 ci: swap release action to ncipollo for gitea compatibility 2026-09-11 16:06:43 +02:00
karol d9880f259d trigger ci for release test 2026-09-11 16:00:04 +02:00
karol 08b5ab381e ci: grant write permissions for release upload 2026-09-11 15:21:25 +02:00
karol a63298ef6e ci: use -node image variant to fix act runner checkout issue, update actions version 2026-09-11 15:08:38 +02:00
karol 1294e944a7 ci: use cimg/android:2024.04 image for runner compatibility 2026-09-11 15:00:00 +02:00
karol 7258335180 ci: fix runner labels to match homelab act_runner (ubuntu-latest) 2026-09-11 14:52:17 +02:00
karol 139d72affe ci: fix runner labels to match homelab act_runner (ubuntu-latest) 2026-09-11 14:50:37 +02:00
karol 3791ccf888 trigger ci 2026-09-11 14:43:34 +02:00
karol dc78615c83 ci: add auto-build and release workflow 2026-09-11 14:40:08 +02:00
karol 53dfa915fb Merge upstream updates into local fork 2026-09-11 14:10:42 +02:00
karol 7c6141f98c feat: prioritize Bluetooth sensor data over GPS
- Restructured sensor logic to give absolute priority to Bluetooth sensors, largely ignoring GPS data for distance and speed calculations.
- Implemented downstream UI expansions, custom recording layouts, and new settings to reflect and control the new sensor-first flow.
- Added MediaNotificationListenerService and integrated media playback tracking within statistics.
- Modified VoiceAnnouncementUtils to harmonize announcements with the updated architecture.
- Added 'About This Fork' section to the README.

Note: These modifications were co-created with Antigravity AI.
2026-09-11 14:10:42 +02:00
gallegonovato f8279c7095 Translated using Weblate (Spanish)
Currently translated at 100.0% (551 of 551 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/es/
2026-09-05 10:26:13 +00:00
Kito 465e3e9be8 Translated using Weblate (Indonesian)
Currently translated at 3.6% (7 of 191 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/id/
2026-09-05 10:26:13 +00:00
gallegonovato f38999a970 Translated using Weblate (Spanish)
Currently translated at 23.5% (45 of 191 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/es/
2026-09-05 10:26:13 +00:00
Dennis Guse 4dfd2485bf Bugfix: avoid crash in exporter. 2026-09-03 17:50:47 +02:00
Dennis Guse 10273cd772 TrackRecordingManager: rather crash than try to load invalid data. 2026-09-03 17:50:47 +02:00
Bone NI fbfe6f3ab8 Added translation using Weblate (Lao) 2026-09-02 09:54:56 +00:00
love80312 86726c5e0e Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (551 of 551 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/zh_Hant/
2026-09-02 09:54:55 +00:00
love80312 b711c4fdc5 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 1.5% (3 of 190 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/zh_Hant/
2026-09-02 09:54:55 +00:00
CI/CD Bot by pstorch cefdb1280a chore(deps): update dependency com.android.tools.build:gradle to v9.4.0 2026-09-01 14:35:00 +00:00
Dennis Guse 91b379c328 Release: v4.28.3 2026-08-31 20:07:23 +02:00
Dennis Guse e28a84fc88 Allow to disable segment creation (max recording distance).
Fixes #2389.
2026-08-28 07:07:06 +02:00
Dennis Guse c2d8d06204 Merge remote-tracking branch 'weblate/main' 2026-08-28 06:47:06 +02:00
José Rebelo 7ca871c699 Bugfix: Respect declared namespace prefixes 2026-08-26 15:21:06 +01:00
Dennis Guse 337c50eb91 Cleanup: qName. 2026-08-26 07:34:22 +02:00
Dany Mestas 2ff8d21897 GPX: match activity types case-insensitively on import. 2026-08-25 22:23:57 +02:00
Dennis Guse 2d4e987812 GPX: export moving speed in summary. 2026-08-24 17:26:11 +02:00
CI/CD Bot by pstorch fb928ccc45 chore(deps): update dependency com.android.tools.build:gradle to v9.3.2 2026-08-24 03:34:20 +00:00
Priit Jõerüüt 25a2f978c4 Translated using Weblate (Estonian)
Currently translated at 100.0% (554 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/et/
2026-07-29 03:59:43 +00:00
jimkats 36c3885dce Translated using Weblate (Greek)
Currently translated at 70.3% (390 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/el/
2026-07-29 03:59:43 +00:00
110 changed files with 5466 additions and 323 deletions
-36
View File
@@ -1,36 +0,0 @@
name: Android Test
on:
push:
branches:
- main
pull_request:
jobs:
lint:
name: Android Lint
runs-on: docker
container:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Lint
shell: bash
run: ./gradlew lint
android-check:
runs-on: docker
container:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Test
shell: bash
run: ./gradlew testNightlyDebugUnitTest --stacktrace
+56
View File
@@ -0,0 +1,56 @@
name: Auto-Sync Upstream
on:
schedule:
- cron: '0 3 * * *' # Uruchamia się codziennie o 3:00 w nocy
workflow_dispatch: # Pozwala uruchomić to ręcznie z poziomu Gitea
permissions:
contents: write
jobs:
sync:
name: Sync with Upstream
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Configure Git & Fetch
run: |
git config user.name "Gitea Actions Bot"
git config user.email "actions@gitea.local"
git remote add upstream https://gitea.local.labus.uk/upstream-mirrors/OpenTracks.git
git fetch upstream main
- name: Check for updates & Attempt Merge
id: merge
run: |
# Sprawdzamy czy są jakiekolwiek nowe zmiany
if git diff --quiet HEAD upstream/main; then
echo "Repozytorium jest aktualne. Brak zmian."
echo "has_changes=false" >> $GITHUB_ENV
exit 0
fi
echo "Znaleziono nowe zmiany. Próbuję scalić..."
echo "has_changes=true" >> $GITHUB_ENV
# Próba automatycznego scalenia (merge)
if git merge upstream/main --no-edit; then
echo "Scalenie przebiegło pomyślnie!"
else
echo "Wykryto konflikty! Przerywam scalanie."
git merge --abort
exit 1 # Błąd celowy - Gitea wyśle Ci powiadomienie o failu
fi
- name: Push changes
if: env.has_changes == 'true'
run: |
git push origin main
echo "Zmiany pomyślnie wypchnięte do repozytorium!"
+78
View File
@@ -0,0 +1,78 @@
name: CI Pipeline
on:
push:
branches:
- main
pull_request:
permissions:
contents: write
jobs:
lint:
name: Android Lint
runs-on: ubuntu-latest
container:
image: 'cimg/android:2024.04-node'
steps:
- name: Checkout sources
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Lint
shell: bash
run: ./gradlew lint
android-check:
name: Unit Tests
runs-on: ubuntu-latest
container:
image: 'cimg/android:2024.04-node'
steps:
- name: Checkout sources
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Test
shell: bash
run: ./gradlew testNightlyDebugUnitTest --stacktrace
release:
name: Build and Upload APK
needs: [lint, android-check]
if: github.event_name == 'push'
runs-on: ubuntu-latest
container:
image: 'cimg/android:2024.04-node'
steps:
- name: Checkout sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build APKs
shell: bash
run: |
./gradlew assembleDebug
./gradlew assembleRelease
- name: Prepare APKs
shell: bash
run: |
mkdir -p release-apks
find . -name "*.apk" -print -exec cp {} release-apks/ \;
ls -la release-apks/
- name: Upload to Gitea Releases
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: build-${{ github.run_number }}
name: "Build #${{ github.run_number }}"
prerelease: true
files: |-
release-apks/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="OpenTracks">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+12
View File
@@ -45,6 +45,18 @@ Translations are hosted on <a href="https://translate.codeberg.org/engage/opentr
<img src="https://translate.codeberg.org/widget/opentracks/opentracks-src-main-res-values-strings-xml-main/horizontal-auto.svg" alt="Translation status" />
</a>
## ⚠️ About This Fork
This repository is a customized fork of the official OpenTracks application.
**Core Purpose:**
The primary goal of this fork is to fundamentally change how Bluetooth sensors are prioritized over GPS data. In this modified version, Bluetooth sensor data is given absolute priority and actively ignores/overrides GPS signals in most scenarios. This ensures that cadence, speed, and distance measurements strictly rely on hardware sensors rather than falling back to or being averaged with GPS data.
**Consequential Changes:**
As a direct result of these sensor priority changes, several downstream modifications were implemented to support the new data flow:
* **UI & Settings:** Expanded settings menus and customized recording layouts to better expose the raw sensor data and give the user finer control.
* **Media & Voice Announcements:** Integrated media playback controls directly into the statistics view and overhauled voice announcements to handle audio focus and integrate with the new sensor-first approach.
## Screenshots
<div>
+10 -9
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.3.1'
classpath 'com.android.tools.build:gradle:9.4.0'
}
}
@@ -46,9 +46,9 @@ android {
}
defaultConfig {
applicationId 'de.dennisguse.opentracks'
versionCode 6741
versionName "v4.28.2"
applicationId 'de.dennisguse.opentracks.fork'
versionCode 6751
versionName "v4.28.3"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
@@ -98,6 +98,7 @@ android {
release {
crunchPngs = false
minifyEnabled = false
signingConfig signingConfigs.debug
}
}
@@ -106,18 +107,18 @@ android {
productFlavors {
nightly {
dimension 'version'
applicationId 'de.dennisguse.opentracks.nightly'
applicationId 'de.dennisguse.opentracks.fork.nightly'
versionCode getVersionCodeNightly()
versionName getVersionName()
base.archivesName = "de.dennisguse.opentracks.nightly_${getVersionCodeNightly()}"
base.archivesName = "de.dennisguse.opentracks.fork.nightly_${getVersionCodeNightly()}"
signingConfig = signingConfigs.nightly
}
irreproducible {
// Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/
dimension 'version'
applicationId 'de.dennisguse.opentracks'
applicationId 'de.dennisguse.opentracks.fork'
versionNameSuffix 'irreproducible'
}
reproducible {
@@ -125,9 +126,9 @@ android {
// FDroid: https://f-droid.org/de/packages/de.dennisguse.opentracks.playstore
// PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore
dimension 'version'
applicationId 'de.dennisguse.opentracks.playstore'
applicationId 'de.dennisguse.opentracks.fork.playstore'
base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
base.archivesName = "de.dennisguse.opentracks.fork.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
signingConfig = signingConfigs.release
}
View File
@@ -0,0 +1,10 @@
v4.28.3: OpenTracks
Changes:
- Settings: allow to disable segment creation (max recording distance)
- Importer: match activity type case-insensitive
- GPX: export moving speed in summary
Bugfix:
- GPX: importer must respect namespace prefixes
@@ -1,15 +1,15 @@
v3.0.0: OpenTracks
Primer lanzamiento de OpenTracks (es decir, una bifurcación de MyTracks)
Primera versión de OpenTracks (es decir, una bifurcación dura de MyTracks)
Cambios:
- Cambio de nombre de MyTracks a OpenTracks
- Se agregó un sensor de frecuencia cardíaca Bluetooth LE
- Se eliminaron todas las funciones que requieren acceso a Internet.
- Se eliminó la integración de Google Maps
- Se eliminó el uso de los servicios de Google Play.
- Se eliminaron los sensores ANT+
- Se eliminaron los sensores clásicos de Bluetooth.
- Se eliminó Protobuf
- Se eliminaron la estimación de calorías y la estimación de actividad.
- Se eliminó la API del servicio MyTracks (es decir, no hay integración con otras aplicaciones)
- Se ha añadido el sensor de frecuencia cardíaca Bluetooth LE
- Se han eliminado todas las funciones que requerían acceso a Internet
- Se ha eliminado la integración con Google Maps
- Se ha eliminado el uso de los servicios de Google Play
- Se han eliminado los sensores ANT+
- Se han eliminado los sensores Bluetooth clásicos
- Se ha eliminado Protobuf
- Se han eliminado la estimación de calorías y la estimación de actividad
- Se ha eliminado la API del servicio MyTracks (es decir, no hay integración con otras aplicaciones)
@@ -13,5 +13,5 @@ Correcciones de errores:
- Se ha corregido la exportación a KML/KMZ
Desarrollador:
- Las pruebas son ejecutables (pero muchas fallan)
- Las pruebas se pueden ejecutar (aunque muchas fallan)
- Se ha habilitado la integración continua con TravisCI (sin pruebas)
@@ -1,14 +1,14 @@
v3.1.0: OpenTracks
Cambios:
- Se agregó la capacidad de mostrar rutas en la aplicación de mapas (probado con OsmAnd y Maps.ME).
- Se ha añadido la posibilidad de visualizar las estadísticas de seguimiento sin desbloquear el dispositivo durante el mismo.
- Se ha eliminado el modo horizontal de la vista de detalles de la pista (debido a un error).
- El historial de sugerencias de búsqueda ha sido eliminado.
- Se ha añadido la opción de mostrar el recorrido en aplicaciones de mapas (probado con OsmAnd y Maps.ME)
- Se ha añadido la opción de mostrar las estadísticas del recorrido sin desbloquear el dispositivo mientras se realiza el seguimiento
- Se ha eliminado el modo horizontal para la vista detallada del recorrido (presentaba errores)
- Se ha eliminado el historial de sugerencias de búsqueda
Corrección de errores:
- Solicitar permisos de almacenamiento para todas las importaciones/todas las exportaciones
- Se solicita permiso de almacenamiento para «Importar todo»/«Exportar todo»
Desarrolladores:
- Actualizar a Gradle 5.6
- No minimice la versión de lanzamiento
Desarrollador:
- Actualización a Gradle 5.6
- No se minimizan las compilaciones de lanzamiento
@@ -0,0 +1,7 @@
v3.2.1: OpenTracks
Cambios:
- Los ajustes ahora están en una sola página
Desarrollo:
-Los ajustes se migraron a Androidx compat (usa fragments)
@@ -0,0 +1,13 @@
v3.2.2: OpenTracks
Cambios:
- Se eliminaron las estadísticas agregadas
- Migrado a Material Design
Corrección de errores:
- Se corrigió la exportación de datos de sensores
- Se corrigió la importación de datos de sensores incompletos
Desarrollador:
- Se reemplazó la barra de acción (actionbar) por la barra de herramientas (toolbar)
- Migrado a androidx appcompat (incl. Material Design)
@@ -0,0 +1,14 @@
v3.2.3: OpenTracks
Cambios:
- Compartir: usar Android Sharesheet
- Compartir: compartir múltiples pistas como archivos individuales
- Usar Android ShareSheet
- Sensor de frecuencia cardíaca (Bluetooth LE): reconexión automática
Corrección de errores:
- Sensor de frecuencia cardíaca (Bluetooth LE): desconexión correcta
- La frecuencia cardíaca no siempre se mostraba durante la grabación
Desarrollador:
- Se habilitaron las pruebas de instrumentación en TravisCI
@@ -0,0 +1,5 @@
v3.2.4: OpenTracks
Corrección de errores:
- Caída/cierre inesperado (crash) en la actividad de ajustes
Se eliminó la posibilidad de cambiar la frecuencia de voz/fracción desde el menú contextual
@@ -0,0 +1,15 @@
v3.2.5: OpenTracks
Cambios:
- Deshabilitar el cambio de actividad
- Añadir permiso para Android 10: grabación de datos de ubicación
Corrección de errores:
- Corregir el restablecimiento de los ajustes
Desarrollador:
- Preferencias
- si no se pueden cargar los Ajustes, restablecer todas las preferencias
- se movió el acceso a las preferencias a PreferencesUtil
- se movieron los valores por defecto de las preferencias del código a XML
- usar SummaryProvider
@@ -0,0 +1,8 @@
v3.2.6: OpenTracks
Cambios:
- Monitor de frecuencia cardíaca: escanear dispositivos disponibles
- Eliminar acceso directo a los ajustes de Bluetooth
Desarrollador:
- Preferencias: se movieron los valores por defecto de las preferencias del código a XML
@@ -0,0 +1,10 @@
v3.2.7: OpenTracks
Cambios:
- La notificación muestra la precisión del GPS
- Mostrar notificación emergente (heads-up) si la precisión del GPS cae por debajo del umbral
- Mostrar siempre el gráfico tanto por tiempo como por distancia
- Se añadió ayuda (en Ajustes)
Corrección de errores:
- TrackDetailActivity: se deshabilitó el modo horizontal que no funcionaba (temporal)
@@ -0,0 +1,10 @@
v3.2.8: OpenTracks
Cambios:
- Gráfico: zoom mediante gestos
Corrección de errores:
- La exportación a través de KMZ no exportaba correctamente los nombres de archivo de las imágenes
- Compartir múltiples pistas no incluía los archivos KMZ
- No se mostraba la interfaz de usuario de MarkerDetailActivity
- Los ajustes se cerraban inesperadamente si la preferencia de frecuencia cardíaca no estaba inicializada
@@ -0,0 +1,10 @@
v3.2.9: OpenTracks
Cambios:
- Opción de mantener la pantalla encendida durante la grabación
- Marcadores: se eliminaron por completo los marcadores de división (split markers)
- Los anuncios de voz ahora funcionan sin el permiso READ_PHONE_STATE
Corrección de errores:
- Estadísticas: no se mostraba la frecuencia cardíaca
- Estadísticas: el tiempo se restablecía brevemente a 0s
@@ -0,0 +1,11 @@
v3.2.10: OpenTracks
Cambios:
- ChartView ya no es configurable
- Después de reiniciar: se eliminó el reinicio automático de una grabación incompleta
Corrección de errores:
- Los anuncios de voz usan MAY_DUCK (de nuevo)
- ChartView muestra marcadores (de nuevo)
- Se corrigió el diseño de grabación en horizontal (landscape recordingLayout) de TrackListActivity
- Se solicita el comportamiento heredado de la API de archivos (necesario para Android 10)
@@ -0,0 +1,12 @@
v3.2.11: OpenTracks
Error:
- La velocidad máxima es idéntica a la velocidad promedio
Corrección de errores:
- Cierre inesperado (crash) al seleccionar el sensor de frecuencia cardíaca
Desarrollador:
- Se reemplazó TabHost por TabsLayout
- Se eliminó el primer punto de ruta (waypoint) especial
- Se eliminaron las traducciones duplicadas
@@ -0,0 +1,13 @@
v3.3.0: OpenTracks
Cambios importantes:
- Fotos en almacenamiento privado (ocultas para otras apps)
NOTA: No se verán fotos de versiones anteriores
- Español (Latinoamérica) requiere Android 7.0 o superior
Cambios:
- Importar/exportar vía Storage Access Framework (sin permisos de almacenamiento)
Desarrollador:
- Limpieza de estructuras de datos internas
- Icono/nombre distintos para versión debug
@@ -0,0 +1,11 @@
v3.3.1: OpenTracks
Cambios:
- Android 10: permite conservar datos al desinstalar
- API Dashboard: icono de mapa para abrir la pista en app externa (ej. OSMDashboard)
Corrección de errores:
- Se corrigió el nombre de la app (roto en v3.3.0)
Desarrollador:
- Documentación de la API Dashboard en README_DEVELOPER.md
@@ -0,0 +1,11 @@
v3.3.2: OpenTracks
Cambios:
- Exportación: ahora sobrescribe los archivos
Corrección de errores:
- Se elimina siempre la notificación de grabación
Desarrollador:
- En API 26+ se usa java.time para analizar y escribir fechas
- Refactorización de TrackRecordingService
@@ -0,0 +1,12 @@
v3.4.0: OpenTracks
Cambios:
- API Dashboard: múltiples pistas
- Ayuda actualizada
Corrección de errores:
- Se corrigió el cierre inesperado al recrear fragmentos
- En pausa: evita que la app se detenga
Desarrollador:
- Pruebas habilitadas vía CI (Github Actions y TravisCI)
@@ -0,0 +1,14 @@
v3.5.0: OpenTracks
Cambios:
- Marcador: seleccionar imágenes de la galería
- Marcador: compartir imagen
- Utilizar el nombre de la ruta como nombre de archivo al compartir
- Se han eliminado los valores mínimos y máximos de altitud (eran imprecisos)
Corrección de errores:
- Los gráficos utilizan las unidades y la escala preferidas
Desarrollador:
- Base de datos: eliminar columnas en desuso
- Base de datos: añadir índices
@@ -0,0 +1,10 @@
v3.6.0: OpenTracks
Cambios:
- Ahora se pueden reanudar las rutas que se habían detenido
Corrección de errores:
- La importación de archivos KMZ no procesaba correctamente la URL de las fotos
Desarrollador:
- Se ha simplificado el procesamiento de datos
@@ -0,0 +1,4 @@
v3.6.1: OpenTracks
Corrección de errores:
- Importación de KMZ: guardar la ruta correcta de la imagen
@@ -0,0 +1,4 @@
v3.6.2: OpenTracks
Corrección de errores:
- Los gráficos no se mostraban correctamente (problema introducido en la v3.6.0)
@@ -0,0 +1,13 @@
v3.7.0: OpenTracks
Cambios:
- Informe de velocidad/ritmo por tipo de actividad
- Se ha añadido una solución provisional para el cambio de semana del GPS
- (Experimental) Compatibilidad con sensores de ciclismo Bluetooth LE (cadencia y velocidad)
- Se ha mejorado la importación de KMZ/KML
Corrección de errores:
- Importación de KMZ: solo se importan las imágenes utilizadas
Desarrollador:
- Se ha refactorizado TrackRecordingService
@@ -0,0 +1,7 @@
v3.7.1: OpenTracks
Solución provisional:
- Ocultar el botón de inicio del GPS
Corrección de errores:
- Se ha corregido un puntero nulo
@@ -0,0 +1,6 @@
v3.7.2: OpenTracks
Corrección de errores:
- Se ha vuelto a añadir el botón «Iniciar GPS»
- El nombre de la ruta ya no se sobrescribe al final de una grabación
- Marcadores: al eliminar una foto, también se borra el archivo
@@ -0,0 +1,7 @@
v3.7.3: OpenTracks
Corrección de errores:
- Se ha corregido el cálculo de la cadencia y la velocidad (mediante el sensor Bluetooth LE)
Desarrollador:
- Se han eliminado las traducciones que no se utilizaban
@@ -0,0 +1,12 @@
v3.7.4: OpenTracks
Cambios:
- Formato de compartir/exportar configurable
Corrección de errores:
- El diámetro de la rueda no se almacenaba correctamente (para el sensor de velocidad)
- Ayuda: los enlaces son clicables
Desarrollador:
- Se ha dividido TrackDetailActivity
- El sucesor de StatsFragment utiliza ConstraintLayout
@@ -0,0 +1,11 @@
v3.7.5: OpenTracks
Cambios:
- Se ha añadido un resumen de estadísticas agregadas (desde el menú de la pantalla principal)
- API del panel de control: se pasan los ajustes «mostrar en la pantalla de bloqueo» y «mantener la pantalla encendida»
- Se han actualizado las traducciones
- Importación: se ha flexibilizado el formato de fecha ISO 8601
- Mostrar ruta en el mapa: se puede seleccionar el formato de archivo y la apertura del panel de control funciona en Android R (API 30+)
Corrección de errores:
- El icono de la aplicación ahora se muestra correctamente
@@ -0,0 +1,11 @@
v3.7.6: OpenTracks
Cambios:
- Modo horizontal
- La interfaz de usuario para borrar rutas ahora es solo un indicador giratorio
- La velocidad se exporta e importa (GPX/KML/KMZ)
- El formato de exportación predeterminado es KMZ, incluyendo todos los datos y las imágenes
Desarrollador:
- Se han sustituido las AsyncTasks por hilos
- Sincronización: se genera un UUID por cada ruta (aún no se exporta)
@@ -0,0 +1,16 @@
v3.8.0: OpenTracks
Cambio importante:
- Importación/exportación de GPX de velocidad, cadencia y frecuencia cardíaca a través de TrackPointExtension/v2
Cambios:
- API del panel de control: permite acceder a los marcadores (incluida una llamada de retorno para mostrarlos en OpenTracks)
- Nuevo icono de marcador (chincheta naranja, SVG)
- Los archivos que se van a importar se pueden seleccionar a través del gestor de archivos
Correcciones de errores:
- La interfaz de usuario de estadísticas agregadas respeta la frecuencia preferida
- Fallaba la edición de un marcador al cambiar la orientación
Desarrollador:
- Se ha refactorizado TrackRecordingService
@@ -0,0 +1,8 @@
v3.8.1: OpenTracks
Cambios:
- Durante la grabación: se ha añadido la opción de pantalla completa
- El panel de control se inicia en la misma tarea
Correcciones de errores:
- Los filtros de intención para importar archivos eran demasiado optimistas
@@ -0,0 +1,14 @@
v3.9.0: OpenTracks
Cambios:
- Exportación de rutas con su UUID
- Se evita la importación de rutas ya existentes por su UUID (configurable; por defecto, activado)
- Ahora hay que mantener pulsado el botón de pausa/parada
- El preinicio del GPS muestra el estado de la señal
- Se han añadido nuevos iconos de actividad
- Sensor de velocidad: ahora se puede configurar el tamaño de la rueda en milímetros
Correcciones de errores:
- Se han corregido algunos fallos poco frecuentes (NPE)
- Se ha corregido el formato del ritmo
- Se ha cambiado la posición del icono de la aplicación en el lanzador
@@ -1,7 +1,7 @@
v4.14.1: OpenTracks
Cambios:
- Detección de inactividad se puede desactivar
- Se puede desactivar la detección de inactividad
Correcciones de errores:
- UI para añadir imágenes a los marcadores estaba oculto
Corrección de errores:
- La interfaz de usuario para añadir imágenes a los marcadores estaba oculta
@@ -1,8 +1,22 @@
OpenTracks es un tracker deportivo que respeta tu privacidad.
Un compañero de seguimiento deportivo que respeta tu privacidad.
Funciones:
* Tracker deportivo: haz un seguimiento de tus actividades al aire libre
* Marca lugares de interés, incluso agrega fotos a los marcadores
* Pulsómetro (compatible con dispositivos Bluetooth LE)
* Exporta tus rutas como KML o GPX
* Completamente offline, no necesitas internet
El deporte y las actividades al aire libre aportan felicidad, destreza y confianza.
Cuida tu salud llevando un registro de tu entrenamiento.
Registra tus sesiones de carrera o caminata, y te ofrece un ciclocomputador con una pantalla más grande para el ciclismo.
Marca lugares interesantes a lo largo de tu recorrido con fotos.
Guarda estadísticas detalladas para su análisis.
Comparte solo los datos que quieras que vean los demás.
* Avisos de voz.
* Compatible con sensores Bluetooth LE: frecuencia cardíaca, velocidad y distancia (ciclismo), cadencia (ciclismo) y medidor de potencia (ciclismo).
* Desnivel positivo y negativo: mediante sensor barométrico.
* Altitud mostrada en EGM2008 (por encima del nivel medio del mar); exportada como WGS84.
* Exporta los datos como rutas en formato KMZ (incluidas fotos), KML o GPX.
* No requiere acceso a Internet ni permisos adicionales.
* Temas oscuro y claro, respetando la configuración del sistema.
* Sin anuncios.
Software libre / Software gratuito / Código abierto,
lo que significa que puedes utilizar, estudiar, modificar y compartir el código fuente.
Licencia Apache 2.0
@@ -0,0 +1,15 @@
v3.0.0: OpenTracks
Rilisan pertama OpenTracks (fork keras dari MyTracks)
Perubahan:
- Perubahan merek MyTracks ke OpenTracks
- Menambahkan sensor detak jantung Bluetooth LE
- Menghapus semua fitur yang memerlukan koneksi internet
- Menghapus integrasi Google Maps
- Menghapus penggunaan layanan Google Play
- Menghapus sensor ANT+
- Menghapus sensor klasik Bluetooth
- Menghapus Protobuf
- Menghapus estimasi kalori dan aktivitas
- Menghapus API MyTracks Service (dengan kata lain, tidak ada integrasi dengan aplikasi lain)
@@ -0,0 +1,17 @@
v3.0.1: OpenTracks
Perubahan:
- Bagikan penanda
- Perubahan struktur pengaturan
- Menghapus tombol kembali di bilah aksi
- Ekspor: menghapus pilihan pengaturan ukuran foto
- Ekspor: KML sebagai format sekarang dapat dipilih
Perbaikan Bug:
- direktori ekspor sekarang di /OpenTracks/
- mode lanskap tidak akan mogok
- memperbaiki ekspor KML/KMZ
Pengembang:
- Tes dapat dieksekusi (namun banyak yang gagal)
- Mengaktifkan integrasi terus menerus dengan TravisCI (tanpa tes)
@@ -0,0 +1,14 @@
v3.1.0: OpenTracks
Perubahan:
- Menambahkan opsi tampilan jalur salju di peta aplikasi (telah diuji dengan OsmAnd dan Maps.ME)
- Menambahkan ketika melacak, akan menampilkan statistik jalur tanpa perlu membuka kunci perangkat
- Menghapus mode landkap untuk tampilan detail jalur (tidak stabil)
- Menghapus riwayat saran penelusuran
Perbaikan Bug:
- Permintaan izin penyimpanan untuk impor/ekspor semua
Pengembang:
- Pembaruan ke Gradle 5.6
- Jangan mengecilkan (minify) rilisan build
@@ -1,4 +1,4 @@
Teman pelacakan olahraga yang menghormati privasi Anda.
Sebuah teman pelacak olahraga yang benar-benar menghormati privasi Anda.
Olahraga dan aktivitas luar ruangan memberikan kebahagiaan, keterampilan, dan kepercayaan diri.
@@ -11,12 +11,12 @@ Hanya membagikan data yang Anda ingin orang lain memilikinya.
* Notifikasi suara.
* Mendukung sensor Bluetooth LE: detak jantung, kecepatan dan jarak (bersepeda), irama (bersepeda), dan pengukur daya (bersepeda).
* Kenaikan dan penurunan ketinggian: melalui sensor barometrik.
* Ketinggian yang ditampilkan dalam EGM2008 (di atas permukaan laut); diekspor sebagai WGS84.
* Ketinggian ditampilkan dalam EGM2008 (di atas permukaan laut); diekspor sebagai WGS84.
* Mengekspor data sebagai lintasan, baik sebagai KMZ (termasuk foto), KML, atau GPX.
* Tidak ada akses Internet atau izin tambahan.
* Tema gelap dan terang, dengan memperhatikan pengaturan sistem.
* Tanpa iklan.
Perangkat lunak bebas / Perangkat Lunak Bebas / Sumber Terbuka
Perangkat Lunak Bebas / Sumber Terbuka
yang berarti Anda dapat menggunakan, mempelajari, mengubah, dan berbagi kode sumber.
Berlisensi Apache 2.0
@@ -1 +1 @@
Sebuah pelacak olahraga yang benar-benar menghormati privasi Anda.
Sebuah teman pelacak olahraga yang benar-benar menghormati privasi Anda.
@@ -1 +1 @@
一個尊重您的隱私的運動蹤跡紀錄器。
一款尊重您隱私的運動蹤跡紀錄器。
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
cat << 'EOC' > src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java.patch
--- src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java
+++ src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java
@@ -351,6 +351,11 @@
public static boolean shouldVoiceAnnouncementLongBreak() {
return getBoolean(R.string.settings_announcements_long_break_key, true);
}
+
+ public static long getLongBreakDuration() {
+ int minutes = getInt(R.string.settings_long_break_duration_key, 3);
+ return minutes * 60 * 1000L;
+ }
EOC
patch -p0 < src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java.patch
+13
View File
@@ -0,0 +1,13 @@
cat << 'EOC' > src/main/res/values/strings.xml.patch
--- src/main/res/values/strings.xml
+++ src/main/res/values/strings.xml
@@ -1071,6 +1071,8 @@
<string name="settings_announcements_long_break_key">settings_announcements_long_break_key</string>
<string name="settings_announcements_long_break_title">Long break</string>
+ <string name="settings_long_break_duration_key">settings_long_break_duration_key</string>
+ <string name="settings_long_break_duration_title">Long break duration (minutes)</string>
</resources>
EOC
patch -p0 < src/main/res/values/strings.xml.patch
+1
View File
@@ -0,0 +1 @@
cat src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java | grep -n "if (movingDistance != null) {" -B 2 -A 30
+4
View File
@@ -0,0 +1,4 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'OpenTracks'
@@ -0,0 +1,78 @@
package de.dennisguse.opentracks.data.models;
import static org.junit.Assert.assertEquals;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Locale;
@RunWith(AndroidJUnit4.class)
public class ActivityTypeTest {
@Test
public void findBy_id() {
assertEquals(ActivityType.TRAIL_RUNNING, ActivityType.findBy("trail running"));
}
@Test
public void findBy_idOfEveryActivityType() {
for (ActivityType activityType : ActivityType.values()) {
assertEquals(activityType, ActivityType.findBy(activityType.getId()));
}
}
@Test
public void findBy_synonymsOfEveryActivityType() {
for (ActivityType activityType : ActivityType.values()) {
for (String synonym : activityType.synonyms) {
assertEquals(activityType, ActivityType.findBy(synonym));
assertEquals(activityType, ActivityType.findBy(synonym.toUpperCase(Locale.ROOT).replace(' ', '_')));
}
}
}
@Test
public void findBy_ignoresCase() {
assertEquals(ActivityType.RUNNING, ActivityType.findBy("RUNNING"));
assertEquals(ActivityType.ATV, ActivityType.findBy("atv"));
}
@Test
public void findBy_ignoresSeparators() {
assertEquals(ActivityType.TRAIL_RUNNING, ActivityType.findBy("TRAIL_RUNNING"));
assertEquals(ActivityType.MOUNTAIN_BIKING, ActivityType.findBy("mountain-biking"));
assertEquals(ActivityType.CROSS_COUNTRY_SKIING, ActivityType.findBy("cross_country_skiing"));
}
@Test
public void findBy_ignoresSurroundingAndRepeatedWhitespace() {
assertEquals(ActivityType.MOUNTAIN_BIKING, ActivityType.findBy(" Mountain Biking "));
}
@Test
public void findBy_synonym() {
assertEquals(ActivityType.RUNNING, ActivityType.findBy("treadmill"));
assertEquals(ActivityType.TRAIL_RUNNING, ActivityType.findBy("TRAIL_RUN"));
assertEquals(ActivityType.MOUNTAIN_BIKING, ActivityType.findBy("MOUNTAIN_BIKE"));
assertEquals(ActivityType.SWIMMING_OPEN, ActivityType.findBy("SWIMMING_OPENWATER"));
assertEquals(ActivityType.BIKING, ActivityType.findBy("bike"));
}
@Test
public void findBy_unknown() {
assertEquals(ActivityType.UNKNOWN, ActivityType.findBy("hovercraft"));
}
@Test
public void findBy_null() {
assertEquals(ActivityType.UNKNOWN, ActivityType.findBy(null));
}
@Test
public void findBy_blank() {
assertEquals(ActivityType.UNKNOWN, ActivityType.findBy(" "));
}
}
@@ -161,6 +161,143 @@ public class GPXTrackImporterTest {
), importedTrackPoints);
}
@LargeTest
@Test
public void gpx_with_power_pwd() throws IOException {
// given
XMLImporter importer = new XMLImporter(new GPXTrackImporter(context, trackImporter));
InputStream inputStream = InstrumentationRegistry.getInstrumentation().getContext().getResources().openRawResource(de.dennisguse.opentracks.test.R.raw.gpx_with_power_pwr);
// when
// 1. import
importTrackId = importer.importFile(inputStream).get(0);
// then
// 2. track
Track importedTrack = contentProviderUtils.getTrack(importTrackId);
assertNotNull(importedTrack);
assertEquals("", importedTrack.activityTypeLocalized());
assertEquals("", importedTrack.description());
assertEquals("gpx with power", importedTrack.name());
assertEquals(ActivityType.UNKNOWN, importedTrack.activityType());
// 3. trackpoints
List<TrackPoint> importedTrackPoints = TestDataUtil.getTrackPoints(contentProviderUtils, importTrackId);
assertEquals(3, importedTrackPoints.size());
TrackPointAssert a = new TrackPointAssert();
a.assertEquals(List.of(
new TrackPoint(null, TrackPoint.Type.SEGMENT_START_AUTOMATIC,
new Position(
Instant.parse("2021-09-07T22:10:19Z"),
30.14185982, -40.3863038, null,
Altitude.WGS84.of(-5), null,
null,
null),
null,
null,
null,
null,
new Power(100f),
null
),
new TrackPoint(null, TrackPoint.Type.TRACKPOINT,
new Position(
Instant.parse("2021-09-07T22:11:07Z"),
30.14184657, -40.38670089, null,
Altitude.WGS84.of(-5), null,
null,
Speed.of(0.7976524233818054)),
null,
null,
null,
null,
new Power(110f),
null),
new TrackPoint(null, TrackPoint.Type.TRACKPOINT,
new Position(
Instant.parse("2021-09-07T22:12:00Z"),
30.14185982, -40.3863038, null,
Altitude.WGS84.of(-5), null,
null,
Speed.of(0.7224021553993225)),
null,
null,
null,
null,
new Power(150f),
null)
), importedTrackPoints);
}
@LargeTest
@Test
public void gpx_with_power_gpxpx() throws IOException {
// given
XMLImporter importer = new XMLImporter(new GPXTrackImporter(context, trackImporter));
InputStream inputStream = InstrumentationRegistry.getInstrumentation().getContext().getResources().openRawResource(de.dennisguse.opentracks.test.R.raw.gpx_with_power_gpxpx);
// when
// 1. import
importTrackId = importer.importFile(inputStream).get(0);
// then
// 2. track
Track importedTrack = contentProviderUtils.getTrack(importTrackId);
assertNotNull(importedTrack);
assertEquals("", importedTrack.activityTypeLocalized());
assertEquals("", importedTrack.description());
assertEquals("gpx with power", importedTrack.name());
assertEquals(ActivityType.UNKNOWN, importedTrack.activityType());
// 3. trackpoints
List<TrackPoint> importedTrackPoints = TestDataUtil.getTrackPoints(contentProviderUtils, importTrackId);
assertEquals(3, importedTrackPoints.size());
TrackPointAssert a = new TrackPointAssert();
a.assertEquals(List.of(
new TrackPoint(null, TrackPoint.Type.SEGMENT_START_AUTOMATIC,
new Position(
Instant.parse("2021-09-07T22:10:19Z"),
30.14185982, -40.3863038, null,
Altitude.WGS84.of(-5), null,
null,
null),
null,
null,
null,
null,
new Power(100f),
null
),
new TrackPoint(null, TrackPoint.Type.TRACKPOINT,
new Position(
Instant.parse("2021-09-07T22:11:07Z"),
30.14184657, -40.38670089, null,
Altitude.WGS84.of(-5), null,
null,
Speed.of(0.7976524233818054)),
null,
null,
null,
null,
new Power(110f),
null),
new TrackPoint(null, TrackPoint.Type.TRACKPOINT,
new Position(
Instant.parse("2021-09-07T22:12:00Z"),
30.14185982, -40.3863038, null,
Altitude.WGS84.of(-5), null,
null,
Speed.of(0.7224021553993225)),
null,
null,
null,
null,
new Power(150f),
null)
), importedTrackPoints);
}
@LargeTest
@Test
public void gpx_speed_no_namespace() throws IOException {
@@ -92,7 +92,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -132,7 +135,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -172,7 +178,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -212,7 +221,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -254,7 +266,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -276,7 +291,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -316,7 +334,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -338,7 +359,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -378,7 +402,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -418,7 +445,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -458,7 +488,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -498,7 +531,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -520,7 +556,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -560,7 +599,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -582,7 +624,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -610,7 +655,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
@@ -644,7 +692,10 @@ public class VoiceAnnouncementUtilsTest {
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
null,
null,
null,
null
);
// when
+2 -1
View File
@@ -28,10 +28,11 @@ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/
<opentracks:typeTranslated><![CDATA[unknown]]></opentracks:typeTranslated>
<gpxtrkx:TrackStatsExtension>
<gpxtrkx:Distance>0.0</gpxtrkx:Distance>
<gpxtrkx:TimerTime>0</gpxtrkx:TimerTime>
<gpxtrkx:TotalElapsedTime>0</gpxtrkx:TotalElapsedTime>
<gpxtrkx:MovingTime>0</gpxtrkx:MovingTime>
<gpxtrkx:StoppedTime>0</gpxtrkx:StoppedTime>
<gpxtrkx:MaxSpeed>0.0</gpxtrkx:MaxSpeed>
<gpxtrkx:MovingSpeed>0.0</gpxtrkx:MovingSpeed>
</gpxtrkx:TrackStatsExtension>
</extensions>
<trkseg>
@@ -0,0 +1,32 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<gpx version="1.1" creator="Cruiser" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<trk>
<name>gpx with power</name>
<trkseg>
<trkpt lat="30.14185982" lon="-40.3863038">
<ele>-5</ele>
<time>2021-09-07T22:10:19Z</time>
<hdop>4</hdop>
<extensions>
<gpxpx:PowerInWatts>100</gpxpx:PowerInWatts>
</extensions>
</trkpt>
<trkpt lat="30.14184657" lon="-40.38670089">
<ele>-5</ele>
<time>2021-09-07T22:11:07Z</time>
<hdop>4</hdop>
<extensions>
<gpxpx:PowerInWatts>110</gpxpx:PowerInWatts>
</extensions>
</trkpt>
<trkpt lat="30.14185982" lon="-40.3863038">
<ele>-5</ele>
<time>2021-09-07T22:12:00Z</time>
<hdop>4</hdop>
<extensions>
<gpxpx:PowerInWatts>150</gpxpx:PowerInWatts>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
@@ -0,0 +1,32 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<gpx version="1.1" creator="Cruiser" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:pwr="http://www.garmin.com/xmlschemas/PowerExtension/v1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<trk>
<name>gpx with power</name>
<trkseg>
<trkpt lat="30.14185982" lon="-40.3863038">
<ele>-5</ele>
<time>2021-09-07T22:10:19Z</time>
<hdop>4</hdop>
<extensions>
<pwr:PowerInWatts>100</pwr:PowerInWatts>
</extensions>
</trkpt>
<trkpt lat="30.14184657" lon="-40.38670089">
<ele>-5</ele>
<time>2021-09-07T22:11:07Z</time>
<hdop>4</hdop>
<extensions>
<pwr:PowerInWatts>110</pwr:PowerInWatts>
</extensions>
</trkpt>
<trkpt lat="30.14185982" lon="-40.3863038">
<ele>-5</ele>
<time>2021-09-07T22:12:00Z</time>
<hdop>4</hdop>
<extensions>
<pwr:PowerInWatts>150</pwr:PowerInWatts>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+10
View File
@@ -424,6 +424,16 @@ limitations under the License.
android:icon="@drawable/ic_logo_color_24dp"
android:label="@string/recording_service" />
<service
android:name=".services.MediaNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!--Necessary for SDK < 33 to (re-)store per app locale information-->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -67,6 +67,7 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
private static final String CURRENT_TAB_TAG_KEY = "current_tab_tag_key";
private Snackbar snackbar;
private Snackbar sensorWarningSnackbar;
// The following are setFrequency in onCreate
private ContentProviderUtils contentProviderUtils;
@@ -86,6 +87,9 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
service.getGpsStatusObservable()
.observe(TrackRecordingActivity.this, this::onGpsStatusChanged);
service.getSensorDisconnectedWarningObservable()
.observe(TrackRecordingActivity.this, this::onSensorDisconnectedWarningChanged);
if (!service.isRecording()) {
finish();
return;
@@ -408,4 +412,27 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
});
snackbar.show();
}
private void onSensorDisconnectedWarningChanged(boolean show) {
if (show) {
if (sensorWarningSnackbar == null) {
sensorWarningSnackbar = Snackbar
.make(viewBinding.trackRecordingCoordinatorLayout,
getString(R.string.sensor_disconnected_warning),
Snackbar.LENGTH_INDEFINITE)
.setAction(getString(R.string.sensor_disconnected_switch_action), v -> {
if (trackRecordingServiceConnection != null && trackRecordingServiceConnection.getTrackRecordingService() != null) {
trackRecordingServiceConnection.getTrackRecordingService().setOverrideSensorWithGPS(true);
}
});
}
if (!sensorWarningSnackbar.isShown()) {
sensorWarningSnackbar.show();
}
} else {
if (sensorWarningSnackbar != null && sensorWarningSnackbar.isShown()) {
sensorWarningSnackbar.dismiss();
}
}
}
}
@@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import de.dennisguse.opentracks.R;
@@ -14,13 +15,13 @@ public enum ActivityType {
AIRPLANE("airplane", R.string.activity_type_airplane, R.drawable.ic_activity_flight_24dp, true),
ATV("ATV", R.string.activity_type_atv, R.drawable.ic_activity_drive_24dp, true),
BIKING("biking", R.string.activity_type_biking, R.drawable.ic_activity_bike_24dp, true),
BIKING("biking", List.of("bike", "indoor cycling"), R.string.activity_type_biking, R.drawable.ic_activity_bike_24dp, true),
BLIMP("blimp", R.string.activity_type_blimp, ActivityIcon.ICON_UNKNOWN, true),
BOAT("boat", R.string.activity_type_boat, R.drawable.ic_activity_boat_24dp, true),
CLIMBING("climbing", R.string.activity_type_climbing, R.drawable.ic_activity_climbing_24dp, false),
COMMERCIAL_AIRPLANE("commercial airplane", R.string.activity_type_commercial_airplane, R.drawable.ic_activity_flight_24dp, true),
CROSS_COUNTRY_SKIING("cross-country skiing", R.string.activity_type_cross_country_skiing, R.drawable.ic_activity_skiing_24dp, true),
CYCLING("cycling", R.string.activity_type_cycling, R.drawable.ic_activity_bike_24dp, true),
CYCLING("cycling", List.of("cycle"), R.string.activity_type_cycling, R.drawable.ic_activity_bike_24dp, true),
DIRT_BIKE("dirt bike", R.string.activity_type_dirt_bike, R.drawable.ic_activity_mtb_24dp, true),
DONKEY_BACK_RIDING("donkey back riding", R.string.activity_type_donkey_back_riding, ActivityIcon.ICON_UNKNOWN, true),
DRIVING("driving", R.string.activity_type_driving, R.drawable.ic_activity_drive_24dp, true),
@@ -30,21 +31,21 @@ public enum ActivityType {
FERRY("ferry", R.string.activity_type_ferry, R.drawable.ic_activity_boat_24dp, true),
FRISBEE("frisbee", R.string.activity_type_frisbee, ActivityIcon.ICON_UNKNOWN, true),
GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true),
GRAVEL_BIKING("gravel biking", R.string.activity_type_gravel_biking, R.drawable.ic_activity_bike_24dp, true),
GRAVEL_BIKING("gravel biking", List.of("gravel bike"), R.string.activity_type_gravel_biking, R.drawable.ic_activity_bike_24dp, true),
HANG_GLIDING("hang gliding", R.string.activity_type_hang_gliding, ActivityIcon.ICON_UNKNOWN, true),
HELICOPTER("helicopter", R.string.activity_type_helicopter, ActivityIcon.ICON_UNKNOWN, true),
HIKING("hiking", R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false),
HIKING("hiking", List.of("hike"), R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false),
HORSE_BACK_RIDING("horse back riding", R.string.activity_type_horse_back_riding, ActivityIcon.ICON_UNKNOWN, true),
HOT_AIR_BALLOON("hot air balloon", R.string.activity_type_hot_air_balloon, ActivityIcon.ICON_UNKNOWN, true),
ICE_SAILING("ice sailing", R.string.activity_type_ice_sailing, ActivityIcon.ICON_UNKNOWN, true),
INLINE_SKATING("inline skating", R.string.activity_type_inline_skating, R.drawable.ic_activity_inline_skating_24dp, true),
INLINE_SKATING("inline skating", List.of("inline skate"), R.string.activity_type_inline_skating, R.drawable.ic_activity_inline_skating_24dp, true),
KAYAKING("kayaking", R.string.activity_type_kayaking, R.drawable.ic_activity_kayaking_24dp, true),
KITE_SURFING("kite surfing", R.string.activity_type_kite_surfing, ActivityIcon.ICON_UNKNOWN, true),
LAND_SAILING("land sailing", R.string.activity_type_land_sailing, ActivityIcon.ICON_UNKNOWN, true),
MIXED_TYPE("mixed type", R.string.activity_type_mixed_type, ActivityIcon.ICON_UNKNOWN, true),
MOTOR_BIKE("motor bike", R.string.activity_type_motor_bike, R.drawable.ic_activity_motorbike_24dp, true),
MOTOR_BOATING("motor boating", R.string.activity_type_motor_boating, R.drawable.ic_activity_boat_24dp, true),
MOUNTAIN_BIKING("mountain biking", R.string.activity_type_mountain_biking, R.drawable.ic_activity_mtb_24dp, true),
MOUNTAIN_BIKING("mountain biking", List.of("mountain bike"), R.string.activity_type_mountain_biking, R.drawable.ic_activity_mtb_24dp, true),
OFF_TRAIL_HIKING("off trail hiking", R.string.activity_type_off_trail_hiking, R.drawable.ic_activity_walk_24dp, false),
OTHER("other", R.string.activity_type_other, ActivityIcon.ICON_UNKNOWN, true),
PADDLING("paddling", R.string.activity_type_paddling, ActivityIcon.ICON_UNKNOWN, true),
@@ -53,46 +54,57 @@ public enum ActivityType {
RC_BOAT("RC boat", R.string.activity_type_rc_boat, R.drawable.ic_activity_boat_24dp, true),
RC_HELICOPTER("RC helicopter", R.string.activity_type_rc_helicopter, ActivityIcon.ICON_UNKNOWN, true),
RIDING("riding", R.string.activity_type_horse_back_riding, ActivityIcon.ICON_UNKNOWN, true),
ROAD_BIKING("road biking", R.string.activity_type_road_biking, R.drawable.ic_activity_bike_24dp, true),
ROAD_BIKING("road biking", List.of("road bike"), R.string.activity_type_road_biking, R.drawable.ic_activity_bike_24dp, true),
ROLLER_SKIING("roller skiing", R.string.activity_type_roller_skiing, ActivityIcon.ICON_UNKNOWN, true),
ROWING("rowing", R.string.activity_type_rowing, ActivityIcon.ICON_UNKNOWN, true),
RUNNING("running", R.string.activity_type_running, R.drawable.ic_activity_run_24dp, false),
RUNNING("running", List.of("run", "treadmill"), R.string.activity_type_running, R.drawable.ic_activity_run_24dp, false),
SAILING("sailing", R.string.activity_type_sailing, R.drawable.ic_activity_sailing_24dp, true),
KICKSCOOTER("kickscooter", R.string.activity_type_kickscooter, R.drawable.ic_activity_scooter_24dp, true),
SEAPLANE("seaplane", R.string.activity_type_seaplane, R.drawable.ic_activity_flight_24dp, true),
SKATE_BOARDING("skateboarding", R.string.activity_type_skate_boarding, R.drawable.ic_activity_skateboarding_24dp, true),
SKATE_BOARDING("skateboarding", List.of("skateboard"), R.string.activity_type_skate_boarding, R.drawable.ic_activity_skateboarding_24dp, true),
SKATING("skating", R.string.activity_type_skating, ActivityIcon.ICON_UNKNOWN, true),
SKIING("skiing", R.string.activity_type_skiing, R.drawable.ic_activity_skiing_24dp, true),
SKY_JUMPING("sky jumping", R.string.activity_type_sky_jumping, ActivityIcon.ICON_UNKNOWN, true),
SLED("sled", R.string.activity_type_sled, ActivityIcon.ICON_UNKNOWN, true),
SNOW_BOARDING("snowboarding", R.string.activity_type_snow_boarding, R.drawable.ic_activity_snowboarding_24dp, true),
SNOW_BOARDING("snowboarding", List.of("snowboard"), R.string.activity_type_snow_boarding, R.drawable.ic_activity_snowboarding_24dp, true),
SNOW_SHOEING("snow shoeing", R.string.activity_type_snow_shoeing, ActivityIcon.ICON_UNKNOWN, true),
SPEED_WALKING("speed walking", R.string.activity_type_speed_walking, R.drawable.ic_activity_walk_24dp, false),
STREET_RUNNING("street running", R.string.activity_type_street_running, R.drawable.ic_activity_run_24dp, false),
SURFING("surfing", R.string.activity_type_surfing, ActivityIcon.ICON_UNKNOWN, true),
TRACK_CYCLING("track cycling", R.string.activity_type_track_cycling, R.drawable.ic_activity_bike_24dp, true),
TRACK_RUNNING("track running", R.string.activity_type_trail_running, R.drawable.ic_activity_run_24dp, false),
TRACK_RUNNING("track running", List.of("track run"), R.string.activity_type_trail_running, R.drawable.ic_activity_run_24dp, false),
TRAIL_HIKING("trail hiking", R.string.activity_type_trail_hiking, R.drawable.ic_activity_walk_24dp, false),
TRAIL_RUNNING("trail running", R.string.activity_type_trail_running, R.drawable.ic_activity_run_24dp, false),
TRAIL_RUNNING("trail running", List.of("trail run"), R.string.activity_type_trail_running, R.drawable.ic_activity_run_24dp, false),
TRAIN("train", R.string.activity_type_train, ActivityIcon.ICON_UNKNOWN, true),
ULTIMATE_FRISBEE("ultimate frisbee", R.string.activity_type_ultimate_frisbee, ActivityIcon.ICON_UNKNOWN, true),
WAKEBOARDING("wakeboarding", R.string.activity_type_wakeboarding, ActivityIcon.ICON_UNKNOWN, true),
WALKING("walking", R.string.activity_type_walking, R.drawable.ic_activity_walk_24dp, false),
WALKING("walking", List.of("walk"), R.string.activity_type_walking, R.drawable.ic_activity_walk_24dp, false),
WATER_SKIING("water skiing", R.string.activity_type_water_skiing, ActivityIcon.ICON_UNKNOWN, true),
WIND_SURFING("wind surfing", R.string.activity_type_wind_surfing, ActivityIcon.ICON_UNKNOWN, true),
SWIMMING("swimming", R.string.activity_type_swimming, R.drawable.ic_activity_swimming_24dp, false),
SWIMMING_OPEN("swimming in open water", R.string.activity_type_swimming_open, R.drawable.ic_activity_swimming_open_24dp, false),
SWIMMING("swimming", List.of("swim"), R.string.activity_type_swimming, R.drawable.ic_activity_swimming_24dp, false),
SWIMMING_OPEN("swimming in open water", List.of("swimming openwater", "open water swimming"), R.string.activity_type_swimming_open, R.drawable.ic_activity_swimming_open_24dp, false),
WORKOUT("workout", R.string.activity_type_workout, R.drawable.ic_activity_workout_24dp, false),
UNKNOWN("unknown", R.string.activity_type_unknown, ActivityIcon.ICON_UNKNOWN, true);
final String id;
/**
* Spellings used by other applications that {@link #normalize(String)} cannot map onto {@link #id}.
* Already normalized.
*/
final List<String> synonyms;
final int iconDrawableId;
final boolean showSpeedPreferred;
final int localizedStringId;
ActivityType(String id, int localizedStringId, int iconDrawableId, boolean showSpeedPreferred) {
this(id, List.of(), localizedStringId, iconDrawableId, showSpeedPreferred);
}
ActivityType(String id, List<String> synonyms, int localizedStringId, int iconDrawableId, boolean showSpeedPreferred) {
this.id = id;
this.synonyms = synonyms;
this.localizedStringId = localizedStringId;
this.iconDrawableId = iconDrawableId;
this.showSpeedPreferred = showSpeedPreferred;
@@ -121,12 +133,39 @@ public enum ActivityType {
.toList();
}
/**
* Lowercases and reduces "_", "-" and whitespace runs to a single space.
* Lowercasing is locale-independent: under a Turkish locale the I of "RUNNING" would otherwise
* lowercase to a dotless i and stop matching "running".
*/
@NonNull
private static String normalize(String activityTypeId) {
if (activityTypeId == null) {
return "";
}
return activityTypeId.toLowerCase(Locale.ROOT)
.replaceAll("[\\s_-]+", " ")
.trim();
}
/**
* Resolves an activity type as written by another application; GPX's type element is free text.
*/
@NonNull
public static ActivityType findBy(String activityTypeId) {
return Arrays.stream(ActivityType.values()).filter(
it -> it.id.equals(activityTypeId)
).findFirst()
.orElse(ActivityType.UNKNOWN);
String normalized = normalize(activityTypeId);
if (normalized.isEmpty()) {
return ActivityType.UNKNOWN;
}
return Arrays.stream(ActivityType.values())
.filter(it -> normalize(it.id).equals(normalized))
.findFirst()
.orElseGet(() -> Arrays.stream(ActivityType.values())
.filter(it -> it.synonyms.contains(normalized))
.findFirst()
.orElse(ActivityType.UNKNOWN));
}
@NonNull
@@ -151,12 +151,14 @@ public class SegmentStatisticUpdater {
}
public void addMovingTime(TrackPoint trackPoint, TrackPoint lastTrackPoint) {
Duration movingDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime());
addMovingTime(Duration.between(lastTrackPoint.getTime(), trackPoint.getTime()));
}
if (movingDuration.isNegative()) {
throw new RuntimeException("Moving time cannot be negative: " + lastTrackPoint.getTime() + " is after " + trackPoint.getTime());
public void addMovingTime(Duration duration) {
if (duration.isNegative()) {
throw new RuntimeException("Moving time cannot be negative");
}
this.movingDuration = this.movingDuration.plus(movingDuration);
movingDuration = movingDuration.plus(duration);
}
private Speed getAverageMovingSpeed() {
@@ -143,7 +143,6 @@ public class TrackStatisticsUpdater {
}
{
// Update total distance
Distance movingDistance = null;
if (trackPoint.sensorDistance() != null) {
movingDistance = trackPoint.sensorDistance();
@@ -153,16 +152,7 @@ public class TrackStatisticsUpdater {
// GPS-based distance/speed
movingDistance = trackPoint.distanceToPrevious(lastTrackPoint);
}
if (movingDistance != null) {
currentSegment.addTotalDistance(movingDistance);
}
if (!isIdle
&& !trackPoint.isSegmentManualStart()
&& lastTrackPoint != null) {
currentSegment.addMovingTime(trackPoint, lastTrackPoint);
}
boolean isIdleTransition = false;
if (trackPoint.isIdleTriggered()) {
isIdle = true;
} else if (isIdle) {
@@ -170,6 +160,26 @@ public class TrackStatisticsUpdater {
if (movingDistance != null
&& movingDistance.greaterOrEqualThan(PreferencesUtils.getRecordingDistanceInterval())) {
isIdle = false;
isIdleTransition = true;
}
}
if (movingDistance != null && !isIdle) {
currentSegment.addTotalDistance(movingDistance);
}
if (!isIdle
&& !trackPoint.isSegmentManualStart()
&& lastTrackPoint != null) {
if (isIdleTransition) {
if (trackPoint.position().hasSpeed() && trackPoint.position().speed().toMPS() > 0) {
double timeSeconds = movingDistance.toM() / trackPoint.position().speed().toMPS();
currentSegment.addMovingTime(Duration.ofMillis((long)(timeSeconds * 1000)));
} else {
currentSegment.addMovingTime(Duration.ofMillis((long)(movingDistance.toM() * 1000)));
}
} else {
currentSegment.addMovingTime(trackPoint, lastTrackPoint);
}
}
@@ -105,7 +105,7 @@ public class ExportActivity extends AbstractActivity {
private ContentProviderUtils contentProviderUtils;
// List of tracks to be exported.
private ArrayList<ExportTask> exportTasks;
private final ArrayList<ExportTask> exportTasks = new ArrayList<>();
private final LinkedBlockingQueue<PendingConflict> conflictsQueue = new LinkedBlockingQueue<>();
private final Handler conflictsHandler = new Handler();
@@ -183,7 +183,7 @@ public class ExportActivity extends AbstractActivity {
trackExportTotalCount = savedInstanceState.getInt(BUNDLE_TOTAL_COUNT);
directoryFiles = savedInstanceState.getStringArrayList(BUNDLE_DIRECTORY_FILES);
trackErrors = savedInstanceState.getStringArrayList(BUNDLE_TRACK_ERRORS);
exportTasks = new ArrayList<>(savedInstanceState.getParcelableArrayList(BUNDLE_EXPORT_TASKS));
exportTasks.addAll(savedInstanceState.getParcelableArrayList(BUNDLE_EXPORT_TASKS));
setProgress();
nextExport(null);
}
@@ -226,7 +226,6 @@ public class ExportActivity extends AbstractActivity {
return false;
}
exportTasks = new ArrayList<>();
if (allInOneFile) {
String filename = "OpenTracks-Backup";
exportTasks.add(new ExportTask(filename, trackFileFormat, tracks.stream().map(Track::id).toList()));
@@ -282,10 +282,11 @@ public class GPXTrackExporter implements TrackExporter {
Statistics trackStatistics = track.statistics();
printWriter.println("<gpxtrkx:TrackStatsExtension>");
printWriter.println("<gpxtrkx:Distance>" + trackStatistics.totalDistance().toM() + "</gpxtrkx:Distance>");
printWriter.println("<gpxtrkx:TimerTime>" + trackStatistics.totalDuration().getSeconds() + "</gpxtrkx:TimerTime>");
printWriter.println("<gpxtrkx:TotalElapsedTime>" + trackStatistics.totalDuration().getSeconds() + "</gpxtrkx:TotalElapsedTime>");
printWriter.println("<gpxtrkx:MovingTime>" + trackStatistics.movingDuration().getSeconds() + "</gpxtrkx:MovingTime>");
printWriter.println("<gpxtrkx:StoppedTime>" + trackStatistics.getStoppedTime().getSeconds() + "</gpxtrkx:StoppedTime>");
printWriter.println("<gpxtrkx:MaxSpeed>" + trackStatistics.maxSpeed().toMPS() + "</gpxtrkx:MaxSpeed>");
printWriter.println("<gpxtrkx:MovingSpeed>" + trackStatistics.getAverageMovingSpeed().toMPS() + "</gpxtrkx:MovingSpeed>");
if (trackStatistics.altitudeGainLoss() != null) {
printWriter.println("<gpxtrkx:Ascent>" + trackStatistics.altitudeGainLoss().gain_m() + "</gpxtrkx:Ascent>");
printWriter.println("<gpxtrkx:Descent>" + trackStatistics.altitudeGainLoss().loss_m() + "</gpxtrkx:Descent>");
@@ -74,28 +74,44 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
private static final String TAG_TRACK_POINT = "trkpt";
private static final String TAG_TRACK_SEGMENT = "trkseg";
private static final String TAG_TYPE = "type";
private static final String TAG_TYPE_LOCALIZED = "opentracks:typeTranslated";
private static final String TAG_MARKER = "wpt";
private static final String TAG_ID = "opentracks:trackid";
private static final String ATTRIBUTE_LAT = "lat";
private static final String ATTRIBUTE_LON = "lon";
private static final String TAG_EXTENSION_SPEED = "gpxtpx:speed";
/**
* Often speed is exported without the proper namespace.
*/
private static final String TAG_EXTENSION_SPEED_COMPAT = "speed";
private static final String TAG_EXTENSION_HEARTRATE = "gpxtpx:hr";
private static final String TAG_EXTENSION_TEMPERATURE = "gpxtpx:atemp";
private static final String TAG_EXTENSION_CADENCE = "gpxtpx:cad";
private static final String TAG_EXTENSION_POWER = "pwr:PowerInWatts";
private static final String TAG_EXTENSION_GAIN = "opentracks:gain";
private static final String TAG_EXTENSION_LOSS = "opentracks:loss";
private static final String TAG_EXTENSION_DISTANCE = "opentracks:distance";
private static final String TAG_EXTENSION_ACCURACY_HORIZONTAL = "opentracks:accuracy_horizontal";
private static final String TAG_EXTENSION_ACCURACY_VERTICAL = "opentracks:accuracy_vertical";
/**
* Garmin Trackpoint Extensions.
*/
private static final String NS_GARMIN_TRACKPOINT_EXTENSION_V1 = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1";
private static final String NS_GARMIN_TRACKPOINT_EXTENSION_V2 = "http://www.garmin.com/xmlschemas/TrackPointExtension/v2";
private static final String TAG_GARMIN_TPX_EXTENSION_SPEED = "speed";
private static final String TAG_GARMIN_TPX_EXTENSION_HEARTRATE = "hr";
private static final String TAG_GARMIN_TPX_EXTENSION_TEMPERATURE = "atemp";
private static final String TAG_GARMIN_TPX_EXTENSION_CADENCE = "cad";
/**
* Garmin Trackpoint Extensions.
*/
private static final String NS_GARMIN_POWER_EXTENSION_V1 = "http://www.garmin.com/xmlschemas/PowerExtension/v1";
private static final String TAG_GARMIN_PWR_EXTENSION_POWER = "PowerInWatts";
/**
* OpenTracks Extensions.
*/
private static final String NS_OPENTRACKS = "http://opentracksapp.com/xmlschemas/v1";
private static final String TAG_OPENTRACKS_EXTENSION_TRACK_ID = "trackid";
private static final String TAG_OPENTRACKS_EXTENSION_TYPE_LOCALIZED = "typeTranslated";
private static final String TAG_OPENTRACKS_EXTENSION_GAIN = "gain";
private static final String TAG_OPENTRACKS_EXTENSION_LOSS = "loss";
private static final String TAG_OPENTRACKS_EXTENSION_DISTANCE = "distance";
private static final String TAG_OPENTRACKS_EXTENSION_ACCURACY_HORIZONTAL = "accuracy_horizontal";
private static final String TAG_OPENTRACKS_EXTENSION_ACCURACY_VERTICAL = "accuracy_vertical";
private Locator locator;
private final Context context;
@@ -145,8 +161,8 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
}
@Override
public void startElement(String uri, String localName, String tag, Attributes attributes) {
switch (tag) {
public void startElement(String uri, String localName, String qName, Attributes attributes) {
switch (qName) {
case TAG_MARKER:
onMarkerStart(attributes);
break;
@@ -168,108 +184,128 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
}
@Override
public void endElement(String uri, String localName, String tag) {
switch (tag) {
case TAG_GPX -> onFileEnd();
case TAG_MARKER -> onMarkerEnd();
case TAG_TRACK -> {
if (activityTypeLocalized == null) {
// Backward compatibility: up v4.9.1 as <type> contained localized content.
activityTypeLocalized = activityType;
public void endElement(String uri, String localName, String qName) {
if (NS_GARMIN_TRACKPOINT_EXTENSION_V1.equals(uri) || NS_GARMIN_TRACKPOINT_EXTENSION_V2.equals(uri)) {
switch (localName) {
case TAG_GARMIN_TPX_EXTENSION_SPEED -> {
if (content != null) {
speed = content.trim();
}
}
trackImporter.setTrackData(context, name, uuid, description, activityTypeLocalized, activityType, zoneOffset);
zoneOffset = null;
}
case TAG_TRACK_SEGMENT -> onTrackSegmentEnd();
case TAG_TRACK_POINT -> {
TrackPoint trackPoint = createTrackPoint();
if (trackPoint != null) currentSegment.add(trackPoint);
}
case TAG_NAME -> {
if (content != null) {
name = content.trim();
case TAG_GARMIN_TPX_EXTENSION_HEARTRATE -> {
if (content != null) {
heartrate = content.trim();
}
}
}
case TAG_DESCRIPTION -> {
if (content != null) {
description = content.trim();
case TAG_GARMIN_TPX_EXTENSION_TEMPERATURE -> {
if (content != null) {
temperature = content.trim();
}
}
}
case TAG_TYPE -> { //Track or Marker/WPT
if (content != null) {
// In older version this might be localized content.
activityType = content.trim();
markerType = content.trim();
case TAG_GARMIN_TPX_EXTENSION_CADENCE -> {
if (content != null) {
cadence = content.trim();
}
}
default -> Log.w(TAG, "Unknown Garmin trackpoint extension tag " + localName);
}
case TAG_TYPE_LOCALIZED -> {
if (content != null) {
activityTypeLocalized = content.trim();
} else if (NS_GARMIN_POWER_EXTENSION_V1.equals(uri)) {
//noinspection SwitchStatementWithTooFewBranches
switch (localName) {
case TAG_GARMIN_PWR_EXTENSION_POWER -> {
if (content != null) {
power = content.trim();
}
}
default -> Log.w(TAG, "Unknown Garmin power extension tag " + localName);
}
case TAG_TIME -> {
if (content != null) {
time = content.trim();
} else if (NS_OPENTRACKS.equals(uri)) {
switch (localName) {
case TAG_OPENTRACKS_EXTENSION_TRACK_ID -> {
if (content != null) {
uuid = content.trim();
}
}
}
case TAG_ALTITUDE -> {
if (content != null) {
altitude = content.trim();
case TAG_OPENTRACKS_EXTENSION_TYPE_LOCALIZED -> {
if (content != null) {
activityTypeLocalized = content.trim();
}
}
}
case TAG_EXTENSION_SPEED, TAG_EXTENSION_SPEED_COMPAT -> {
if (content != null) {
speed = content.trim();
case TAG_OPENTRACKS_EXTENSION_GAIN -> {
if (content != null) {
gain = content.trim();
}
}
}
case TAG_EXTENSION_HEARTRATE -> {
if (content != null) {
heartrate = content.trim();
case TAG_OPENTRACKS_EXTENSION_LOSS -> {
if (content != null) {
loss = content.trim();
}
}
}
case TAG_EXTENSION_TEMPERATURE -> {
if (content != null) {
temperature = content.trim();
case TAG_OPENTRACKS_EXTENSION_DISTANCE -> {
if (content != null) {
sensorDistance = content.trim();
}
}
}
case TAG_EXTENSION_CADENCE -> {
if (content != null) {
cadence = content.trim();
case TAG_OPENTRACKS_EXTENSION_ACCURACY_HORIZONTAL -> {
if (content != null) {
accuracyHorizontal = content.trim();
}
}
}
case TAG_EXTENSION_POWER -> {
if (content != null) {
power = content.trim();
case TAG_OPENTRACKS_EXTENSION_ACCURACY_VERTICAL -> {
if (content != null) {
accuracyVertical = content.trim();
}
}
default -> Log.w(TAG, "Unknown OpenTracks extension tag " + localName);
}
case TAG_ID -> {
if (content != null) {
uuid = content.trim();
} else {
switch (qName) {
case TAG_GPX -> onFileEnd();
case TAG_MARKER -> onMarkerEnd();
case TAG_TRACK -> {
if (activityTypeLocalized == null) {
// Backward compatibility: up v4.9.1 as <type> contained localized content.
activityTypeLocalized = activityType;
}
trackImporter.setTrackData(context, name, uuid, description, activityTypeLocalized, activityType, zoneOffset);
zoneOffset = null;
}
}
case TAG_EXTENSION_GAIN -> {
if (content != null) {
gain = content.trim();
case TAG_TRACK_SEGMENT -> onTrackSegmentEnd();
case TAG_TRACK_POINT -> {
TrackPoint trackPoint = createTrackPoint();
if (trackPoint != null) currentSegment.add(trackPoint);
}
}
case TAG_EXTENSION_LOSS -> {
if (content != null) {
loss = content.trim();
case TAG_NAME -> {
if (content != null) {
name = content.trim();
}
}
}
case TAG_EXTENSION_DISTANCE -> {
if (content != null) {
sensorDistance = content.trim();
case TAG_DESCRIPTION -> {
if (content != null) {
description = content.trim();
}
}
}
case TAG_EXTENSION_ACCURACY_HORIZONTAL -> {
if (content != null) {
accuracyHorizontal = content.trim();
case TAG_TYPE -> { //Track or Marker/WPT
if (content != null) {
// In older version this might be localized content.
activityType = content.trim();
markerType = content.trim();
}
}
}
case TAG_EXTENSION_ACCURACY_VERTICAL -> {
if (content != null) {
accuracyVertical = content.trim();
case TAG_TIME -> {
if (content != null) {
time = content.trim();
}
}
case TAG_ALTITUDE -> {
if (content != null) {
altitude = content.trim();
}
}
case TAG_EXTENSION_SPEED_COMPAT -> {
if (content != null) {
speed = content.trim();
}
}
}
}
@@ -146,8 +146,8 @@ public class KMLTrackImporter extends DefaultHandler implements XMLImporter.Trac
}
@Override
public void startElement(String uri, String localName, String tag, Attributes attributes) throws SAXException {
switch (tag) {
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
switch (qName) {
case TAG_PLACEMARK, TAG_PHOTO_OVERLAY ->
// Note that a track is contained in a Placemark, calling onMarkerStart will clear various track variables like name, typeLocalized, and description.
onMarkerStart();
@@ -168,8 +168,8 @@ public class KMLTrackImporter extends DefaultHandler implements XMLImporter.Trac
}
@Override
public void endElement(String uri, String localName, String tag) throws SAXException {
switch (tag) {
public void endElement(String uri, String localName, String qName) throws SAXException {
switch (qName) {
case TAG_KML -> onFileEnd();
case TAG_PLACEMARK, TAG_PHOTO_OVERLAY ->
// Note that a track is contained in a Placemark, calling onMarkerEnd is save since markerType is not set for a track.
@@ -189,6 +189,17 @@ public class DataProvider {
Log.i(TAG, "Starting dashboard activity with generic intent (package=" + targetPackage + ", class=" + targetClass + ")");
}
// Explicitly grant URI permissions to all packages that can resolve this intent.
// This works around issues where ClipData multiple URI grants fail on some Android versions.
android.content.pm.PackageManager packageManager = context.getPackageManager();
java.util.List<android.content.pm.ResolveInfo> resolveInfos = packageManager.queryIntentActivities(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY);
for (android.content.pm.ResolveInfo resolveInfo : resolveInfos) {
String packageName = resolveInfo.activityInfo.packageName;
for (Uri uri : uris) {
context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
}
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
@@ -9,6 +9,7 @@ import androidx.annotation.VisibleForTesting;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.BatteryLevel;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.DriverObserver;
import de.dennisguse.opentracks.sensors.driver.SensorType;
@@ -28,6 +29,9 @@ public abstract class SensorHandler<AggregatorInput, AggregatorOutput> implement
protected Aggregator<AggregatorInput, AggregatorOutput> aggregator;
@Nullable
private Integer batteryLevel = null;
protected SensorHandler(SensorManager sensorManager) {
this.sensorManager = sensorManager;
}
@@ -115,7 +119,8 @@ public abstract class SensorHandler<AggregatorInput, AggregatorOutput> implement
return;
}
SensorHandler.this.aggregator.resetAggregated();
SensorHandler.this.aggregator = null;
sensorManager.onChange();
}
@Override
@@ -130,5 +135,18 @@ public abstract class SensorHandler<AggregatorInput, AggregatorOutput> implement
@Override
public void onDisconnected() {
SensorHandler.this.aggregator = null;
batteryLevel = null;
sensorManager.onChange();
}
@Override
public void onBatteryLevelReceived(int percentage) {
batteryLevel = percentage;
sensorManager.onChange();
}
@Nullable
public Integer getBatteryLevel() {
return batteryLevel;
}
}
@@ -12,6 +12,8 @@ import androidx.annotation.VisibleForTesting;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.BatteryLevel;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Position;
@@ -239,6 +241,9 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
}
public SensorDataSet getSensorDataSet(Instant now) {
if (!isStarted()) {
return new SensorDataSet(new SensorData<>(Position.of(now), ""), null, null, null, null, null, null, null, null, null, null);
}
//We always need a Position with now
SensorData<Position> gpsSensorData = gpsHandler.getSensorData(now);
if (gpsSensorData != null && gpsSensorData.data() != null) {
@@ -247,6 +252,13 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
gpsSensorData = new SensorData<>(Position.of(now), "");
}
BatteryLevel cadenceBattery = cyclingCadenceHandler.getBatteryLevel() != null
? BatteryLevel.of(cyclingCadenceHandler.getBatteryLevel()) : null;
BatteryLevel speedBattery = cyclingDistanceSpeedHandler.getBatteryLevel() != null
? BatteryLevel.of(cyclingDistanceSpeedHandler.getBatteryLevel()) : null;
BatteryLevel heartRateBattery = heartRateHandler.getBatteryLevel() != null
? BatteryLevel.of(heartRateHandler.getBatteryLevel()) : null;
return new SensorDataSet(
gpsSensorData,
getSpeed(now),
@@ -255,7 +267,10 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
temperatureHandler.getSensorData(now),
getCadence(now),
cyclingPowerHandler.getSensorData(now),
altitudeChangeHandler.getSensorData(now)
altitudeChangeHandler.getSensorData(now),
cadenceBattery,
speedBattery,
heartRateBattery
);
}
@@ -35,6 +35,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import de.dennisguse.opentracks.data.models.BatteryLevel;
import de.dennisguse.opentracks.sensors.BluetoothUtils;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
@@ -53,6 +54,9 @@ public class BluetoothDriver<T> implements Driver {
private final BluetoothAdapter bluetoothAdapter;
private BluetoothGatt bluetoothGatt;
private Context context;
private Handler handler;
private String address;
private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
@@ -68,10 +72,21 @@ public class BluetoothDriver<T> implements Driver {
case BluetoothProfile.STATE_DISCONNECTED -> {
//This is also triggered, if no connection was established (ca. 30s)
Log.i(TAG, gatt.getDevice() + ": disconnected from sensor: trying to reconnect");
if (gatt.connect()) {
Log.e(TAG, gatt.getDevice() + ": could not trigger reconnect for sensor");
}
observer.onConnectionLost();
synchronized (BluetoothDriver.this) {
gatt.close();
if (bluetoothGatt == gatt) {
bluetoothGatt = null;
}
if (context != null && handler != null && address != null) {
final Context ctx = context;
final Handler hnd = handler;
final String addr = address;
hnd.post(() -> connect(ctx, hnd, addr));
}
}
}
}
}
@@ -116,9 +131,39 @@ public class BluetoothDriver<T> implements Driver {
Log.e(TAG, "CLIENT_CHARACTERISTIC_CONFIG_UUID descriptor could not be written");
}
// NOTE: battery read is triggered in onDescriptorWrite, after this write completes.
// Attempting readCharacteristic here would fail silently because GATT
// allows only one pending operation at a time.
observer.onConnected(device.getAddress(), device.getName());
}
@Override
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
// After the notification descriptor write completes, try to read the battery level.
// This is the correct moment — GATT is idle and can process a new operation.
BluetoothGattService batteryService = gatt.getService(BluetoothUtils.BATTERY.serviceUUID());
if (batteryService != null) {
BluetoothGattCharacteristic batteryChar = batteryService.getCharacteristic(BluetoothUtils.BATTERY.measurementUUID());
if (batteryChar != null) {
boolean queued = gatt.readCharacteristic(batteryChar);
Log.d(TAG, gatt.getDevice() + ": battery read queued=" + queued);
}
}
}
@Override
public void onCharacteristicRead(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic, int status) {
if (status == BluetoothGatt.GATT_SUCCESS
&& characteristic.getUuid().equals(BluetoothUtils.BATTERY.measurementUUID())) {
BatteryLevel level = BluetoothUtils.parseBatteryLevel(characteristic);
if (level != null) {
Log.d(TAG, gatt.getDevice() + ": battery level " + level.percentage() + "%");
observer.onBatteryLevelReceived(level.percentage());
}
}
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) {
UUID serviceUUID = characteristic.getService().getUuid();
@@ -169,6 +214,10 @@ public class BluetoothDriver<T> implements Driver {
Log.d(TAG, device + ": trying to connect");
this.context = context;
this.handler = handler;
this.address = address;
bluetoothGatt = device.connectGatt(context, false, connectCallback, BluetoothDevice.TRANSPORT_AUTO, 0, handler);
}
@@ -184,6 +233,10 @@ public class BluetoothDriver<T> implements Driver {
Log.i(TAG, bluetoothGatt.getDevice() + ": disconnect finished");
bluetoothGatt = null;
this.context = null;
this.handler = null;
this.address = null;
observer.onDisconnected();
}
@@ -28,4 +28,10 @@ public interface DriverObserver<T> {
* For example, the user deactivated the internal GPS.
*/
default void onSensorDeactivated() {}
/**
* The Driver read the battery level from the Battery Service (0x180F).
* Only called if the device exposes the Battery Service.
*/
default void onBatteryLevelReceived(int percentage) {}
}
@@ -5,6 +5,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.UintUtils;
@@ -18,17 +19,20 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
super(sensorAddress, sensorName);
}
private Instant lastValidUpdate;
@Override
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CyclingCadenceMeasurement> current) {
if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) {
output = directCadenceData.cadence();
lastValidUpdate = current.time();
return;
}
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value());
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value(), current.time());
}
private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw) {
private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw, Instant now) {
CyclingCadenceBluetooth.CrankData previousRaw = getPreviousRaw();
if (previousRaw == null) return;
@@ -36,6 +40,9 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
if (timeDiff.isZero()) {
if (lastValidUpdate != null && Duration.between(lastValidUpdate, now).compareTo(getMaxSensorAge()) > 0) {
resetOutdated();
}
return;
}
if (timeDiff.isNegative()) {
@@ -52,9 +59,15 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
}
long crankDiff = UintUtils.diff(currentRaw.crankRevolutionsCount(), previousRaw.crankRevolutionsCount(), UintUtils.UINT32_MAX);
lastValidUpdate = now;
output = Cadence.of(crankDiff, timeDiff);
}
@Override
protected void resetOutdated() {
output = Cadence.of(0f);
}
private CyclingCadenceBluetooth.CrankData getPreviousRaw() {
if (previous == null) {
return null;
@@ -5,6 +5,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
@@ -21,9 +22,12 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
super(sensorAddress, sensorName);
}
private Instant lastValidUpdate;
@Override
protected void computeValue(@NonNull Raw<CyclingDistanceSpeedBluetooth.WheelData> current) {
if (previous == null) {
lastValidUpdate = current.time();
return;
}
@@ -31,6 +35,9 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
if (timeDiff.isZero()) {
if (lastValidUpdate != null && Duration.between(lastValidUpdate, current.time()).compareTo(getMaxSensorAge()) > 0) {
resetOutdated();
}
return;
}
if (timeDiff.isNegative()) {
@@ -52,6 +59,7 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
distanceOverall = distance.plus(output.distanceOverall);
}
Speed speed_mps = Speed.of(distance, timeDiff);
lastValidUpdate = current.time();
output = new Data(distance, distanceOverall, speed_mps);
}
@@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.BatteryLevel;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
@@ -27,6 +28,12 @@ public record SensorDataSet(
@Nullable
SensorData<Power> power,
@Nullable
SensorData<AltitudeGainLoss> altitudeGainLoss
SensorData<AltitudeGainLoss> altitudeGainLoss,
@Nullable
BatteryLevel cadenceBattery,
@Nullable
BatteryLevel speedBattery,
@Nullable
BatteryLevel heartRateBattery
) {
}
@@ -0,0 +1,174 @@
package de.dennisguse.opentracks.services;
import android.content.ComponentName;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.service.notification.NotificationListenerService;
import android.util.Log;
import android.view.KeyEvent;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.util.List;
public class MediaNotificationListenerService extends NotificationListenerService {
private static final String TAG = MediaNotificationListenerService.class.getSimpleName();
private MediaSessionManager mediaSessionManager;
private MediaController currentController;
private static final MutableLiveData<MediaData> mediaDataLiveData = new MutableLiveData<>(new MediaData(null, null, false, false));
private static MediaNotificationListenerService instance;
public static LiveData<MediaData> getMediaDataObservable() {
return mediaDataLiveData;
}
public static MediaNotificationListenerService getInstance() {
return instance;
}
private final MediaController.Callback mediaControllerCallback = new MediaController.Callback() {
@Override
public void onPlaybackStateChanged(PlaybackState state) {
updateMediaData();
}
@Override
public void onMetadataChanged(MediaMetadata metadata) {
updateMediaData();
}
};
private final MediaSessionManager.OnActiveSessionsChangedListener sessionsChangedListener = controllers -> {
Log.d(TAG, "Active sessions changed");
findActiveMediaController(controllers);
};
@Override
public void onCreate() {
super.onCreate();
instance = this;
mediaSessionManager = (MediaSessionManager) getSystemService(MEDIA_SESSION_SERVICE);
}
@Override
public void onListenerConnected() {
super.onListenerConnected();
Log.d(TAG, "Listener connected");
mediaDataLiveData.postValue(new MediaData(null, null, false, true));
try {
ComponentName componentName = new ComponentName(this, MediaNotificationListenerService.class);
mediaSessionManager.addOnActiveSessionsChangedListener(sessionsChangedListener, componentName);
findActiveMediaController(mediaSessionManager.getActiveSessions(componentName));
} catch (SecurityException e) {
Log.e(TAG, "SecurityException while accessing MediaSessionManager", e);
}
}
@Override
public void onListenerDisconnected() {
super.onListenerDisconnected();
Log.d(TAG, "Listener disconnected");
mediaDataLiveData.postValue(new MediaData(null, null, false, false));
if (mediaSessionManager != null) {
mediaSessionManager.removeOnActiveSessionsChangedListener(sessionsChangedListener);
}
if (currentController != null) {
currentController.unregisterCallback(mediaControllerCallback);
currentController = null;
}
instance = null;
}
private void findActiveMediaController(List<MediaController> controllers) {
if (controllers == null || controllers.isEmpty()) {
if (currentController != null) {
currentController.unregisterCallback(mediaControllerCallback);
currentController = null;
}
updateMediaData();
return;
}
MediaController newController = controllers.get(0);
for (MediaController controller : controllers) {
PlaybackState state = controller.getPlaybackState();
if (state != null && (state.getState() == PlaybackState.STATE_PLAYING || state.getState() == PlaybackState.STATE_BUFFERING)) {
newController = controller;
break;
}
}
if (currentController != null && currentController.getSessionToken().equals(newController.getSessionToken())) {
// Already tracking this controller
updateMediaData();
return;
}
if (currentController != null) {
currentController.unregisterCallback(mediaControllerCallback);
}
currentController = newController;
currentController.registerCallback(mediaControllerCallback);
updateMediaData();
}
private void updateMediaData() {
if (currentController == null) {
mediaDataLiveData.postValue(new MediaData(null, null, false, true));
return;
}
MediaMetadata metadata = currentController.getMetadata();
PlaybackState playbackState = currentController.getPlaybackState();
String title = null;
String artist = null;
if (metadata != null) {
title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE);
artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
}
boolean isPlaying = false;
if (playbackState != null) {
isPlaying = playbackState.getState() == PlaybackState.STATE_PLAYING ||
playbackState.getState() == PlaybackState.STATE_BUFFERING;
}
mediaDataLiveData.postValue(new MediaData(title, artist, isPlaying, true));
}
public void sendMediaCommand(int keyCode) {
if (currentController != null) {
currentController.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
currentController.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
} else {
// Fallback if no active controller (will broadcast to the system)
android.media.AudioManager audioManager = (android.media.AudioManager) getSystemService(AUDIO_SERVICE);
if (audioManager != null) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
}
}
public static class MediaData {
public final String title;
public final String artist;
public final boolean isPlaying;
public final boolean isPermissionGranted;
public MediaData(String title, String artist, boolean isPlaying, boolean isPermissionGranted) {
this.title = title;
this.artist = artist;
this.isPlaying = isPlaying;
this.isPermissionGranted = isPermissionGranted;
}
}
}
@@ -16,9 +16,13 @@ public record RecordingData(
*/
TrackPoint latestTrackPoint,
SensorDataSet sensorDataSet,
Statistics currentSegment) {
Statistics currentSegment,
String autoPauseStatus,
String idleTimer,
String walkingTimer,
String markerTimer) {
public static final RecordingData NOT_RECORDING = new RecordingData(null, null, null, null);
public static final RecordingData NOT_RECORDING = new RecordingData(null, null, null, null, "", "", "", "");
public Statistics trackStatistics() {
return track.statistics();
@@ -36,10 +36,12 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
private static final AltitudeCorrectionManager ALTITUDE_CORRECTION_MANAGER = new AltitudeCorrectionManager();
private final Runnable ON_IDLE = this::onIdle;
private final Runnable ON_LONG_BREAK = this::onLongBreak;
private final ContentProviderUtils contentProviderUtils;
private final Context context;
private final IdleObserver idleObserver;
private final SensorDisconnectObserver sensorDisconnectObserver;
private final Handler handler;
@@ -59,9 +61,25 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
private TrackPoint lastStoredTrackPoint;
private TrackPoint lastStoredTrackPointWithLocation;
TrackRecordingManager(Context context, TrackPointCreator trackPointCreator, IdleObserver idleObserver, Handler handler) {
private boolean hasSensorBeenConnected = false;
private boolean isSensorDisconnectedWarningActive = false;
private boolean autoPauseOnCadence0;
private de.dennisguse.opentracks.data.models.Speed autoPauseCadence0Speed;
private boolean hasCadenceSensorBeenConnected = false;
private String autoPauseStatus = "N/A";
private java.time.Instant idleTimeoutScheduledAt;
private java.time.Instant walkingStartedAt;
private java.time.Instant idleStartedAt;
private boolean longBreakRecorded = false;
private boolean gpsOverriden = false;
TrackRecordingManager(Context context, TrackPointCreator trackPointCreator, IdleObserver idleObserver, SensorDisconnectObserver sensorDisconnectObserver, Handler handler) {
this.context = context;
this.idleObserver = idleObserver;
this.sensorDisconnectObserver = sensorDisconnectObserver;
this.trackPointCreator = trackPointCreator;
this.handler = handler;
contentProviderUtils = new ContentProviderUtils(context);
@@ -97,6 +115,23 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
onNewTrackPoint(segmentStartTrackPoint);
handler.post(() -> {
if (trackId != null) {
onIdle();
}
});
String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue());
if (!de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address)) {
handler.postDelayed(() -> {
if (trackId != null && !hasSensorBeenConnected && !isSensorDisconnectedWarningActive) {
isSensorDisconnectedWarningActive = true;
if (sensorDisconnectObserver != null) {
sensorDisconnectObserver.onSensorDisconnectedWarning(true);
}
}
}, 10000);
}
return trackId;
}
@@ -116,6 +151,23 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
reset();
handler.post(() -> {
if (this.trackId != null) {
onIdle();
}
});
String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue());
if (!de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address)) {
handler.postDelayed(() -> {
if (trackId != null && !hasSensorBeenConnected && !isSensorDisconnectedWarningActive) {
isSensorDisconnectedWarningActive = true;
if (sensorDisconnectObserver != null) {
sensorDisconnectObserver.onSensorDisconnectedWarning(true);
}
}
}, 10000);
}
return true;
}
@@ -126,11 +178,21 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
trackId = null;
trackStatisticsUpdater = null;
idleTimeoutScheduledAt = null;
walkingStartedAt = null;
idleStartedAt = null;
longBreakRecorded = false;
handler.removeCallbacks(ON_LONG_BREAK);
reset();
}
RecordingData getDataForUI() {
if (trackId == null) {
throw new RuntimeException("Requesting data if not recording should not be done.");
}
Pair<TrackPoint, SensorDataSet> current = trackPointCreator.createCurrentTrackPoint(lastTrackPointUIWithSpeed, lastTrackPointUIWithAltitude, lastStoredTrackPointWithLocation);
TrackPoint trackPoint = current.first;
TrackStatisticsUpdater tmpTrackStatisticsUpdater = new TrackStatisticsUpdater(trackStatisticsUpdater, trackPoint);
@@ -139,38 +201,120 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
Track track = contentProviderUtils.getTrack(trackId); //Get copy TODO: Avoid re-loading data from DB
if (track == null) {
Log.w(TAG, "Requesting data if not recording is taking place, should not be done.");
return null;
throw new RuntimeException("Data could not be loaded for TrackId " + trackId);
}
TrackBuilder trackBuilder = new TrackBuilder(track);
trackBuilder.setStatistics(tmpTrackStatisticsUpdater.getTrackStatistics());
return new RecordingData(trackBuilder.getTrack(), TrackPointWithEGM2008, current.second, tmpTrackStatisticsUpdater.getCurrentSegment());
java.time.Instant now = java.time.Instant.now();
String idleTimer = "";
if (idleTimeoutScheduledAt != null && !"IDLE".equals(autoPauseStatus) && !"WALKING".equals(autoPauseStatus)) {
java.time.Duration d = java.time.Duration.between(now, idleTimeoutScheduledAt);
if (!d.isNegative()) idleTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d);
}
String walkingTimer = "";
if (walkingStartedAt != null && "WALKING".equals(autoPauseStatus)) {
java.time.Duration d = java.time.Duration.between(walkingStartedAt, now);
if (!d.isNegative()) walkingTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d);
}
String markerTimer = "";
if (idleStartedAt != null && "IDLE".equals(autoPauseStatus)) {
java.time.Duration d = java.time.Duration.between(idleStartedAt, now);
java.time.Duration markerDuration = de.dennisguse.opentracks.settings.PreferencesUtils.getMarkerIdleDuration();
if (longBreakRecorded || d.compareTo(markerDuration) >= 0) {
d = markerDuration;
}
if (!d.isNegative()) markerTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d);
}
return new RecordingData(trackBuilder.getTrack(), TrackPointWithEGM2008, current.second, tmpTrackStatisticsUpdater.getCurrentSegment(), autoPauseStatus, idleTimer, walkingTimer, markerTimer);
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public synchronized void onIdle() {
Log.d(TAG, "Becoming idle");
onNewTrackPoint(trackPointCreator.createIdle());
TrackPoint idlePoint = trackPointCreator.createIdle();
boolean preventIdle = de.dennisguse.opentracks.settings.PreferencesUtils.shouldPreventIdleIfCadence();
if (preventIdle && hasCadenceSensorBeenConnected) {
if (idlePoint.cadence() != null && idlePoint.cadence().value_rpm() > 0) {
scheduleNewIdleTimeout();
return;
}
}
if (!"IDLE".equals(autoPauseStatus)) {
idleStartedAt = java.time.Instant.now();
longBreakRecorded = false;
java.time.Duration markerDuration = de.dennisguse.opentracks.settings.PreferencesUtils.getMarkerIdleDuration();
handler.removeCallbacks(ON_LONG_BREAK);
handler.postDelayed(ON_LONG_BREAK, markerDuration.toMillis());
}
autoPauseStatus = "IDLE";
onNewTrackPoint(idlePoint);
idleObserver.onIdle();
}
private synchronized void onLongBreak() {
if ("IDLE".equals(autoPauseStatus) && idleStartedAt != null && !longBreakRecorded) {
longBreakRecorded = true;
TrackPoint trackPoint = trackPointCreator.createSegmentStartManual();
insertTrackPoint(trackPoint, true);
idleObserver.onLongBreak();
}
}
/**
* @return TrackPoint was stored?
*/
synchronized boolean onNewTrackPoint(@NonNull TrackPoint trackPoint) {
if (trackPoint.position().hasSpeed()) {
lastTrackPointUIWithSpeed = trackPoint;
}
if (trackPoint.position().hasAltitude()) {
lastTrackPointUIWithAltitude = trackPoint;
}
boolean sensorHasDistance = trackPoint.sensorDistance() != null;
if (sensorHasDistance) {
hasSensorBeenConnected = true;
if (isSensorDisconnectedWarningActive) {
isSensorDisconnectedWarningActive = false;
sensorDisconnectObserver.onSensorDisconnectedWarning(false);
sensorDisconnectObserver.onSensorConnected();
}
} else if (hasSensorBeenConnected) {
if (!isSensorDisconnectedWarningActive) {
isSensorDisconnectedWarningActive = true;
sensorDisconnectObserver.onSensorDisconnectedWarning(true);
}
}
String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue());
boolean isSensorExpected = !de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address);
boolean strictSensorMode = isSensorExpected && !sensorHasDistance && !gpsOverriden;
if (strictSensorMode) {
if (trackPoint.position().hasSpeed()) {
trackPoint = trackPoint.with(trackPoint.position().with(de.dennisguse.opentracks.data.models.Speed.ZERO));
}
}
if (trackPoint.position().hasSpeed()) {
lastTrackPointUIWithSpeed = trackPoint;
}
if (trackPoint.type() == TrackPoint.Type.IDLE) {
longBreakRecorded = false;
insertTrackPoint(trackPoint, true);
handler.removeCallbacks(ON_IDLE);
long durationMs = PreferencesUtils.getMarkerIdleDuration().toMillis();
if (durationMs > 0) {
handler.postDelayed(ON_LONG_BREAK, durationMs);
}
return true;
}
//Storing trackPoint
@@ -201,11 +345,37 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
return true;
}
if (trackPoint.cadence() != null) {
hasCadenceSensorBeenConnected = true;
}
boolean isWalkingWithBike = false;
if (autoPauseOnCadence0 && hasCadenceSensorBeenConnected && autoPauseCadence0Speed != null) {
if (trackPoint.cadence() == null || trackPoint.cadence().getRPM() == 0) {
if (trackPoint.position().hasSpeed() && trackPoint.position().speed().toMPS() > 0 && trackPoint.position().speed().lessThan(autoPauseCadence0Speed)) {
isWalkingWithBike = true;
}
}
}
if (!isWalkingWithBike && "WALKING".equals(autoPauseStatus)) {
autoPauseStatus = "REC";
walkingStartedAt = null;
scheduleNewIdleTimeout();
}
Distance distanceToLastStoredTrackPoint;
if (trackPoint.position().hasLocation() && !lastStoredTrackPoint.position().hasLocation()) {
distanceToLastStoredTrackPoint = trackPoint.distanceToPreviousFromLocation(lastStoredTrackPointWithLocation);
if (isWalkingWithBike || strictSensorMode) {
distanceToLastStoredTrackPoint = Distance.ZERO;
if (isWalkingWithBike) {
autoPauseStatus = "WALKING";
}
} else {
distanceToLastStoredTrackPoint = trackPoint.distanceToPrevious(lastStoredTrackPoint);
if (trackPoint.position().hasLocation() && !lastStoredTrackPoint.position().hasLocation()) {
distanceToLastStoredTrackPoint = trackPoint.distanceToPreviousFromLocation(lastStoredTrackPointWithLocation);
} else {
distanceToLastStoredTrackPoint = trackPoint.distanceToPrevious(lastStoredTrackPoint);
}
}
if (distanceToLastStoredTrackPoint.greaterThan(maxRecordingDistance)) {
@@ -213,6 +383,20 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
insertTrackPoint(trackPoint, true);
scheduleNewIdleTimeout();
if (!isWalkingWithBike) {
if ("IDLE".equals(autoPauseStatus)) {
handler.removeCallbacks(ON_LONG_BREAK);
}
autoPauseStatus = "REC";
} else {
if (!"WALKING".equals(autoPauseStatus)) {
walkingStartedAt = java.time.Instant.now();
if ("IDLE".equals(autoPauseStatus)) {
handler.removeCallbacks(ON_LONG_BREAK);
}
}
autoPauseStatus = "WALKING";
}
return true;
}
@@ -220,6 +404,20 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
insertTrackPoint(trackPoint, false);
scheduleNewIdleTimeout();
if (!isWalkingWithBike) {
if ("IDLE".equals(autoPauseStatus)) {
handler.removeCallbacks(ON_LONG_BREAK);
}
autoPauseStatus = "REC";
} else {
if (!"WALKING".equals(autoPauseStatus)) {
walkingStartedAt = java.time.Instant.now();
if ("IDLE".equals(autoPauseStatus)) {
handler.removeCallbacks(ON_LONG_BREAK);
}
}
autoPauseStatus = "WALKING";
}
return true;
}
@@ -236,6 +434,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
}
handler.removeCallbacks(ON_IDLE);
handler.postDelayed(ON_IDLE, idleDuration.toMillis());
idleTimeoutScheduledAt = java.time.Instant.now().plus(idleDuration);
}
Statistics getTrackStatistics() {
@@ -285,6 +484,13 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
lastStoredTrackPoint = null;
lastStoredTrackPointWithLocation = null;
hasSensorBeenConnected = false;
isSensorDisconnectedWarningActive = false;
hasCadenceSensorBeenConnected = false;
if (sensorDisconnectObserver != null) {
sensorDisconnectObserver.onSensorDisconnectedWarning(false);
}
}
@Override
@@ -298,13 +504,36 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
if (PreferencesUtils.isKey(R.string.idle_duration_key, key)) {
idleDuration = PreferencesUtils.getIdleDurationTimeout();
}
if (PreferencesUtils.isKey(R.string.settings_recording_auto_pause_on_cadence_0_key, key)) {
autoPauseOnCadence0 = PreferencesUtils.shouldAutoPauseOnCadence0();
}
if (PreferencesUtils.isKey(R.string.settings_recording_auto_pause_on_cadence_0_speed_key, key)) {
autoPauseCadence0Speed = PreferencesUtils.getAutoPauseCadence0Speed();
}
}
public TrackPoint getLastStoredTrackPointWithLocation() {
return lastStoredTrackPointWithLocation;
}
public void setOverrideSensorWithGPS(boolean override) {
if (override) {
hasSensorBeenConnected = false;
gpsOverriden = true;
if (isSensorDisconnectedWarningActive) {
isSensorDisconnectedWarningActive = false;
sensorDisconnectObserver.onSensorDisconnectedWarning(false);
}
}
}
public interface IdleObserver {
void onIdle();
void onLongBreak();
}
public interface SensorDisconnectObserver {
void onSensorDisconnectedWarning(boolean show);
void onSensorConnected();
}
}
@@ -44,7 +44,7 @@ import de.dennisguse.opentracks.services.announcement.VoiceAnnouncementManager;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
public class TrackRecordingService extends Service implements TrackPointCreator.Callback, SharedPreferences.OnSharedPreferenceChangeListener, TrackRecordingManager.IdleObserver {
public class TrackRecordingService extends Service implements TrackPointCreator.Callback, SharedPreferences.OnSharedPreferenceChangeListener, TrackRecordingManager.IdleObserver, TrackRecordingManager.SensorDisconnectObserver {
private static final String TAG = TrackRecordingService.class.getSimpleName();
@@ -84,6 +84,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
private RecordingStatus recordingStatus;
private MutableLiveData<RecordingStatus> recordingStatusObservable;
private MutableLiveData<GpsStatusValue> gpsStatusObservable;
private MutableLiveData<Boolean> sensorDisconnectedWarningObservable;
private MutableLiveData<RecordingData> recordingDataObservable;
private Handler handler;
@@ -104,10 +105,11 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
recordingStatusObservable = new MutableLiveData<>();
updateRecordingStatus(STATUS_DEFAULT);
gpsStatusObservable = new MutableLiveData<>(STATUS_GPS_DEFAULT);
sensorDisconnectedWarningObservable = new MutableLiveData<>(false);
recordingDataObservable = new MutableLiveData<>(RecordingData.NOT_RECORDING);
trackPointCreator = new TrackPointCreator(this);
trackRecordingManager = new TrackRecordingManager(this, trackPointCreator, this, handler);
trackRecordingManager = new TrackRecordingManager(this, trackPointCreator, this, this, handler);
voiceAnnouncementManager = new VoiceAnnouncementManager(this);
notificationManager = new TrackRecordingServiceNotificationManager(this);
@@ -138,6 +140,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
recordingStatusObservable = null;
gpsStatusObservable = null;
sensorDisconnectedWarningObservable = null;
recordingDataObservable = null;
Log.d(TAG, "Destroyed");
@@ -315,6 +318,10 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
return gpsStatusObservable;
}
public LiveData<Boolean> getSensorDisconnectedWarningObservable() {
return sensorDisconnectedWarningObservable;
}
public LiveData<RecordingData> getRecordingDataObservable() {
return recordingDataObservable;
}
@@ -337,6 +344,32 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
voiceAnnouncementManager.announceIdle();
}
@Override
public void onLongBreak() {
voiceAnnouncementManager.announceLongBreak();
}
@Override
public void onSensorDisconnectedWarning(boolean show) {
if (show) {
voiceAnnouncementManager.announceSensorDisconnected();
}
if (sensorDisconnectedWarningObservable != null) {
sensorDisconnectedWarningObservable.postValue(show);
}
}
@Override
public void onSensorConnected() {
voiceAnnouncementManager.announceSensorConnected();
}
public void setOverrideSensorWithGPS(boolean override) {
if (trackRecordingManager != null) {
trackRecordingManager.setOverrideSensorWithGPS(override);
}
}
@VisibleForTesting
public void stopUpdateRecordingData() {
handler.removeCallbacks(updateRecordingData);
@@ -119,6 +119,30 @@ public class VoiceAnnouncementManager implements SharedPreferences.OnSharedPrefe
voiceAnnouncement.speak(VoiceAnnouncementUtils.createIdle(context));
}
public void announceLongBreak() {
if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementLongBreak()) {
return;
}
voiceAnnouncement.speak(VoiceAnnouncementUtils.createLongBreak(context));
}
public void announceSensorDisconnected() {
if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementSensorDisconnected()) {
return;
}
voiceAnnouncement.speak(VoiceAnnouncementUtils.createSensorDisconnected(context));
}
public void announceSensorConnected() {
if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementSensorConnected()) {
return;
}
voiceAnnouncement.speak(VoiceAnnouncementUtils.createSensorConnected(context));
}
public void announceStatisticsIfNeeded(@NonNull RecordingData data) {
if (shouldNotAnnounce()) {
return;
@@ -50,6 +50,21 @@ class VoiceAnnouncementUtils {
.append(context.getString(R.string.voiceIdle));
}
static Spannable createLongBreak(Context context) {
return new SpannableStringBuilder()
.append(context.getString(R.string.voiceLongBreak));
}
static Spannable createSensorDisconnected(Context context) {
return new SpannableStringBuilder()
.append(context.getString(R.string.voiceSensorDisconnected));
}
static Spannable createSensorConnected(Context context) {
return new SpannableStringBuilder()
.append(context.getString(R.string.voiceSensorConnected));
}
static Spannable createStatistics(Context context, Track track, SensorDataSet sensorDataSet, UnitSystem unitSystem, boolean isReportSpeed, @Nullable Statistics currentInterval, @Nullable SensorStatistics sensorStatistics) {
Statistics trackStatistics = track.statistics();
@@ -101,8 +101,11 @@ public class TrackPointCreator implements SharedPreferences.OnSharedPreferenceCh
position = position.withCoordinates(lastStoredTrackPointWithLocation.position());
}
if (lastTrackPointUISpeed != null)
if (sensorDataSet.speed() != null) {
position = position.with(sensorDataSet.speed().data());
} else if (lastTrackPointUISpeed != null) {
position = position.with(lastTrackPointUISpeed.position().speed());
}
if (lastTrackPointUIAltitude != null)
position = position.with(lastTrackPointUIAltitude.position().altitude());
@@ -38,11 +38,13 @@ import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.DistanceFormatter;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.HeartRateZones;
import de.dennisguse.opentracks.data.models.Track;
@@ -248,6 +250,10 @@ public class PreferencesUtils {
setString(R.string.stats_units_key, unitSystem.getPreferenceId());
}
public static Duration getMarkerIdleDuration() {
return Duration.ofMinutes(getInt(R.string.marker_idle_duration_key, 3));
}
//TODO Check if actually needed or can be superseeded by a flexible default in getUnit()
public static void applyDefaultUnit() {
if (getString(R.string.stats_units_key, "").isEmpty()) {
@@ -341,6 +347,20 @@ public class PreferencesUtils {
return getBoolean(R.string.voice_announce_idle_key, true);
}
public static boolean shouldVoiceAnnouncementLongBreak() {
return getBoolean(R.string.voice_announce_long_break_key, true);
}
public static boolean shouldVoiceAnnouncementSensorDisconnected() {
return getBoolean(R.string.voice_announce_sensor_disconnected_key, true);
}
public static boolean shouldVoiceAnnouncementSensorConnected() {
return getBoolean(R.string.voice_announce_sensor_connected_key, true);
}
public static boolean shouldVoiceAnnounceTime() {
return getBoolean(R.string.voice_announce_time_key, true);
}
@@ -539,6 +559,7 @@ public class PreferencesUtils {
String[] entries = new String[entryValues.length];
final int maxRecordingDistanceDefault = Integer.parseInt(resources.getString(R.string.max_recording_distance_default));
final int maxRecordingDistanceOff = Integer.parseInt(resources.getString(R.string.max_recording_distance_off));
UnitSystem unitSystem = getUnitSystem();
DistanceFormatter formatter = DistanceFormatter.Builder()
@@ -555,6 +576,9 @@ public class PreferencesUtils {
case METRIC, IMPERIAL_METER -> {
if (value == maxRecordingDistanceDefault) {
entries[i] = resources.getString(R.string.value_integer_meter_recommended, value);
}
if (value == maxRecordingDistanceOff) {
entries[i] = resources.getString(R.string.value_off);
} else {
entries[i] = displayValue;
}
@@ -562,6 +586,9 @@ public class PreferencesUtils {
case IMPERIAL_FEET, NAUTICAL_IMPERIAL -> {
if (value == maxRecordingDistanceDefault) {
entries[i] = resources.getString(R.string.value_integer_feet_recommended, (int) distance.toFT());
}
if (value == maxRecordingDistanceOff) {
entries[i] = resources.getString(R.string.value_off);
} else {
entries[i] = displayValue;
}
@@ -681,6 +708,51 @@ public class PreferencesUtils {
return entries;
}
static String[] getMarkerIdleDurationEntries() {
String[] entryValues = resources.getStringArray(R.array.marker_idle_duration_values);
String[] entries = new String[entryValues.length];
final int defaultDuration = Integer.parseInt(resources.getString(R.string.marker_idle_duration_default));
for (int i = 0; i < entryValues.length; i++) {
int value = Integer.parseInt(entryValues[i]);
if (value == defaultDuration) {
entries[i] = resources.getString(R.string.value_integer_minute_recommended, value);
} else {
entries[i] = resources.getString(R.string.value_integer_minute, value);
}
}
return entries;
}
public static boolean shouldAutoPauseOnCadence0() {
return getBoolean(R.string.settings_recording_auto_pause_on_cadence_0_key, false);
}
public static boolean shouldPreventIdleIfCadence() {
return getBoolean(R.string.settings_recording_prevent_idle_if_cadence_key, true);
}
public static Speed getAutoPauseCadence0Speed() {
final int DEFAULT = Integer.parseInt(resources.getString(R.string.auto_pause_cadence_0_speed_default));
int value = getInt(R.string.settings_recording_auto_pause_on_cadence_0_speed_key, DEFAULT);
return Speed.ofKMH(value);
}
static String[] getAutoPauseCadence0SpeedEntries() {
String[] entryValues = resources.getStringArray(R.array.auto_pause_cadence_0_speed_values);
String[] entries = new String[entryValues.length];
for (int i = 0; i < entryValues.length; i++) {
int value = Integer.parseInt(entryValues[i]);
entries[i] = value + " km/h";
}
return entries;
}
public static boolean shouldInstantExportToFile() {
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri();
@@ -838,7 +910,15 @@ public class PreferencesUtils {
new DataField(resources.getString(R.string.stats_custom_layout_cadence_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_power_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_clock_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_temperature_key), false, false, false)
new DataField(resources.getString(R.string.stats_custom_layout_temperature_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_auto_pause_status_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_hr_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_cadence_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_speed_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_debug_idle_timer_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_debug_walking_timer_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_debug_marker_timer_key), false, false, false),
new DataField(resources.getString(R.string.stats_custom_layout_media_key), false, false, true)
);
RecordingLayout defaultLayout = new RecordingLayout(resources.getString(R.string.stats_custom_layout_default_layout), 2);
@@ -854,7 +934,7 @@ public class PreferencesUtils {
String selectedLayout = getSelectedLayoutName();
String csvCustomLayouts = getString(R.string.stats_custom_layouts_key, buildDefaultLayout());
List<RecordingLayout> recordingLayouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<RecordingLayout> recordingLayouts = getAllCustomLayouts();
RecordingLayout fallback = !recordingLayouts.isEmpty() ? recordingLayouts.get(0) : getDefaultLayout();
@@ -900,7 +980,18 @@ public class PreferencesUtils {
public static List<RecordingLayout> getAllCustomLayouts() {
String csvCustomLayouts = getString(R.string.stats_custom_layouts_key, buildDefaultLayout());
return RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<RecordingLayout> layouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<DataField> defaultFields = getDefaultLayout().getFields();
for (RecordingLayout layout : layouts) {
for (DataField df : defaultFields) {
boolean hasField = layout.getFields().stream().anyMatch(f -> f.getKey().equals(df.getKey()));
if (!hasField) {
layout.addField(new DataField(df.getKey(), false, false, df.isWide()));
}
}
}
return layouts;
}
public static void resetCustomLayoutPreferences() {
@@ -57,5 +57,15 @@ public class RecordingSettingsFragment extends PreferenceFragmentCompat {
ListPreference idleDuration = findPreference(getString(R.string.idle_duration_key));
idleDuration.setEntries(PreferencesUtils.getIdleDurationEntries());
ListPreference autoPauseCadence0Speed = findPreference(getString(R.string.settings_recording_auto_pause_on_cadence_0_speed_key));
if (autoPauseCadence0Speed != null) {
autoPauseCadence0Speed.setEntries(PreferencesUtils.getAutoPauseCadence0SpeedEntries());
}
ListPreference markerIdleDuration = findPreference(getString(R.string.marker_idle_duration_key));
if (markerIdleDuration != null) {
markerIdleDuration.setEntries(PreferencesUtils.getMarkerIdleDurationEntries());
}
}
}
@@ -40,6 +40,13 @@ public class RecordingLayoutIO {
Log.e(TAG, "Invalid CSV layout. It shouldn't happen: " + csvLine);
return recordingLayout;
}
String key = fieldParts[0];
if (key.equals(resources.getString(R.string.stats_custom_layout_sensor_status_key))) {
// Ignore deprecated sensor_status key to safely remove it from user's custom layout
continue;
}
recordingLayout.addField(fromCSV(fieldParts, resources));
}
return recordingLayout;
@@ -50,11 +57,24 @@ public class RecordingLayoutIO {
}
private static DataField fromCSV(String[] fieldParts, @NonNull Resources resources) {
boolean isWide;
String key = fieldParts[0];
// Force isWide to true for specific keys that must always be wide
if (key.equals(resources.getString(R.string.stats_custom_layout_coordinates_key)) ||
key.equals(resources.getString(R.string.stats_custom_layout_media_key))) {
isWide = true;
} else if (fieldParts.length > 3) {
isWide = YES_VALUE.equals(fieldParts[3]);
} else {
isWide = false;
}
return new DataField(
fieldParts[0],
key,
YES_VALUE.equals(fieldParts[1]),
YES_VALUE.equals(fieldParts[2]),
fieldParts[0].equals(resources.getString(R.string.stats_custom_layout_coordinates_key)));
isWide);
}
static String toCsv(DataField datafield) {
@@ -77,7 +77,7 @@ public class SettingsCustomLayoutEditAdapter extends RecyclerView.Adapter<Recycl
Speed.ZERO
);
demoData = new RecordingData(track, new TrackPoint(TrackPoint.Type.TRACKPOINT, position), null, statistics);
demoData = new RecordingData(track, new TrackPoint(TrackPoint.Type.TRACKPOINT, position), null, statistics, "", "", "", "");
}
private RecordingLayout recordingLayout;
@@ -4,6 +4,7 @@ import android.util.Pair;
import android.view.LayoutInflater;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.BatteryLevel;
import de.dennisguse.opentracks.data.models.DistanceFormatter;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.SpeedFormatter;
@@ -288,4 +289,86 @@ public abstract class GenericStatisticsViewHolder extends StatisticViewHolder<St
getBinding().statsDescriptionMain.setText(R.string.stats_coordinates);
}
}
public static class AutoPauseStatus extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
String value = data.autoPauseStatus();
if (value == null || value.isEmpty()) {
value = "N/A";
}
getBinding().statsValue.setText(value);
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_auto_pause_status));
}
}
public static class SensorStatusHR extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
String status = getContext().getString(R.string.value_unknown);
if (data.sensorDataSet() != null && data.sensorDataSet().heartRate() != null) {
BatteryLevel battery = data.sensorDataSet().heartRateBattery();
status = battery != null ? battery.percentage() + "%" : "OK";
}
getBinding().statsValue.setText(status);
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_sensor_status_hr));
}
}
public static class SensorStatusCadence extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
String status = getContext().getString(R.string.value_unknown);
if (data.sensorDataSet() != null && data.sensorDataSet().cadence() != null) {
BatteryLevel battery = data.sensorDataSet().cadenceBattery();
status = battery != null ? battery.percentage() + "%" : "OK";
}
getBinding().statsValue.setText(status);
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_sensor_status_cadence));
}
}
public static class SensorStatusSpeed extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
String status = getContext().getString(R.string.value_unknown);
if (data.sensorDataSet() != null && data.sensorDataSet().speed() != null) {
BatteryLevel battery = data.sensorDataSet().speedBattery();
status = battery != null ? battery.percentage() + "%" : "OK";
}
getBinding().statsValue.setText(status);
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_sensor_status_speed));
}
}
public static class DebugIdleTimer extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
getBinding().statsValue.setText(data.idleTimer().isEmpty() ? "---" : data.idleTimer());
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_debug_idle_timer));
}
}
public static class DebugWalkingTimer extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
getBinding().statsValue.setText(data.walkingTimer().isEmpty() ? "---" : data.walkingTimer());
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_debug_walking_timer));
}
}
public static class DebugMarkerTimer extends GenericStatisticsViewHolder {
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
getBinding().statsValue.setText(data.markerTimer().isEmpty() ? "---" : data.markerTimer());
getBinding().statsUnit.setText("");
getBinding().statsDescriptionMain.setText(getContext().getString(R.string.stats_debug_marker_timer));
}
}
}
@@ -38,6 +38,14 @@ public class Mapping {
m.put(context.getString(R.string.stats_custom_layout_cadence_key), SensorStatisticsViewHolder.SensorCadence::new);
m.put(context.getString(R.string.stats_custom_layout_power_key), SensorStatisticsViewHolder.SensorPower::new);
m.put(context.getString(R.string.stats_custom_layout_clock_key), ClockViewHolder::new);
m.put(context.getString(R.string.stats_custom_layout_auto_pause_status_key), GenericStatisticsViewHolder.AutoPauseStatus::new);
m.put(context.getString(R.string.stats_custom_layout_sensor_status_hr_key), GenericStatisticsViewHolder.SensorStatusHR::new);
m.put(context.getString(R.string.stats_custom_layout_sensor_status_cadence_key), GenericStatisticsViewHolder.SensorStatusCadence::new);
m.put(context.getString(R.string.stats_custom_layout_sensor_status_speed_key), GenericStatisticsViewHolder.SensorStatusSpeed::new);
m.put(context.getString(R.string.stats_custom_layout_debug_idle_timer_key), GenericStatisticsViewHolder.DebugIdleTimer::new);
m.put(context.getString(R.string.stats_custom_layout_debug_walking_timer_key), GenericStatisticsViewHolder.DebugWalkingTimer::new);
m.put(context.getString(R.string.stats_custom_layout_debug_marker_timer_key), GenericStatisticsViewHolder.DebugMarkerTimer::new);
m.put(context.getString(R.string.stats_custom_layout_media_key), MediaViewHolder::new);
return m;
}
@@ -0,0 +1,84 @@
package de.dennisguse.opentracks.viewmodels;
import android.content.Intent;
import android.provider.Settings;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import androidx.lifecycle.LifecycleOwner;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.databinding.StatsMediaItemBinding;
import de.dennisguse.opentracks.services.MediaNotificationListenerService;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.settings.UnitSystem;
import de.dennisguse.opentracks.ui.customRecordingLayout.DataField;
public class MediaViewHolder extends StatisticViewHolder<StatsMediaItemBinding> {
@Override
protected StatsMediaItemBinding createViewBinding(LayoutInflater inflater) {
return StatsMediaItemBinding.inflate(inflater);
}
@Override
public void configureUI(DataField dataField) {
getBinding().statsMediaPrev.setOnClickListener(v -> {
MediaNotificationListenerService service = MediaNotificationListenerService.getInstance();
if (service != null) {
service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_PREVIOUS);
}
});
getBinding().statsMediaNext.setOnClickListener(v -> {
MediaNotificationListenerService service = MediaNotificationListenerService.getInstance();
if (service != null) {
service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_NEXT);
}
});
getBinding().statsMediaPlayPause.setOnClickListener(v -> {
MediaNotificationListenerService service = MediaNotificationListenerService.getInstance();
if (service != null) {
service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE);
}
});
getBinding().statsMediaPermissionButton.setOnClickListener(v -> {
Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
});
if (getContext() instanceof LifecycleOwner) {
MediaNotificationListenerService.getMediaDataObservable().observe((LifecycleOwner) getContext(), mediaData -> {
if (mediaData.isPermissionGranted) {
getBinding().statsMediaPermissionButton.setVisibility(View.GONE);
getBinding().statsMediaControls.setVisibility(View.VISIBLE);
getBinding().statsMediaTitle.setVisibility(View.VISIBLE);
getBinding().statsMediaArtist.setVisibility(View.VISIBLE);
getBinding().statsMediaTitle.setText(mediaData.title != null ? mediaData.title : getContext().getString(R.string.media_title_default));
getBinding().statsMediaArtist.setText(mediaData.artist != null ? mediaData.artist : getContext().getString(R.string.media_artist_default));
if (mediaData.isPlaying) {
getBinding().statsMediaPlayPause.setImageResource(R.drawable.ic_baseline_pause_24); // Needs to exist, or fallback to something else
} else {
getBinding().statsMediaPlayPause.setImageResource(R.drawable.ic_baseline_play_arrow_24);
}
} else {
getBinding().statsMediaPermissionButton.setVisibility(View.VISIBLE);
getBinding().statsMediaControls.setVisibility(View.GONE);
getBinding().statsMediaTitle.setVisibility(View.GONE);
getBinding().statsMediaArtist.setVisibility(View.GONE);
}
});
}
}
@Override
public void onChanged(UnitSystem unitSystem, RecordingData data) {
// Not used, as we observe LiveData directly
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M8,5v14l11,-7z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,6h2v12H6zm3.5,6l8.5,6V6z"/>
</vector>
+89
View File
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/stats_media_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/media_title_default"
android:textAppearance="@style/TextAppearance.OpenTracks.PrimaryValue"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/stats_media_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/media_artist_default"
android:textAppearance="@style/TextAppearance.OpenTracks.SecondaryHeader"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/stats_media_title" />
<LinearLayout
android:id="@+id/stats_media_controls"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/stats_media_artist"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/stats_media_prev"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/media_prev"
android:src="@drawable/ic_baseline_skip_previous_24"
app:tint="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/stats_media_play_pause"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/media_play_pause"
android:src="@drawable/ic_baseline_play_arrow_24"
app:tint="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/stats_media_next"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/media_next"
android:src="@drawable/ic_baseline_skip_next_24"
app:tint="?attr/colorOnSurface" />
</LinearLayout>
<Button
android:id="@+id/stats_media_permission_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/media_permission_request"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show More