Compare commits

..

25 Commits

Author SHA1 Message Date
karol e26f102946 Revert "docs: refine README presentation with banner and fork overview"
This reverts commit 3670bb853e.
2026-10-24 12:53:55 +02:00
karol 3670bb853e docs: refine README presentation with banner and fork overview 2026-10-24 12:50:05 +02:00
karol 7a21b2f8e0 docs: standardize fork README and disclose AI-assisted development 2026-10-24 12:42:22 +02:00
karol 63e175c76a chore: rename applicationId and app name to isolate fork from official app 2026-09-18 20:31:23 +02:00
karol 8142073abd chore: trigger Gitea Actions 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
77 changed files with 4857 additions and 3822 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' # See https://codeberg.org/pstorch/ansible-storchp-de/src/branch/main/roles/runner/files/Dockerfile
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' # See https://codeberg.org/pstorch/ansible-storchp-de/src/branch/main/roles/runner/files/Dockerfile
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>
+56 -180
View File
@@ -1,208 +1,84 @@
# <img src="drawable-svg/LOGO.svg" alt="OpenTracks logo" height="40"></img> OpenTracks: a sport tracker
# OpenTracks — sensor-first fork
_OpenTracks_ is a sport tracking application that completely respects your privacy.
A customized fork of OpenTracks for sport tracking that prioritizes Bluetooth sensor measurements over GPS data.
[![Awesome Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://delightful.club/delightful-humane-design/#health)
**Upstream:** [OpenTracksApp/OpenTracks](https://codeberg.org/OpenTracksApp/OpenTracks).
**Fork repository:** [karol/OpenTracks](https://gitea.labus.uk/karol/OpenTracks).
<table>
<tr>
<th>Free (F-Droid)</th>
<th>Free (Nightly for F-Droid)</th>
<th>Donations</th>
</tr>
<tr>
<td align="center">
<a href="https://f-droid.org/packages/de.dennisguse.opentracks.playstore">
<img alt="Get it on F-Droid" src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="60" align="middle">
</a>
</td>
<td align="center">
<a href="https://fdroid.storchp.de/fdroid/repo?fingerprint=99985A7E73DCB0B16C9BDDCE7A0B4996F88068AE7C771ED53E217E69CD1FF196">
<img alt="Nightly builds (for F-Droid client)" src="https://opentracksapp.com/static/img/fdroid.storchp.de.png" height="90" align="middle">
</a>
</td>
<td align="center">
<a href="https://liberapay.com/OpenTracks/donate">
<img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg" height="45" align="middle">
</a>
</td>
</tr>
<tr>
<td align="center">
<img alt="OpenTracks version published on F-Droid" src="https://img.shields.io/f-droid/v/de.dennisguse.opentracks.playstore.svg" align="middle" >
</td>
<td></td>
<td align="center">
<a href="https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore">
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="60" align="middle">
</a>
</td>
</tr>
</table>
Signing key for reproducible build:
[Fork purpose](#fork-purpose) · [Changes from upstream](#changes-from-upstream) · [Requirements](#requirements) · [Getting started](#getting-started) · [Configuration](#configuration) · [Usage](#usage) · [Development](#development) · [Upstream maintenance](#upstream-maintenance) · [Documentation](#documentation) · [Credits](#credits) · [License](#license)
de.dennisguse.opentracks.playstore 87:2C:78:45:63:77:3B:3D:76:9E:C3:4C:EA:C4:E3:8F:1A:2F:15:EF:8E:D4:4A:61:21:4C:6D:9D:11:C8:8A:9B
## Fork purpose
Translations are hosted on <a href="https://translate.codeberg.org/engage/opentracks/">translate.codeberg.org/projects/opentracks/</a>.
<a href="https://translate.codeberg.org/engage/opentracks/">
<img src="https://translate.codeberg.org/widget/opentracks/opentracks-src-main-res-values-strings-xml-main/horizontal-auto.svg" alt="Translation status" />
</a>
Use hardware sensors as the primary source of cadence, speed, and distance, with corresponding changes to recording controls and audio behavior.
## Screenshots
## Changes from upstream
<div>
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot1.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot2.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot3.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot4.png">
</div>
<div>
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot5.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot6.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot7.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot8.png">
</div>
- Bluetooth sensor data takes priority over GPS in the modified recording flow.
- Expanded sensor settings and recording layouts.
- Media controls in the statistics view.
- Voice announcement and audio-focus changes.
- Fork-specific application IDs, including `de.dennisguse.opentracks.fork` and its build variants.
## Features
* __Tracking:__ track your sport and outdoor activities
* __Voice announcements__
* __Photos and Markers:__ mark interesting locations while tracking
* __Export:__
* export tracks either as [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) (incl. photos), [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html), or [GPX 1.1](https://www.topografix.com/GPX/1/1/)
* export automatically after each recording (e.g., to sync via [Nextcloud](https://nextcloud.com/))
* avoid duplication: each exported file contain a random unique identifier (i.e., `opentracks:trackid`)
* __Altitude:__
* gain/loss via barometric sensor (internal if present or via Bluetooth's Environmental Sensing Service)
* shown in EGM2008 (above mean sea level); exported as WGS84
* __Bluetooth LE sensors:__
* heart rate
* cycling: speed and distance
* cycling: cadence
* cycling: power meter
* running: speed and cadence
* support for BLE sensor training only (i.e., without GPS) for indoor training
## Requirements
An overview of tested sensors: [README_TESTED_SENSORS.md](README_TESTED_SENSORS.md)
- Android 8.0/API 26 or later for the app, as configured in `build.gradle`.
- Android SDK with platform 37 for builds.
- The Gradle wrapper; daemon toolchain settings request JetBrains JDK 21 (`gradle/gradle-daemon-jvm.properties`).
### Gadgetbridge integration
## Getting started
OpenTracks can be used with [Gadgetbridge](https://www.gadgetbridge.org/):
* shows statistics via notification on smart watches (requires Gadgetbridge 0.56.1 or later), and
* Gadgetbridge's GPX exporter generates `opentracks:trackid` to avoid duplication (Gadgetbridge 0.53.0 or later).
Download this fork's APKs from [Gitea releases](https://gitea.labus.uk/karol/OpenTracks/releases), or build a debug APK from the repository root:
### Privacy
* __No Internet access:__ Internet is not used
* __No advertising__
* __No in-app analytics__
* __No use of Google Play Services__
__Only required permissions:__
* _ACCESS_FINE_LOCATION_: required to use the GPS.
* _ACCESS_BACKGROUND_LOCATION_: required to start recording with GPS while phone is in standby. (e.g. when triggered by Public API from an external device)
### Public API
OpenTracks includes an API for starting/stopping recording by another installed application (e.g., [Automate](https://llamalab.com/automate/), [Tasker](https://tasker.joaoapps.com), or [Easer](https://github.com/renyuneyun/Easer)).
The API is disabled by default to protect the user's privacy, but it can easily be enabled in the settings. Once enabled, the API can be invoked by sending an explicit Intent to start an activity.
`Package` (depends on the variant installed):
* F-Droid: `de.dennisguse.opentracks`
* GooglePlay: `de.dennisguse.opentracks.playStore`
* Debug: `de.dennisguse.opentracks.debug`
* Nightly: `de.dennisguse.opentracks.nightly`
`Classes`:
* **Start a recording:** `de.dennisguse.opentracks.publicapi.StartRecording`
* Set track data: `TRACK_NAME`, `TRACK_DESCRIPTION`, `TRACK_CATEGORY`, and `TRACK_ICON` (
see `ActivityType.id` defined in [/src/main/java/de/dennisguse/opentracks/util/TrackIconUtils.java#L38](/src/main/java/de/dennisguse/opentracks/data/model/ActivityType.java)).
NOTE: if `TRACK_ICON` is not present, `TRACK_CATEGORY` will be used to determine the icon (
localized).
* Send recorded data to another application via _Dashboard API_: `STATS_TARGET_PACKAGE` and
`STATS_TARGET_CLASS`
* **Stop a recording:** `de.dennisguse.opentracks.publicapi.StopRecording`
* **Create a marker:** `de.dennisguse.opentracks.publicapi.CreateMarker`
For testing the API using adb, the general command syntax is:
```shell
adb shell am start -e someParameter someValue -n "package/class"
```bash
./gradlew assembleIrreproducibleDebug
```
Depending on the package and class, a complete command could look something like this:
Build output is under `build/outputs/apk/`. Store links in the inherited documentation distribute upstream OpenTracks and do not include this fork's changes.
```shell
adb shell am start -n "de.dennisguse.opentracks.playstore/de.dennisguse.opentracks.publicapi.StartRecording"
## Configuration
Configure Bluetooth sensors and recording options in the app settings. Verify the selected sensors before starting a recording.
The inherited public API examples use upstream package names. When targeting this fork, use the application ID of the installed variant from `build.gradle`; activity class names retain the `de.dennisguse.opentracks` namespace.
## Usage
Start a recording after pairing the desired sensors. See the [inherited feature and usage reference](README.upstream.md#features) for tracking, export formats, dashboards, backup, and supported sensor types.
The [screenshots](README.upstream.md#screenshots) are inherited documentation assets and may differ from the modified interface.
## Development
Run the checks configured by Gitea Actions:
```bash
./gradlew lint
./gradlew testNightlyDebugUnitTest --stacktrace
```
## File formats compatibility with open-source software
[CI configuration](.gitea/workflows/ci.yaml) defines build and prerelease publication behavior. Check the workflow files for toolchain and runner requirements.
KML/KMZ can be used to export indoor activities (no GPS present).
GPX exports only those points that have a GPS coordinate present (sensor data is aggregated though).
## Upstream maintenance
| | [GPX 1.1](https://www.topografix.com/GPX/1/1/) | [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) | [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) |
|-----------------------------------------------------------|--------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------|
| [OpenLayers 7.1.0](https://openlayers.org/) | ? | [no](https://github.com/openlayers/openlayers/issues/14104) | [no](https://github.com/openlayers/openlayers/issues/14104) |
| [Golden Cheetah 3.5](https://www.goldencheetah.org/) | ? | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) |
| [GpxPod](https://apps.nextcloud.com/apps/gpxpod) | ? | ? | ? |
| [OsmAnd](https://github.com/osmandapp/OsmAnd) | ? | [no](https://github.com/osmandapp/OsmAnd/issues/15271) | [no](https://github.com/osmandapp/OsmAnd/issues/15271) |
| [FitTrackee](https://github.com/SamR1/FitTrackee) | yes | n/a | n/a |
| [SportsTracker](https://github.com/ssaring/sportstracker) | yes, [single tracks only](https://github.com/ssaring/sportstracker/issues/260) | no | no |
| [ExifTool](https://exiftool.org) | [yes](https://exiftool.org/forum/index.php?topic=15972.0) | no | no |
| [Wanderer](https://wanderer.to/) | yes | yes | yes |
| [GPX.Studio]https://gpx.studio/) | yes | no | no |
The [upstream sync workflow](.gitea/workflows/auto-update.yaml) runs daily and on manual dispatch. It merges `main` from the configured Gitea upstream mirror into this fork's `main`. Resolve sync failures before retrying.
## Dashboard API (incl. map)
After an upstream merge, review sensor priority, recording layouts, announcements, and application IDs. Keep fork-specific instructions in this README. Refresh `README.upstream.md` separately from the merged upstream documentation, retaining upstream attribution and checking relative links.
As of v3.3.1, OpenTracks supports custom dashboards for displaying previously recorded and live
tracks.
## Documentation
The reference implementation is [OSMDashboard](https://codeberg.org/OpenTracksApp/OSMDashboard), which
presents an OpenStreetMap map (showing the current track, incl. updates). The Dashboard API is also
used by [Gadgetbridge](https://codeberg.org/Freeyourgadget/Gadgetbridge/) for displaying live track
statistics on supported wearables.
- [Inherited upstream README](README.upstream.md): retained from this checkout, with the local fork notice removed and malformed local links corrected.
- [Dashboard API](README_API.md).
- [Tested sensors](README_TESTED_SENSORS.md).
- [Upstream release instructions](README_RELEASE.md): review before applying to this fork.
Alternatively, recorded tracks can be shared as KMZ/GPX with installed applications (
e.g., [OsmAnd](https://play.google.com/store/apps/details?id=net.osmand)). However, this is rather
slow and does not provide updates while recording.
[README style guide](docs/README-STYLE.md).
The developer documentation is in [README_API.md](README_API.md).
## Credits
## Backup
Development of the changes maintained in this fork was carried out to a significant extent with assistance from AI coding agents, in collaboration with the repository owner.
OpenTracks stores the data in the app-internal space:
`/data/data/de.dennisguse.opentracks[|.playstore]`
OpenTracks is developed by its upstream contributors and derives from Google My Tracks. See the [project history](README.upstream.md#project-history) and [upstream contribution guidelines](CONTRIBUTING.md). Report fork-specific behavior in this fork's issue tracker.
There is a SQLite database `database.db` that contains the tracks as well as the markers.
Pictures attached to markers are stored as separate files.
## License
For backup and recovery, the best approach is to export as one KMZ file.
This will contain all the tracks, pictures and the data as similar as possible as stored internally.
In difference to backing up the database directly, a KMZ allows to be imported into a different (
e.g., newer) version of OpenTracks.
## Project history
_OpenTracks_ is based upon Google _My Tracks app_ ([code](https://code.google.com/archive/p/mytracks/)).
_My Tracks_ was initially released by Google in 2010 as [open-source software](http://google-latlong.blogspot.fr/2010/05/code-for-my-tracks-is-now-yours.html).
In 2016, [Google decided to discontinue](https://support.google.com/maps/answer/6333516) _My Tracks_ and stopped distributing it via the Google Play store in April 2016.
Then [Plonk42](https://github.com/plonk42) conducted some maintenance work until 2016, so _My Tracks_ could still be used (based upon version _Google's MyTracks_ version 2.0.6).
Plonk42's version is available [here](https://github.com/Plonk42/mytracks).
In 2019, _OpenTracks_ was forked from Plonk42's _My Tracks_ and major rework was conducted.
Rework of _OpenTracks_ included:
* removing Google's analytics code,
* removing integration into Google Drive,
* removing Google Maps integration,
* removing Google Earth integration,
* removing use of Google Play service,
* removing calorie estimation and activity estimation,
* removing support for ANT+ and Classic Bluetooth,
* adding support for Bluetooth LE heart rate sensors,
* removing Protobuf (store sensor data in SQLite columns directly), and
* removing Android Service API for other apps.
Artwork, logos and user interface remained more or less unchanged.
More information about _Google My Tracks_:
* [Wikipedia page](https://en.wikipedia.org/wiki/MyTracks)
* [Google code archive](https://code.google.com/archive/p/mytracks/)
See [LICENSE](LICENSE) for the inherited Apache License 2.0 terms and notices.
+205
View File
@@ -0,0 +1,205 @@
# <img src="drawable-svg/LOGO.svg" alt="OpenTracks logo" height="40"></img> OpenTracks: a sport tracker
_OpenTracks_ is a sport tracking application that completely respects your privacy.
[![Awesome Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://delightful.club/delightful-humane-design/#health)
<table>
<tr>
<th>Free (F-Droid)</th>
<th>Free (Nightly for F-Droid)</th>
<th>Donations</th>
</tr>
<tr>
<td align="center">
<a href="https://f-droid.org/packages/de.dennisguse.opentracks.playstore">
<img alt="Get it on F-Droid" src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="60" align="middle">
</a>
</td>
<td align="center">
<a href="https://fdroid.storchp.de/fdroid/repo?fingerprint=99985A7E73DCB0B16C9BDDCE7A0B4996F88068AE7C771ED53E217E69CD1FF196">
<img alt="Nightly builds (for F-Droid client)" src="https://opentracksapp.com/static/img/fdroid.storchp.de.png" height="90" align="middle">
</a>
</td>
<td align="center">
<a href="https://liberapay.com/OpenTracks/donate">
<img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg" height="45" align="middle">
</a>
</td>
</tr>
<tr>
<td align="center">
<img alt="OpenTracks version published on F-Droid" src="https://img.shields.io/f-droid/v/de.dennisguse.opentracks.playstore.svg" align="middle" >
</td>
<td></td>
<td align="center">
<a href="https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore">
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="60" align="middle">
</a>
</td>
</tr>
</table>
Translations are hosted on <a href="https://translate.codeberg.org/engage/opentracks/">translate.codeberg.org/projects/opentracks/</a>.
<a href="https://translate.codeberg.org/engage/opentracks/">
<img src="https://translate.codeberg.org/widget/opentracks/opentracks-src-main-res-values-strings-xml-main/horizontal-auto.svg" alt="Translation status" />
</a>
## Screenshots
<div>
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot1.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot2.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot3.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot4.png">
</div>
<div>
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot5.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot6.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot7.png">
<img width="23%" src="fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot8.png">
</div>
## Features
* __Tracking:__ track your sport and outdoor activities
* __Voice announcements__
* __Photos and Markers:__ mark interesting locations while tracking
* __Export:__
* export tracks either as [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) (incl. photos), [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html), or [GPX 1.1](https://www.topografix.com/GPX/1/1/)
* export automatically after each recording (e.g., to sync via [Nextcloud](https://nextcloud.com/))
* avoid duplication: each exported file contain a random unique identifier (i.e., `opentracks:trackid`)
* __Altitude:__
* gain/loss via barometric sensor (internal if present or via Bluetooth's Environmental Sensing Service)
* shown in EGM2008 (above mean sea level); exported as WGS84
* __Bluetooth LE sensors:__
* heart rate
* cycling: speed and distance
* cycling: cadence
* cycling: power meter
* running: speed and cadence
* support for BLE sensor training only (i.e., without GPS) for indoor training
An overview of tested sensors: [README_TESTED_SENSORS.md](README_TESTED_SENSORS.md)
### Gadgetbridge integration
OpenTracks can be used with [Gadgetbridge](https://www.gadgetbridge.org/):
* shows statistics via notification on smart watches (requires Gadgetbridge 0.56.1 or later), and
* Gadgetbridge's GPX exporter generates `opentracks:trackid` to avoid duplication (Gadgetbridge 0.53.0 or later).
### Privacy
* __No Internet access:__ Internet is not used
* __No advertising__
* __No in-app analytics__
* __No use of Google Play Services__
__Only required permissions:__
* _ACCESS_FINE_LOCATION_: required to use the GPS.
* _ACCESS_BACKGROUND_LOCATION_: required to start recording with GPS while phone is in standby. (e.g. when triggered by Public API from an external device)
### Public API
OpenTracks includes an API for starting/stopping recording by another installed application (e.g., [Automate](https://llamalab.com/automate/), [Tasker](https://tasker.joaoapps.com), or [Easer](https://github.com/renyuneyun/Easer)).
The API is disabled by default to protect the user's privacy, but it can easily be enabled in the settings. Once enabled, the API can be invoked by sending an explicit Intent to start an activity.
`Package` (depends on the variant installed):
* F-Droid: `de.dennisguse.opentracks`
* GooglePlay: `de.dennisguse.opentracks.playStore`
* Debug: `de.dennisguse.opentracks.debug`
* Nightly: `de.dennisguse.opentracks.nightly`
`Classes`:
* **Start a recording:** `de.dennisguse.opentracks.publicapi.StartRecording`
* Set track data: `TRACK_NAME`, `TRACK_DESCRIPTION`, `TRACK_CATEGORY`, and `TRACK_ICON` (
see `ActivityType.id` defined in [ActivityType.java](src/main/java/de/dennisguse/opentracks/data/models/ActivityType.java)).
NOTE: if `TRACK_ICON` is not present, `TRACK_CATEGORY` will be used to determine the icon (
localized).
* Send recorded data to another application via _Dashboard API_: `STATS_TARGET_PACKAGE` and
`STATS_TARGET_CLASS`
* **Stop a recording:** `de.dennisguse.opentracks.publicapi.StopRecording`
* **Create a marker:** `de.dennisguse.opentracks.publicapi.CreateMarker`
For testing the API using adb, the general command syntax is:
```shell
adb shell am start -e someParameter someValue -n "package/class"
```
Depending on the package and class, a complete command could look something like this:
```shell
adb shell am start -n "de.dennisguse.opentracks.playstore/de.dennisguse.opentracks.publicapi.StartRecording"
```
## File formats compatibility with open-source software
KML/KMZ can be used to export indoor activities (no GPS present).
GPX exports only those points that have a GPS coordinate present (sensor data is aggregated though).
| | [GPX 1.1](https://www.topografix.com/GPX/1/1/) | [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) | [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) |
|-----------------------------------------------------------|--------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------|
| [OpenLayers 7.1.0](https://openlayers.org/) | ? | [no](https://github.com/openlayers/openlayers/issues/14104) | [no](https://github.com/openlayers/openlayers/issues/14104) |
| [Golden Cheetah 3.5](https://www.goldencheetah.org/) | ? | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) |
| [GpxPod](https://apps.nextcloud.com/apps/gpxpod) | ? | ? | ? |
| [OsmAnd](https://github.com/osmandapp/OsmAnd) | ? | [no](https://github.com/osmandapp/OsmAnd/issues/15271) | [no](https://github.com/osmandapp/OsmAnd/issues/15271) |
| [FitTrackee](https://github.com/SamR1/FitTrackee) | yes | n/a | n/a |
| [SportsTracker](https://github.com/ssaring/sportstracker) | yes, [single tracks only](https://github.com/ssaring/sportstracker/issues/260) | no | no |
| [ExifTool](https://exiftool.org) | [yes](https://exiftool.org/forum/index.php?topic=15972.0) | no | no |
| [Wanderer](https://wanderer.to/) | yes | yes | yes |
| [GPX.Studio](https://gpx.studio/) | yes | no | no |
## Dashboard API (incl. map)
As of v3.3.1, OpenTracks supports custom dashboards for displaying previously recorded and live
tracks.
The reference implementation is [OSMDashboard](https://codeberg.org/OpenTracksApp/OSMDashboard), which
presents an OpenStreetMap map (showing the current track, incl. updates). The Dashboard API is also
used by [Gadgetbridge](https://codeberg.org/Freeyourgadget/Gadgetbridge/) for displaying live track
statistics on supported wearables.
Alternatively, recorded tracks can be shared as KMZ/GPX with installed applications (
e.g., [OsmAnd](https://play.google.com/store/apps/details?id=net.osmand)). However, this is rather
slow and does not provide updates while recording.
The developer documentation is in [README_API.md](README_API.md).
## Backup
OpenTracks stores the data in the app-internal space:
`/data/data/de.dennisguse.opentracks[|.playstore]`
There is a SQLite database `database.db` that contains the tracks as well as the markers.
Pictures attached to markers are stored as separate files.
For backup and recovery, the best approach is to export as one KMZ file.
This will contain all the tracks, pictures and the data as similar as possible as stored internally.
In difference to backing up the database directly, a KMZ allows to be imported into a different (
e.g., newer) version of OpenTracks.
## Project history
_OpenTracks_ is based upon Google _My Tracks app_ ([code](https://code.google.com/archive/p/mytracks/)).
_My Tracks_ was initially released by Google in 2010 as [open-source software](http://google-latlong.blogspot.fr/2010/05/code-for-my-tracks-is-now-yours.html).
In 2016, [Google decided to discontinue](https://support.google.com/maps/answer/6333516) _My Tracks_ and stopped distributing it via the Google Play store in April 2016.
Then [Plonk42](https://github.com/plonk42) conducted some maintenance work until 2016, so _My Tracks_ could still be used (based upon version _Google's MyTracks_ version 2.0.6).
Plonk42's version is available [here](https://github.com/Plonk42/mytracks).
In 2019, _OpenTracks_ was forked from Plonk42's _My Tracks_ and major rework was conducted.
Rework of _OpenTracks_ included:
* removing Google's analytics code,
* removing integration into Google Drive,
* removing Google Maps integration,
* removing Google Earth integration,
* removing use of Google Play service,
* removing calorie estimation and activity estimation,
* removing support for ANT+ and Classic Bluetooth,
* adding support for Bluetooth LE heart rate sensors,
* removing Protobuf (store sensor data in SQLite columns directly), and
* removing Android Service API for other apps.
Artwork, logos and user interface remained more or less unchanged.
More information about _Google My Tracks_:
* [Wikipedia page](https://en.wikipedia.org/wiki/MyTracks)
* [Google code archive](https://code.google.com/archive/p/mytracks/)
+13 -12
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.5.1'
classpath 'com.android.tools.build:gradle:9.4.0'
}
}
@@ -46,9 +46,9 @@ android {
}
defaultConfig {
applicationId 'de.dennisguse.opentracks'
versionCode 6766
versionName "v4.29.0"
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
}
@@ -151,11 +152,11 @@ dependencies {
implementation 'androidx.gridlayout:gridlayout:1.1.0'
implementation 'com.google.android.material:material:1.14.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
implementation 'androidx.core:core:1.19.1'
implementation 'androidx.core:core:1.19.0'
implementation 'androidx.core:core-splashscreen:1.2.0'
implementation 'androidx.mediarouter:mediarouter:1.8.1'
implementation 'androidx.core:core-location-altitude:1.0.1'
implementation 'androidx.work:work-runtime:2.12.0'
implementation 'androidx.core:core-location-altitude:1.0.0'
implementation 'androidx.work:work-runtime:2.11.2'
implementation 'androidx.health.connect:connect-client:1.1.0'
androidTestImplementation 'androidx.test:core:1.7.0'
-788
View File
@@ -1,788 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xsd:schema
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns="http://www.topografix.com/GPX/1/1"
targetNamespace="http://www.topografix.com/GPX/1/1"
elementFormDefault="qualified">
<xsd:annotation>
<xsd:documentation>
GPX schema version 1.1 - For more information on GPX and this schema, visit http://www.topografix.com/gpx.asp
GPX uses the following conventions: all coordinates are relative to the WGS84 datum. All measurements are in metric units.
</xsd:documentation>
</xsd:annotation>
<xsd:element name="gpx" type="gpxType">
<xsd:annotation>
<xsd:documentation>
GPX is the root element in the XML file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:complexType name="gpxType">
<xsd:annotation>
<xsd:documentation>
GPX documents contain a metadata header, followed by waypoints, routes, and tracks. You can add your own elements
to the extensions section of the GPX document.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence>
<xsd:element name="metadata" type="metadataType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Metadata about the file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="wpt" type="wptType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A list of waypoints.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="rte" type="rteType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A list of routes.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="trk" type="trkType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A list of tracks.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
<xsd:attribute name="version" type="xsd:string" use="required" fixed="1.1">
<xsd:annotation>
<xsd:documentation>
You must include the version number in your GPX document.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="creator" type="xsd:string" use="required">
<xsd:annotation>
<xsd:documentation>
You must include the name or URL of the software that created your GPX document. This allows others to
inform the creator of a GPX instance document that fails to validate.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="metadataType">
<xsd:annotation>
<xsd:documentation>
Information about the GPX file, author, and copyright restrictions goes in the metadata section. Providing rich,
meaningful information about your GPX files allows others to search for and use your GPS data.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="name" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The name of the GPX file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="desc" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
A description of the contents of the GPX file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="author" type="personType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The person or organization who created the GPX file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="copyright" type="copyrightType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Copyright and license information governing use of the file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="link" type="linkType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
URLs associated with the location described in the file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="time" type="xsd:dateTime" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The creation date of the file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="keywords" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Keywords associated with the file. Search engines or databases can use this information to classify the data.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="bounds" type="boundsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Minimum and maximum coordinates which describe the extent of the coordinates in the file.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="wptType">
<xsd:annotation>
<xsd:documentation>
wpt represents a waypoint, point of interest, or named feature on a map.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<!-- Position info -->
<xsd:element name="ele" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Elevation (in meters) of the point.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="time" type="xsd:dateTime" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Creation/modification timestamp for element. Date and time in are in Univeral Coordinated Time (UTC), not local time! Conforms to ISO 8601 specification for date/time representation. Fractional seconds are allowed for millisecond timing in tracklogs.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="magvar" type="degreesType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Magnetic variation (in degrees) at the point
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="geoidheight" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Height (in meters) of geoid (mean sea level) above WGS84 earth ellipsoid. As defined in NMEA GGA message.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<!-- Description info -->
<xsd:element name="name" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The GPS name of the waypoint. This field will be transferred to and from the GPS. GPX does not place restrictions on the length of this field or the characters contained in it. It is up to the receiving application to validate the field before sending it to the GPS.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="cmt" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS waypoint comment. Sent to GPS as comment.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="desc" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
A text description of the element. Holds additional information about the element intended for the user, not the GPS.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="src" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Source of data. Included to give user some idea of reliability and accuracy of data. "Garmin eTrex", "USGS quad Boston North", e.g.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="link" type="linkType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
Link to additional information about the waypoint.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="sym" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Text of GPS symbol name. For interchange with other programs, use the exact spelling of the symbol as displayed on the GPS. If the GPS abbreviates words, spell them out.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="type" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Type (classification) of the waypoint.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<!-- Accuracy info -->
<xsd:element name="fix" type="fixType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Type of GPX fix.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="sat" type="xsd:nonNegativeInteger" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Number of satellites used to calculate the GPX fix.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="hdop" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Horizontal dilution of precision.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="vdop" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Vertical dilution of precision.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="pdop" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Position dilution of precision.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="ageofdgpsdata" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Number of seconds since last DGPS update.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="dgpsid" type="dgpsStationType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
ID of DGPS station used in differential correction.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
<xsd:attribute name="lat" type="latitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The latitude of the point. This is always in decimal degrees, and always in WGS84 datum.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="lon" type="longitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The longitude of the point. This is always in decimal degrees, and always in WGS84 datum.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="rteType">
<xsd:annotation>
<xsd:documentation>
rte represents route - an ordered list of waypoints representing a series of turn points leading to a destination.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence>
<xsd:element name="name" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS name of route.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="cmt" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS comment for route.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="desc" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Text description of route for user. Not sent to GPS.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="src" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Source of data. Included to give user some idea of reliability and accuracy of data.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="link" type="linkType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
Links to external information about the route.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="number" type="xsd:nonNegativeInteger" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS route number.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="type" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Type (classification) of route.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="rtept" type="wptType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A list of route points.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="trkType">
<xsd:annotation>
<xsd:documentation>
trk represents a track - an ordered list of points describing a path.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence>
<xsd:element name="name" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS name of track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="cmt" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS comment for track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="desc" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
User description of track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="src" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Source of data. Included to give user some idea of reliability and accuracy of data.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="link" type="linkType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
Links to external information about track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="number" type="xsd:nonNegativeInteger" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
GPS track number.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="type" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Type (classification) of track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="trkseg" type="trksegType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A Track Segment holds a list of Track Points which are logically connected in order. To represent a single GPS track where GPS reception was lost, or the GPS receiver was turned off, start a new Track Segment for each continuous span of track data.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="extensionsType">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence>
<xsd:any namespace="##other" processContents="lax" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:any>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="trksegType">
<xsd:annotation>
<xsd:documentation>
A Track Segment holds a list of Track Points which are logically connected in order. To represent a single GPS track where GPS reception was lost, or the GPS receiver was turned off, start a new Track Segment for each continuous span of track data.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="trkpt" type="wptType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
A Track Point holds the coordinates, elevation, timestamp, and metadata for a single point in a track.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="extensions" type="extensionsType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
You can add extend GPX by adding your own elements from another schema here.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="copyrightType">
<xsd:annotation>
<xsd:documentation>
Information about the copyright holder and any license governing use of this file. By linking to an appropriate license,
you may place your data into the public domain or grant additional usage rights.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="year" type="xsd:gYear" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Year of copyright.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="license" type="xsd:anyURI" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Link to external file containing license text.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
<xsd:attribute name="author" type="xsd:string" use="required">
<xsd:annotation>
<xsd:documentation>
Copyright holder (TopoSoft, Inc.)
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="linkType">
<xsd:annotation>
<xsd:documentation>
A link to an external resource (Web page, digital photo, video clip, etc) with additional information.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="text" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Text of hyperlink.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="type" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Mime type of content (image/jpeg)
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
<xsd:attribute name="href" type="xsd:anyURI" use="required">
<xsd:annotation>
<xsd:documentation>
URL of hyperlink.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="emailType">
<xsd:annotation>
<xsd:documentation>
An email address. Broken into two parts (id and domain) to help prevent email harvesting.
</xsd:documentation>
</xsd:annotation>
<xsd:attribute name="id" type="xsd:string" use="required">
<xsd:annotation>
<xsd:documentation>
id half of email address (billgates2004)
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="domain" type="xsd:string" use="required">
<xsd:annotation>
<xsd:documentation>
domain half of email address (hotmail.com)
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="personType">
<xsd:annotation>
<xsd:documentation>
A person or organization.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="name" type="xsd:string" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Name of person or organization.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="email" type="emailType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Email address.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="link" type="linkType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Link to Web site or other external information about person.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="ptType">
<xsd:annotation>
<xsd:documentation>
A geographic point with optional elevation and time. Available for use by other schemas.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="ele" type="xsd:decimal" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The elevation (in meters) of the point.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="time" type="xsd:dateTime" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
The time that the point was recorded.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
<xsd:attribute name="lat" type="latitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The latitude of the point. Decimal degrees, WGS84 datum.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="lon" type="longitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The latitude of the point. Decimal degrees, WGS84 datum.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="ptsegType">
<xsd:annotation>
<xsd:documentation>
An ordered sequence of points. (for polygons or polylines, e.g.)
</xsd:documentation>
</xsd:annotation>
<xsd:sequence> <!-- elements must appear in this order -->
<xsd:element name="pt" type="ptType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
Ordered list of geographic points.
</xsd:documentation>
</xsd:annotation>
</xsd:element>
</xsd:sequence>
</xsd:complexType>
<xsd:complexType name="boundsType">
<xsd:annotation>
<xsd:documentation>
Two lat/lon pairs defining the extent of an element.
</xsd:documentation>
</xsd:annotation>
<xsd:attribute name="minlat" type="latitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The minimum latitude.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="minlon" type="longitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The minimum longitude.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="maxlat" type="latitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The maximum latitude.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="maxlon" type="longitudeType" use="required">
<xsd:annotation>
<xsd:documentation>
The maximum longitude.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
</xsd:complexType>
<xsd:simpleType name="latitudeType">
<xsd:annotation>
<xsd:documentation>
The latitude of the point. Decimal degrees, WGS84 datum.
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:decimal">
<xsd:minInclusive value="-90.0"/>
<xsd:maxInclusive value="90.0"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="longitudeType">
<xsd:annotation>
<xsd:documentation>
The longitude of the point. Decimal degrees, WGS84 datum.
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:decimal">
<xsd:minInclusive value="-180.0"/>
<xsd:maxExclusive value="180.0"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="degreesType">
<xsd:annotation>
<xsd:documentation>
Used for bearing, heading, course. Units are decimal degrees, true (not magnetic).
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:decimal">
<xsd:minInclusive value="0.0"/>
<xsd:maxExclusive value="360.0"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="fixType">
<xsd:annotation>
<xsd:documentation>
Type of GPS fix. none means GPS had no fix. To signify "the fix info is unknown, leave out fixType entirely. pps = military signal used
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:string">
<xsd:enumeration value="none"/>
<xsd:enumeration value="2d"/>
<xsd:enumeration value="3d"/>
<xsd:enumeration value="dgps"/>
<xsd:enumeration value="pps"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="dgpsStationType">
<xsd:annotation>
<xsd:documentation>
Represents a differential GPS station.
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:integer">
<xsd:minInclusive value="0"/>
<xsd:maxInclusive value="1023"/>
</xsd:restriction>
</xsd:simpleType>
</xsd:schema>
-2274
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
# Repository README standard
Version: 1.0. Documentation language: English.
A README should explain the project, help a new reader start it, and point to detailed references. Keep project-specific instructions and existing attribution when applying this standard.
## Original projects
Start with one `# Project name` heading and a short description of what the project does and who it serves. An optional local screenshot or banner may follow. Then add a navigation line linking to the level-two sections present in the document.
Use these level-two headings in this order. Omit sections that do not apply; do not add empty sections or filler just to match the template.
| Section | Content |
| --- | --- |
| Features | Concrete capabilities and supported use cases. |
| Requirements | Runtime, hardware, services, and build prerequisites; distinguish build from runtime needs. |
| Getting started | Shortest complete path from checkout or download to a working result, including initial setup. |
| Configuration | Settings, defaults, required values, and example files. Explain prerequisites before commands that need them. |
| Usage | Common workflows, commands, and expected output or artifact locations. |
| Architecture | Project layout, interfaces, data model, or hardware overview. |
| Limitations | Known constraints and validation boundaries; keep critical setup constraints near the affected step too. |
| Development | Build, test, validation, and release instructions supported by repository files. |
| Documentation | Links to detailed guides, diagrams, screenshots, and this style guide. |
| Credits | Contributors, reused work, upstream sources, and existing AI-assistance disclosures. |
| License | Link to the actual project license; state explicitly when no project-level license file exists. |
Keep specialized content under these headings using `###` and `####`. For example, put power variants under Configuration, pinouts under Architecture, and schematic downloads under Documentation or Architecture. Small component READMEs can use only the relevant sections and link back to the root README.
## Forks
Use the same formatting rules, but introduce the fork before describing the original project. Start with one title, a short fork description, and clearly labeled links to upstream and the fork repository.
Use these headings in order, omitting optional sections when unnecessary:
1. Fork purpose
2. Changes from upstream
3. Requirements
4. Getting started
5. Configuration
6. Usage
7. Architecture
8. Limitations
9. Development
10. Upstream maintenance
11. Documentation
12. Credits
13. License
Explain why the fork exists, list its differences, and identify where to obtain its builds. Clearly label links to upstream builds, contribution channels, and screenshots. Include upstream synchronization instructions based on the actual workflow, and checks needed after merges.
Keep inherited documentation in `README.upstream.md` at the repository root. Keeping it beside the original README preserves relative links to assets. Retain upstream attribution, donations, and license notices there. Label any local corrections; this file is inherited documentation, not a claim to be the latest upstream version. After an upstream merge, refresh the inherited file and review the fork README separately. Do not overwrite the fork's explanation with the upstream README.
Borrowing a component or taking inspiration from another project does not by itself make the entire repository a fork. Explain that relationship under Credits and preserve component licensing.
## Markdown and accuracy
- Write documentation in English. Preserve proper names and UI labels as shown in the product.
- Use one H1, sentence-case headings, and consecutive heading levels. Do not number headings.
- Separate paragraphs, headings, lists, tables, and fenced blocks with blank lines.
- Use `-` for unordered lists. Indent nested content consistently; use four spaces for nested unordered lists.
- Label fenced blocks: `bash`, `sh`, `powershell`, `python`, `json`, `yaml`, `dotenv`, `text`, or `mermaid` as appropriate.
- Run commands from the repository root unless a different working directory is explicitly stated.
- Use placeholders or portable example paths instead of private workstation paths. Never include real tokens, passwords, private keys, or local data.
- Link to versioned files using relative paths. Use full URLs for other repositories, services, and upstream sources. Avoid sibling-checkout links.
- Prefer Markdown. Use HTML only when it improves a gallery or layout; provide image alt text.
- Keep navigation anchors synchronized with headings. Avoid duplicate headings that create ambiguous anchors.
- Avoid decorative badges, repeated separators, promotional claims, and unsupported claims of production readiness or completed testing.
- Verify requirements and commands against manifests, scripts, configuration, and workflows. Distinguish previously documented results from checks performed during an edit.
- Preserve existing technical instructions, screenshots, credits, and license notices when reorganizing. Move long references into `docs/` when useful and update links relative to their new location.
- Never infer a license from a badge, framework, dependency, or a different repository. License choices require an explicit project decision.
## Maintenance
Update the README alongside changes to installation, configuration, commands, supported hardware, or release behavior. Check local links, navigation, and Markdown structure before committing. Run a command only when its effects are appropriate; a documentation check must not flash hardware, publish a release, send messages, or start an import.
View File
@@ -1,6 +0,0 @@
v4.29.0: OpenTracks
Changes:
- Removed "Export all (one file for all tracks)" functionality
For KML/KMZ, the markers are exported in such a way that on import OpenTracks cannot determine to which track a marker belonged.
Recommendation for backups: use KMZ with one track per file.
+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
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
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'
@@ -1,131 +0,0 @@
package de.dennisguse.opentracks.io.file.exporter;
import static org.junit.Assert.assertEquals;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.GrantPermissionRule;
import androidx.test.rule.ServiceTestRule;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import de.dennisguse.opentracks.TestUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.importer.ExportImportTest;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
@RunWith(JUnit4.class)
public class KMLTrackExporterTest {
@Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
private static final Context context = ApplicationProvider.getApplicationContext();
@BeforeClass
public static void preSetUp() {
// Prepare looper for Android's message queue
if (Looper.myLooper() == null) Looper.prepare();
PreferencesUtils.resetPreferences(context, true);
}
@Test
public void twoTrack_with_oneMarker() throws TimeoutException, IOException {
// given
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
TrackRecordingService service = ((TrackRecordingService.Binder) mServiceRule.bindService(new Intent(context, TrackRecordingService.class)))
.getService();
TrackPointCreator trackPointCreator = service.getTrackPointCreator();
final Track.Id trackIdOne;
{ // first track
trackPointCreator.setClock("2020-02-02T02:02:02Z");
trackIdOne = service.startNewTrack();
Position position = ExportImportTest.sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
contentProviderUtils.insertMarker(
new Marker(
null,
trackIdOne,
"Marker 1",
"Marker 1 desc",
"Marker 1 typeLocalized",
position,
null
));
service.endCurrentTrack();
}
final Track.Id trackIdTwo;
{ // second track
trackPointCreator.setClock("2022-02-02T02:02:02Z");
trackIdTwo = service.startNewTrack();
Position position = ExportImportTest.sendLocation(trackPointCreator, "2022-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
contentProviderUtils.insertMarker(
new Marker(
null,
trackIdTwo,
"Marker 2",
"Marker 2 desc",
"Marker 2 typeLocalized",
position,
null
));
service.endCurrentTrack();
}
// when
TrackExporter exporter = TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA.createTrackExporter(context, new ContentProviderUtils(context));
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.writeTrack(
List.of(contentProviderUtils.getTrack(trackIdOne), contentProviderUtils.getTrack(trackIdTwo)),
outputStream
);
// then
try (InputStream inputStreamExpected = InstrumentationRegistry.getInstrumentation().getContext().getResources().openRawResource(de.dennisguse.opentracks.test.R.raw.kml23_twotracks)) {
String expectation = new String(inputStreamExpected.readAllBytes(), StandardCharsets.UTF_8);
String expectationAdjusted = expectation
.replace("ece162fb-d614-4431-9d33-eb20f8ee8e1d", contentProviderUtils.getTrack(trackIdOne).uuid().toString())
.replace("6712f4ca-3456-4739-841a-9e62760cc447", contentProviderUtils.getTrack(trackIdTwo).uuid().toString());
assertEquals(expectationAdjusted, outputStream.toString());
}
}
}
@@ -127,7 +127,6 @@ public class ExportImportTest {
@Before
public void fileSetup() throws IOException, TimeoutException {
//TODO Replace with a ByteArrayOutputStream instead of storing a file?
tmpFile = File.createTempFile("test", "test", context.getFilesDir());
tmpFileUri = Uri.fromFile(tmpFile);
@@ -156,7 +155,7 @@ public class ExportImportTest {
Distance sensorDistance = Distance.of(10); // recording distance interval
Position position = sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
contentProviderUtils.insertMarker(
new Marker(
null,
@@ -164,7 +163,15 @@ public class ExportImportTest {
"Marker 1",
"Marker 1 desc",
"Marker 1 typeLocalized",
position,
new Position(
Instant.parse("2020-02-02T02:02:03Z"),
3.1234567, 14.0014567,
Distance.of(10),
Altitude.WGS84.of(1020.25),
Distance.of(13),
null,
Speed.of(15)
),
null
));
@@ -179,7 +186,7 @@ public class ExportImportTest {
trackPointCreator.setClock("2020-02-02T02:02:16Z");
mockSensorData(trackPointCreator, 5f, Distance.of(2), 69f, 3f, 50f, null, 30); //Distance will be added to next TrackPoint
position = sendLocation(trackPointCreator, "2020-02-02T02:02:17Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 0f);
sendLocation(trackPointCreator, "2020-02-02T02:02:17Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 0f);
contentProviderUtils.insertMarker(
new Marker(
null,
@@ -187,7 +194,7 @@ public class ExportImportTest {
"Marker 2",
"Marker 2 desc",
"Marker 2 typeLocalized",
position,
service.getLastStoredTrackPointWithLocation().position(),
null
));
@@ -762,8 +769,7 @@ public class ExportImportTest {
sensorManager.getAltitudeChangeHandler().setAggregator(aggregatorBarometer);
}
//TODO Use return value (for markers)
public static Position sendLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, float verticalAccuracy, float speed, double altitude, Float altitudeGain) {
public static void sendLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, float verticalAccuracy, float speed, double altitude, Float altitudeGain) {
Location location = new Location("mock");
location.setLatitude(latitude);
location.setLongitude(longitude);
@@ -776,7 +782,5 @@ public class ExportImportTest {
trackPointCreator.setClock(time);
trackPointCreator.getSensorManager().getGpsHandler().onDataReceived(location);
return Position.of(location, Instant.parse(time));
}
}
@@ -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
@@ -1,64 +0,0 @@
package de.dennisguse.opentracks.share;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Objects;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
public class ShareUtilsTest {
private final Context context = ApplicationProvider.getApplicationContext();
private ContentProviderUtils contentProviderUtils;
@Before
public void setUp() {
contentProviderUtils = new ContentProviderUtils(context);
contentProviderUtils.deleteAllTracks(context);
}
@Test
public void testSingleTrack() {
// given
Track.Id trackId = new Track.Id(System.currentTimeMillis());
contentProviderUtils.insertTrack(TestDataUtil.createTrack(trackId));
// when
Intent intent = ShareUtils.newShareFileIntent(context, trackId);
// then
assertEquals(Intent.ACTION_SEND, intent.getAction());
assertTrue(intent.getParcelableExtra(Intent.EXTRA_STREAM) instanceof Uri);
}
@Test
public void testMultipleTracks() {
// given
long random = System.currentTimeMillis();
Track.Id trackId1 = new Track.Id(random);
Track.Id trackId2 = new Track.Id(random + 1);
contentProviderUtils.insertTrack(TestDataUtil.createTrack(trackId1));
contentProviderUtils.insertTrack(TestDataUtil.createTrack(trackId2));
// when
Intent intent = ShareUtils.newShareFileIntent(context, trackId1, trackId2);
// then
assertEquals(Intent.ACTION_SEND_MULTIPLE, intent.getAction());
ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
assertEquals(2, Objects.requireNonNull(uris).size());
}
}
-185
View File
@@ -1,185 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.3"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:opentracks="http://opentracksapp.com/xmlschemas/v1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.opengis.net/kml/2.3 http://schemas.opengis.net/kml/2.3/ogckml23.xsd
http://opentracksapp.com/xmlschemas/v1 http://opentracksapp.com/xmlschemas/OpenTracks_v1.xsd">
<Document>
<open>1</open>
<visibility>1</visibility>
<name><![CDATA[2020-02-02T03:02+01]]></name>
<atom:generator><![CDATA[OpenTracks]]></atom:generator>
<Style id="track">
<LineStyle><color>7f0000ff</color><width>4</width></LineStyle>
<IconStyle>
<scale>1.3</scale>
<Icon />
</IconStyle>
</Style>
<Style id="waypoint"><IconStyle>
<Icon />
</IconStyle></Style>
<Schema id="schema">
<SimpleArrayField name="speed" type="float">
<displayName><![CDATA[Speed (m/s)]]></displayName>
</SimpleArrayField>
<SimpleArrayField name="power" type="float">
<displayName><![CDATA[Power (W)]]></displayName>
</SimpleArrayField>
<SimpleArrayField name="cadence" type="float">
<displayName><![CDATA[Cadence (rpm)]]></displayName>
</SimpleArrayField>
<SimpleArrayField name="heartrate" type="float">
<displayName><![CDATA[Heart rate (bpm)]]></displayName>
</SimpleArrayField>
<SimpleArrayField name="temperature" type="float">
<displayName><![CDATA[Temperature (Celsius)]]></displayName>
</SimpleArrayField>
</Schema>
<Folder>
<name><![CDATA[2020-02-02T03:02+01 Markers]]></name>
<open>1</open>
<Placemark>
<name><![CDATA[Marker 1]]></name>
<description><![CDATA[Marker 1 desc]]></description>
<TimeStamp><when>2020-02-02T03:02:03+01:00</when></TimeStamp>
<styleUrl>#waypoint</styleUrl>
<ExtendedData>
<Data name="type"><value><![CDATA[Marker 1 typeLocalized]]></value></Data>
</ExtendedData>
<Point>
<coordinates>14.001456,3.123456,1020.25</coordinates>
</Point>
</Placemark>
</Folder>
<Folder>
<name><![CDATA[2022-02-02T03:02+01 Markers]]></name>
<open>1</open>
<Placemark>
<name><![CDATA[Marker 2]]></name>
<description><![CDATA[Marker 2 desc]]></description>
<TimeStamp><when>2022-02-02T03:02:03+01:00</when></TimeStamp>
<styleUrl>#waypoint</styleUrl>
<ExtendedData>
<Data name="type"><value><![CDATA[Marker 2 typeLocalized]]></value></Data>
</ExtendedData>
<Point>
<coordinates>14.001456,3.123456,1020.25</coordinates>
</Point>
</Placemark>
</Folder>
<Folder id="tracks">
<name>Tracks</name>
<open>1</open>
<Placemark>
<name><![CDATA[2020-02-02T03:02+01]]></name>
<description><![CDATA[]]></description>
<opentracks:trackid>ece162fb-d614-4431-9d33-eb20f8ee8e1d</opentracks:trackid>
<styleUrl>#track</styleUrl>
<ExtendedData>
<Data name="activityType"><value><![CDATA[unknown]]></value></Data>
</ExtendedData>
<ExtendedData>
<Data name="type"><value><![CDATA[unknown]]></value></Data>
</ExtendedData>
<MultiTrack>
<altitudeMode>absolute</altitudeMode>
<interpolate>1</interpolate>
<Track>
<when>2020-02-02T03:02:02+01:00</when>
<coord/>
<when>2020-02-02T03:02:03+01:00</when>
<coord>14.001456 3.123456 1020.25</coord>
<when>2020-02-02T03:02:03+01:00</when>
<coord/>
<ExtendedData>
<SchemaData schemaUrl="#schema">
<SimpleArrayData name="trackpoint_type">
<value>SEGMENT_START_MANUAL</value>
<value>TRACKPOINT</value>
<value>SEGMENT_END_MANUAL</value>
</SimpleArrayData>
<SimpleArrayData name="speed">
<value />
<value>15</value>
<value />
</SimpleArrayData>
<SimpleArrayData name="elevation_gain">
<value />
<value>1</value>
<value>1</value>
</SimpleArrayData>
<SimpleArrayData name="elevation_loss">
<value />
<value>1</value>
<value>1</value>
</SimpleArrayData>
<SimpleArrayData name="accuracy_horizontal">
<value />
<value>10</value>
<value />
</SimpleArrayData>
</SchemaData>
</ExtendedData>
</Track>
</MultiTrack>
</Placemark>
<Placemark>
<name><![CDATA[2022-02-02T03:02+01]]></name>
<description><![CDATA[]]></description>
<opentracks:trackid>6712f4ca-3456-4739-841a-9e62760cc447</opentracks:trackid>
<styleUrl>#track</styleUrl>
<ExtendedData>
<Data name="activityType"><value><![CDATA[unknown]]></value></Data>
</ExtendedData>
<ExtendedData>
<Data name="type"><value><![CDATA[unknown]]></value></Data>
</ExtendedData>
<MultiTrack>
<altitudeMode>absolute</altitudeMode>
<interpolate>1</interpolate>
<Track>
<when>2022-02-02T03:02:02+01:00</when>
<coord/>
<when>2022-02-02T03:02:03+01:00</when>
<coord>14.001456 3.123456 1020.25</coord>
<when>2022-02-02T03:02:03+01:00</when>
<coord/>
<ExtendedData>
<SchemaData schemaUrl="#schema">
<SimpleArrayData name="trackpoint_type">
<value>SEGMENT_START_MANUAL</value>
<value>TRACKPOINT</value>
<value>SEGMENT_END_MANUAL</value>
</SimpleArrayData>
<SimpleArrayData name="speed">
<value />
<value>15</value>
<value />
</SimpleArrayData>
<SimpleArrayData name="elevation_gain">
<value />
<value>1</value>
<value>1</value>
</SimpleArrayData>
<SimpleArrayData name="elevation_loss">
<value />
<value>1</value>
<value>1</value>
</SimpleArrayData>
<SimpleArrayData name="accuracy_horizontal">
<value />
<value>10</value>
<value />
</SimpleArrayData>
</SchemaData>
</ExtendedData>
</Track>
</MultiTrack>
</Placemark>
</Folder>
</Document>
</kml>
+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"
@@ -61,7 +61,7 @@ public abstract class AbstractTrackDeleteActivity extends AbstractActivity imple
WorkManager workManager = WorkManager.getInstance(this);
WorkRequest deleteRequest = new OneTimeWorkRequest.Builder(TrackDeletionWorker.class)
.setInputData(new Data.Builder()
.putLongArray(TrackDeletionWorker.TRACKIDS_KEY, trackIdList.stream().mapToLong(Track.Id::id).toArray())
.putLongArray(TrackDeletionWorker.TRACKIDS_KEY, Arrays.stream(trackIds).mapToLong(Track.Id::id).toArray())
.build())
.build();
@@ -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();
}
}
}
}
@@ -51,7 +51,6 @@ import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Statistics;
import de.dennisguse.opentracks.data.models.Temperature;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.statistics.SensorStatistics;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
@@ -233,16 +232,12 @@ public class ContentProviderUtils {
/**
* Inserts a track.
* NOTE: This doesn't insert any trackPoints.
* NOTE: if the track.uuid is null, a random value will be assigned.
*
* @param track the track
* @return the content provider URI of the inserted track.
*/
public Track.Id insertTrack(Track track) {
//TODO Unset trackId as it should be database generated
if (track.uuid() == null) {
track = new TrackBuilder(track, UUID.randomUUID()).getTrack();
}
//TODO Unset trackId
Uri uri = contentResolver.insert(CustomContentProvider.UrlType.TRACKS.getUri(), createContentValues(track));
return new Track.Id(ContentUris.parseId(uri));
}
@@ -57,17 +57,6 @@ public class TrackBuilder {
this.trackStatistics = track.statistics();
}
public TrackBuilder(Track track, UUID uuid) {
this.id = track.id();
this.uuid = uuid;
this.name = track.name();
this.description = track.description();
this.activityTypeLocalized = track.activityTypeLocalized();
this.activityType = track.activityType();
this.zoneOffset = track.zoneOffset();
this.trackStatistics = track.statistics();
}
public void setName(String name) {
this.name = name;
}
@@ -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);
}
}
@@ -49,8 +49,6 @@ import de.dennisguse.opentracks.util.StringUtils;
* Convert {@link Track} incl. {@link Marker} and {@link TrackPoint} to KML version 2.3.
* <a href="https://docs.opengeospatial.org/is/12-007r2/12-007r2.html">...</a>
*
* WARNING: exporting multiple tracks (each with markers) into one file, will not allow to restore to which track a marker belonged.
*
* @author Sandor Dornbush
* @author Rodrigo Damazio
* @author Leif Hendrik Wilden
@@ -30,6 +30,5 @@ import de.dennisguse.opentracks.data.models.Track;
*/
public interface TrackExporter {
//TODO Pass track.Id instead of track?
boolean writeTrack(List<Track> tracks, @NonNull OutputStream outputStream);
}
@@ -131,7 +131,7 @@ public class TrackImporter {
}
//TODO This is a workaround until we have proper UI.
trackData.uuid = null;
trackData.uuid = UUID.randomUUID();
}
trackPoints.sort(Comparator.comparing(TrackPoint::getTime));
@@ -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);
@@ -79,7 +97,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
trackId = contentProviderUtils.insertTrack(
new Track(
null,
null,
UUID.randomUUID(), //TODO This is not really cool: should happen in ContentProviderUtils?
"",
"",
activityTypeLocalized,
@@ -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,6 +178,12 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
trackId = null;
trackStatisticsUpdater = null;
idleTimeoutScheduledAt = null;
walkingStartedAt = null;
idleStartedAt = null;
longBreakRecorded = false;
handler.removeCallbacks(ON_LONG_BREAK);
reset();
}
@@ -148,32 +206,115 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
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
@@ -204,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)) {
@@ -216,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;
}
@@ -223,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;
}
@@ -239,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() {
@@ -288,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
@@ -301,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);
}
@@ -688,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();
@@ -845,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);
@@ -861,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();
@@ -907,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());
}
}
}
@@ -62,15 +62,6 @@ public class ShareUtils {
mime = uriAndMime.second;
}
if (uris.size() == 1) {
return new Intent(Intent.ACTION_SEND)
.setType(mime)
.putExtra(Intent.EXTRA_STREAM, uris.get(0))
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.share_track_subject))
.putExtra(Intent.EXTRA_TEXT, context.getString(R.string.share_track_share_file_body, trackDescription));
}
return new Intent(Intent.ACTION_SEND_MULTIPLE)
.setType(mime)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
@@ -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>
+20 -16
View File
@@ -30,7 +30,9 @@ limitations under the License.
\n* arbeitet vollständig offline
\n* Tracks auf einer Karte anzeigen (siehe unten)</string>
<string name="help_track_map_title">Wie wird ein Track auf einer Karte angezeigt?</string>
<string name="help_track_map_content">Verwende das kleine Kartensymbol. \n a) (Live-Aktualisierungen) Installiere <a href="https://codeberg.org/OpenTracksApp/OSMDashboard">OSMDashboard</a>. OSMDashboard stellt Online- oder Offline-Karten über <a href="https://www.openstreetmap.org">OpenStreetMap</a> zur Verfügung. \n Tatsächlich ist OSMDashboard eine separate App, die nur (vorübergehend) auf die Daten der anzuzeigenden Strecke(n) zugreifen kann. \n b) Über eine normale Kartenanwendung (z.B. <a href="http://osmand.net/">OsmAnd</a> und <a href="https://maps.me/">MAPS.ME</a>). Diese Anwendungen müssen das KMZ-Dateiformat unterstützen (nur Orte und Zeitstempel). Genauer ausgedrückt muss eine App <i>ACTION_VIEW</i> mit MIME <i>application/vnd.google-earth.kmz</i> unterstützen.</string>
<string name="help_track_map_content"> \u0020Verwende das kleine Kartensymbol.
\na) (Live-Aktualisierungen) Installiere <a href="https://codeberg.org/OpenTracksApp/OSMDashboard">OSMDashboard</a>. OSMDashboard stellt Online- oder Offline-Karten über <a href="https://www.openstreetmap.org">OpenStreetMap</a> zur Verfügung. Tatsächlich ist OSMDashboard eine separate App, die nur (vorübergehend) auf die Daten der anzuzeigenden Strecke(n) zugreifen kann.
\nb) Über eine normale Kartenanwendung (z.B. <a href="http://osmand.net/">OsmAnd</a> und <a href="https://maps.me/">MAPS.ME</a>). Diese Anwendungen müssen das KMZ-Dateiformat unterstützen (nur Orte und Zeitstempel). Genauer ausgedrückt muss eine App <i>ACTION_VIEW</i> mit MIME <i>application/vnd.google-earth.kmz</i> unterstützen. \u0020</string>
<string name="help_storage_pictures_title">Wie speichert OpenTracks Fotos, die als Markierungen aufgenommen wurden?</string>
<string name="help_storage_pictures_content">Diese werden App-intern gespeichert (d.h. nur innerhalb von OpenTracks zugänglich).</string>
<string name="help_track_export_title">Wie kann ich aufgezeichnete Tracks exportieren/teilen?</string>
@@ -41,11 +43,24 @@ limitations under the License.
OpenTracks verwendet den GPS-Sensor des Geräts, um den Standort kontinuierlich aufzuzeichnen.
Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), kann OpenTracks nichts dagegen unternehmen.</string>
<string name="help_data_storage_title">Wie speichert OpenTracks Sensordaten?</string>
<string name="help_data_storage_content">Sensordaten werden pro aufgezeichnetem Standort gespeichert. Bezogen auf einen Herzfrequenzsensor bedeutet dies, dass nur <i>eine</i>Herzfrequenzmessung pro Standort gespeichert wird. Wenn Du still stehst oder dich in geschlossenen Räumen aufhältst, werden keine Sensordaten aufgezeichnet (aber sie werden angezeigt).</string>
<string name="help_data_storage_content"> Sensordaten werden pro aufgezeichnetem Standort gespeichert. Bezogen auf einen Herzfrequenzsensor bedeutet dies, dass nur <i>eine</i>Herzfrequenzmessung pro Standort gespeichert wird. Wenn Du still stehst oder dich in geschlossenen Räumen aufhältst, werden keine Sensordaten aufgezeichnet (aber angezeigt). </string>
<string name="help_gps_troubleshooting_title">OpenTracks zeichnet keine Standorte auf?</string>
<string name="help_gps_troubleshooting_content">Was Du tun kannst: \n a) prüfen, ob GPS aktiviert ist, \n b) den GPS-Empfang verbessern (z.B. ins Freie gehen), \n c) mit der <a href="https://f-droid.org/de/packages/com.android.gpstest.osmdroid/">GPSTest</a>-App prüfen, und \n d) Energiesparen deaktivieren. \n \n Wenn das alles nicht hilft, melde bitte einen Fehler.</string>
<string name="help_gps_troubleshooting_content"> \u0020Was Du tun kannst:
\na) prüfen, ob GPS aktiviert ist,
\nb) den GPS-Empfang verbessern (z.B. ins Freie gehen),
\nc) mit der <a href="https://f-droid.org/en/packages/com.android.gpstest.osmdroid/">GPSTest</a>-App das GPS testen, und
\nd) Energiesparen deaktivieren.
\n
\nWenn das alles nicht hilft, melde bitte einen Fehler. \u0020</string>
<string name="help_support_title">Wie kann ich OpenTracks unterstützen?</string>
<string name="help_support_content">Du kannst: \n a) auf OpenTracks aufmerksam machen, \n b) <a href="https://codeberg.org/OpenTracksApp/OpenTracks/issues">Funktionen vorschlagen</a>, \n c) <a href="https://codeberg.org/OpenTracksApp/OpenTracks/issues">Fehler melden</a>, \n d) bei der Software-Entwicklung helfen, \n e) es im <a href="https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore">Google Play Store</a> kaufen, \n f) spenden, und \n g) das tun, was OpenTracks deiner Meinung nach hilft.</string>
<string name="help_support_content"> \u0020Du kannst:
\na) auf OpenTracks aufmerksam machen,
\nb) <a href="https://codeberg.org/OpenTracksApp/OpenTracks/issues">Funktionen vorschlagen</a>,
\nc) <a href="https://codeberg.org/OpenTracksApp/OpenTracks/issues">Fehler melden</a>,
\nd) bei der Software-Entwicklung helfen,
\ne) es im <a href="https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore">Google Play Store</a> kaufen,
\nf) spenden, und
\ng) das tun, was OpenTracks Deiner Meinung nach hilft. \u0020</string>
<!-- Activity Type -->
<string name="activity_type_airplane">Flugzeug</string>
<string name="activity_type_atv">Quad</string>
@@ -67,7 +82,7 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="activity_type_hang_gliding">Drachenfliegen</string>
<string name="activity_type_helicopter">Hubschrauber</string>
<string name="activity_type_hiking">Wandern</string>
<string name="activity_type_horse_back_riding">Ausreiten</string>
<string name="activity_type_horse_back_riding">Reiten</string>
<string name="activity_type_hot_air_balloon">Heißluftballon</string>
<string name="activity_type_ice_sailing">Eissegeln</string>
<string name="activity_type_inline_skating">Inlineskating</string>
@@ -640,15 +655,4 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="settings_announcements_segment_average_speed_pace">Durchschnittsgeschwindigkeit/pace (Segment)</string>
<string name="stats_average_segment_pace">Durchschnittliche Segment Geschwindigkeit</string>
<string name="stats_elapsed_segment_time">Verstrichene Segment Zeit</string>
<string name="activity_type_gravel_biking">Gravelrad</string>
<string name="description_sensor_temperature">Temperatur (Celsius)</string>
<string name="menu_export_all_to_health_connect">Alles exportieren</string>
<string name="settings_export_to_health_connect">Export zu Health Connect</string>
<string name="health_connect_export_enabled_summary">Export der Aktivität zu Health Connect sobald die Aufnahme beendet ist</string>
<string name="health_connect_not_available">Health Connect ist auf diesem Endgerät nicht verfügbar</string>
<string name="health_connect_open_settings_title">Health Connect Einstellungen</string>
<string name="health_connect_open_settings_summary">Öffne die Health Connect Einstellungen</string>
<string name="health_connect_permissions_rationale_title">Health Connect Berechtigungen</string>
<string name="health_connect_permissions_rationale_heading">Wie OpenTracks Health Connect verwendet</string>
<string name="health_connect_permissions_rationale_summary">Sofern in den Einstellungen aktiviert, überträgt OpenTracks die Trainingsdaten in Health Connect. OpenTracks liest keine Daten aus von Health Connect.</string>
</resources>
+19
View File
@@ -197,6 +197,7 @@ limitations under the License.
<string name="settings_recording_max_recording_distance_summary">Nowy segment jest tworzony, jeśli odległość między dwiema lokalizacjami jest większa niż %1$s</string>
<string name="settings_recording_max_recording_distance_title">Maksymalna odległość</string>
<string name="settings_recording_min_recording_distance_title">Rejestrowanie odcinka</string>
<string name="settings_recording_marker_idle_duration_title">Minimalny czas długiej przerwy</string>
<string name="settings_recording_min_recording_interval_title">Rejestrowanie interwału</string>
<string name="settings_recording_min_required_accuracy_title">Dokładność GPS-u</string>
<string name="settings_recording_track_name_date_iso_8601_option">Data (ISO 8601)</string>
@@ -281,6 +282,7 @@ limitations under the License.
<string name="value_integer_meter_recommended">%1$d m (zalecane)</string>
<string name="value_integer_mile_hour">%1$d mil/h</string>
<string name="value_integer_minute">%1$d min</string>
<string name="value_integer_minute_recommended">%1$d min (zalecane)</string>
<string name="value_integer_second">%1$d s</string>
<string name="value_none">Brak</string>
<string name="value_off">Wyłącz</string>
@@ -443,6 +445,8 @@ limitations under the License.
<string name="settings_announcements_totaltime_frequency">Przedział czasu</string>
<string name="stats_sensors_heart_rate">Tętno</string>
<string name="stats_clock">Zegar</string>
<string name="stats_auto_pause_status">Status auto-pauzy</string>
<string name="stats_sensor_status">Status czujników</string>
<string name="settings_announcements_distance_frequency">Długość okrążenia</string>
<string name="settings_layout_reset_confirm_title">Zresetować twoje układy\?</string>
<string name="settings_cycling_sensor">Kolarstwo</string>
@@ -523,6 +527,10 @@ limitations under the License.
<string name="settings_announcements_lap_speed_pace">Tempo/prędkość okrążenia</string>
<string name="track_delete_number_of_tracks">%1$d ścieżki</string>
<string name="settings_announcements_idle_title">Przerwa</string>
<string name="settings_announcements_long_break_title">Długa przerwa (filiżanka)</string>
<string name="settings_announcements_sensor_category_title">Czujniki</string>
<string name="settings_announcements_sensor_disconnected_title">Odłączono czujnik</string>
<string name="settings_announcements_sensor_connected_title">Podłączono czujnik</string>
<string name="settings_recording_title">Nagrywanie</string>
<string name="settings_theme_switch_restart">Zmiana motywu może wymagać ręcznego restartu.</string>
<string name="settings_ui_dynamic_colors_summary">Użyj ręcznie zdefiniowanych Dynamicznych Kolorów (tylko Android 12+; wymaga ręcznego restartu)</string>
@@ -585,4 +593,15 @@ limitations under the License.
<string name="show_on_dashboard">API panelu OpenTracks</string>
<string name="marker_detail_title">%1$d z %2$d: %3$s</string>
<string name="voice_on_device_speaker_title">Użyj wbudowanego głośnika</string>
<string name="sensor_disconnected_warning">Czujnik rozłączony. Kontynuować z GPS?</string>
<string name="sensor_disconnected_switch_action">Przełącz</string>
<string name="settings_recording_auto_pause_on_cadence_0_title">Zatrzymaj przy prowadzeniu roweru</string>
<string name="settings_recording_auto_pause_on_cadence_0_summary">Uruchom autopauzę jeżeli kadencja wynosi 0 a prędkość wskazuje na chód</string>
<string name="settings_recording_auto_pause_on_cadence_0_speed_title">Maksymalna prędkość prowadzenia</string>
<string name="settings_recording_auto_pause_on_cadence_0_speed_summary">Prędkość poniżej której wykrywane jest prowadzenie roweru (gdy kadencja to 0)</string>
<string name="settings_recording_prevent_idle_if_cadence_title">Zapobiegaj pauzie podczas pedałowania</string>
<string name="settings_recording_prevent_idle_if_cadence_summary">Nie włączaj trybu postoju jeżeli pedałujesz, nawet przy braku ruchu</string>
<string name="voiceLongBreak">Rozpoczęto długą przerwę.</string>
<string name="voiceSensorDisconnected">Odłączono czujnik.</string>
<string name="voiceSensorConnected">Podłączono czujnik.</string>
</resources>
+1 -17
View File
@@ -463,20 +463,4 @@ limitations under the License.
<string name="generic_choose_an_option">Alegeți o opțiune</string>
<string name="generic_undo">Anulați</string>
<string name="settings_defaults_summary">Unități preferate, Activitate implicită, Urmărire nume implicit</string>
<string name="help_data_google_play_services_content">Nu</string>
<string name="description_pace_nautical">Ritm (min/NM)</string>
<string name="description_sensor_temperature">Temperatura (Celsius)</string>
<string name="description_speed_nautical">Viteza (noduri)</string>
<string name="description_speed_source_gps">GPS</string>
<string name="generic_from">De la</string>
<string name="generic_to">Către</string>
<string name="create_marker_error">Nu s-a putut crea semnul. Încearcă din nou.</string>
<string name="marker_detail_title">%1$d al %2$d:%3$s</string>
<string name="menu_export_all_one_file">Exportă tot (un fișier pentru toate traseele)</string>
<string name="gps_recording_without_signal">OpenTracks înregistrează fără date GPS.</string>
<string name="activity_type_kickscooter">scuter</string>
<string name="menu_export_all_to_health_connect">Exportă tot</string>
<string name="permission_recording_failed">OpenTracks are nevoie de permisiunea de a accesa locația și dispozitivele în apropiere.</string>
<string name="permission_bluetooth_failed">OpenTracks necesită permisiunea de a utiliza Bluetooth.</string>
<string name="permission_bluetooth_failed_rejected">Îți trebuie să permiți OpenTracks pentru a accesa la Dispozitive din Apropriere în setările sistemului.</string>
</resources>
</resources>
+2 -2
View File
@@ -15,8 +15,8 @@ limitations under the License.
-->
<resources>
<string name="app_web_url" translatable="false">https://codeberg.org/OpenTracksApp/OpenTracks</string>
<string name="app_name" translatable="false">OpenTracks</string>
<string name="launcher_name" translatable="false">OpenTracks</string>
<string name="app_name" translatable="false">OpenTracks (Fork)</string>
<string name="launcher_name" translatable="false">OpenTracks (Fork)</string>
<string name="egm2008" translatable="false">EGM2008</string>
<string name="wgs84" translatable="false">WGS84</string>
+35
View File
@@ -130,6 +130,14 @@
<string name="idle_duration_key" translatable="false">idleSpeedDuration</string>
<string name="idle_duration_default" translatable="false">10</string>
<string name="idle_duration_off" translatable="false">0</string>
<string-array name="auto_pause_cadence_0_speed_values">
<item>4</item>
<item>6</item>
<item>8</item>
<item>10</item>
<item>12</item>
</string-array>
<string-array name="idle_duration_values">
<item>@string/idle_duration_off</item>
<item>5</item>
@@ -141,6 +149,17 @@
<item>120</item>
</string-array>
<string name="marker_idle_duration_default" translatable="false">3</string>
<string-array name="marker_idle_duration_values">
<item>1</item>
<item>2</item>
<item>@string/marker_idle_duration_default</item>
<item>5</item>
<item>10</item>
<item>15</item>
<item>30</item>
</string-array>
<string name="stats_rate_key" translatable="false">statsRate</string>
<string name="stats_rate_default" translatable="false">@string/stats_rate_speed_or_pace_default</string>
<string-array name="stats_rate_metric_options">
@@ -245,6 +264,12 @@
<bool name="voice_on_device_speaker_default" translatable="false">true</bool>
<string name="voice_announce_idle_key" translatable="false">voice_announce_idle_key</string>
<bool name="voice_announce_idle_default" translatable="false">false</bool>
<string name="voice_announce_long_break_key" translatable="false">voice_announce_long_break_key</string>
<bool name="voice_announce_long_break_default" translatable="false">true</bool>
<string name="voice_announce_sensor_disconnected_key" translatable="false">voice_announce_sensor_disconnected_key</string>
<bool name="voice_announce_sensor_disconnected_default" translatable="false">true</bool>
<string name="voice_announce_sensor_connected_key" translatable="false">voice_announce_sensor_connected_key</string>
<bool name="voice_announce_sensor_connected_default" translatable="false">true</bool>
<string name="voice_announce_total_distance_key" translatable="false">voiceAnnounceTotalDistance</string>
<bool name="voice_announce_total_distance_default" translatable="false">true</bool>
@@ -381,6 +406,16 @@
<string name="stats_custom_layout_average_cadence_key" translatable="false">average_cadence</string>
<string name="stats_custom_layout_power_key" translatable="false">power</string>
<string name="stats_custom_layout_clock_key" translatable="false">time</string>
<string name="stats_custom_layout_auto_pause_status_key" translatable="false">auto_pause_status</string>
<string name="stats_custom_layout_sensor_status_key" translatable="false">sensor_status</string>
<string name="stats_custom_layout_sensor_status_hr_key" translatable="false">sensor_status_hr</string>
<string name="stats_custom_layout_sensor_status_cadence_key" translatable="false">sensor_status_cadence</string>
<string name="stats_custom_layout_sensor_status_speed_key" translatable="false">sensor_status_speed</string>
<string name="stats_custom_layout_debug_idle_timer_key" translatable="false">debug_idle_timer</string>
<string name="stats_custom_layout_debug_walking_timer_key" translatable="false">debug_walking_timer</string>
<string name="stats_custom_layout_debug_marker_timer_key" translatable="false">debug_marker_timer</string>
<string name="stats_custom_layout_media_key" translatable="false">media_control</string>
<!-- Stats empty values -->
<string name="stats_empty_value_integer" translatable="false">0</string>
+39 -1
View File
@@ -331,6 +331,10 @@ limitations under the License.
<string name="settings_sensors_summary">Cycling Sensors, Wheel Circumference, Running Sensors</string>
<string name="settings_announcements_idle_category_title">Idle Announcements</string>
<string name="settings_announcements_idle_title">Idle</string>
<string name="settings_announcements_long_break_title">Long break</string>
<string name="settings_announcements_sensor_category_title">Sensors</string>
<string name="settings_announcements_sensor_disconnected_title">Sensor disconnected</string>
<string name="settings_announcements_sensor_connected_title">Sensor connected</string>
<string name="settings_announcements_statistics_title">Statistics Announcements</string>
<string name="settings_announcements_summary">Time, Distance, Voice Speed</string>
<string name="settings_import_export_title">Import and Export</string>
@@ -351,6 +355,8 @@ limitations under the License.
<string name="settings_chart_display_heart_rate_title">Display heart rate</string>
<!-- Settings Recording -->
<string name="settings_recording_title">Recording</string>
<string name="settings_recording_min_recording_interval_title">Recording time interval</string>
<string name="stats_sensor_status">Sensor status</string>
<string name="settings_theme_switch_restart">Theme changes may require a manual restart.</string>
<string name="settings_ui_dynamic_colors_title">Dynamic Colors</string>
<string name="settings_ui_dynamic_colors_summary">Use user-defined Dynamic Colors (Android 12+ only; requires a manual restart)</string>
@@ -369,7 +375,9 @@ limitations under the License.
<string name="settings_recording_max_recording_distance_summary">A new segment is created if the distance between two locations is greater than %1$s</string>
<string name="settings_recording_max_recording_distance_title">Max distance</string>
<string name="settings_recording_min_recording_distance_title">Recording distance interval</string>
<string name="settings_recording_min_recording_interval_title">Recording time interval</string>
<string name="marker_idle_duration_key" translatable="false">marker_idle_duration</string>
<string name="settings_recording_marker_idle_duration_title">Marker idle duration</string>
<string name="settings_recording_min_recording_interval_key" translatable="false">recording_min_recording_interval_key</string>
<string name="settings_recording_min_required_accuracy_summary">Location is ignored if its accuracy is greater than %1$s</string>
<string name="settings_recording_min_required_accuracy_title">GPS accuracy</string>
<string name="settings_recording_track_name_date_iso_8601_option">Date (ISO 8601)</string>
@@ -478,6 +486,14 @@ limitations under the License.
<string name="stats_sensors_cadence">Cadence</string>
<string name="stats_sensors_power">Power</string>
<string name="stats_clock">Clock</string>
<string name="stats_auto_pause_status">Auto-Pause Status</string>
<string name="stats_sensor_status_hr">Sensor HR</string>
<string name="stats_sensor_status_cadence">Sensor Cadence</string>
<string name="stats_sensor_status_speed">Sensor Speed</string>
<string name="stats_debug_idle_timer">Idle Timer</string>
<string name="stats_debug_walking_timer">Walking Timer</string>
<string name="stats_debug_marker_timer">Marker Timer</string>
<!-- Track -->
<string name="track_name_format">Track %1$d</string>
<string name="track_recording_notification_accuracy">Location accuracy: %1$s</string>
@@ -536,6 +552,7 @@ limitations under the License.
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (recommended)</string>
<string name="value_float_knots_recommended">%1$.1f knots (recommended)</string>
<string name="value_integer_minute">%1$d min</string>
<string name="value_integer_minute_recommended">%1$d min (recommended)</string>
<string name="value_integer_second">%1$d s</string>
<string name="value_none">None</string>
<string name="value_off">Off</string>
@@ -543,6 +560,9 @@ limitations under the License.
<string name="value_smallest_recommended">Smallest (recommended)</string>
<string name="value_int_seconds">%1$d s (recommended)</string>
<string name="voiceIdle">Becoming idle.</string>
<string name="voiceLongBreak">Long break recorded.</string>
<string name="voiceSensorDisconnected">Sensor disconnected.</string>
<string name="voiceSensorConnected">Sensor connected.</string>
<string name="voiceHoursPlural">
{n, plural,
=1 {1 hour}
@@ -705,4 +725,22 @@ limitations under the License.
<string name="settings_announcements_lap_power">Lap power</string>
<string name="power_x_watt">Power {x, plural, =1 {1 watt} other {# watts} }</string>
<string name="power">Power</string>
<string name="settings_recording_prevent_idle_if_cadence_key" translatable="false">preventIdleIfCadence</string>
<string name="settings_recording_prevent_idle_if_cadence_title">Prevent auto-pause if pedaling</string>
<string name="settings_recording_prevent_idle_if_cadence_summary">Do not enter idle mode while pedaling even if not moving</string>
<string name="sensor_disconnected_warning">Sensor disconnected. Continue with GPS?</string>
<string name="sensor_disconnected_switch_action">Switch</string>
<string name="settings_recording_auto_pause_on_cadence_0_key" translatable="false">autoPauseCadence0</string>
<string name="settings_recording_auto_pause_on_cadence_0_title">Auto-pause when carrying bike</string>
<string name="settings_recording_auto_pause_on_cadence_0_summary">Pause recording if cadence is 0 and speed is low</string>
<string name="settings_recording_auto_pause_on_cadence_0_speed_key" translatable="false">autoPauseCadence0Speed</string>
<string name="settings_recording_auto_pause_on_cadence_0_speed_title">Auto-pause max speed</string>
<string name="settings_recording_auto_pause_on_cadence_0_speed_summary">Speed below which walking with bike is detected (if cadence is 0)</string>
<string name="auto_pause_cadence_0_speed_default" translatable="false">8</string> <string name="media_title_default">No media playing</string>
<string name="media_artist_default">OpenTracks</string>
<string name="media_prev">Previous</string>
<string name="media_next">Next</string>
<string name="media_play_pause">Play/Pause</string>
<string name="media_permission_request">Enable Notification Access</string>
</resources>
@@ -22,6 +22,21 @@
android:defaultValue="@bool/voice_announce_idle_default"
android:key="@string/voice_announce_idle_key"
android:title="@string/settings_announcements_idle_title" />
<SwitchPreferenceCompat
android:defaultValue="@bool/voice_announce_long_break_default"
android:key="@string/voice_announce_long_break_key"
android:title="@string/settings_announcements_long_break_title" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_announcements_sensor_category_title">
<SwitchPreferenceCompat
android:defaultValue="@bool/voice_announce_sensor_disconnected_default"
android:key="@string/voice_announce_sensor_disconnected_key"
android:title="@string/settings_announcements_sensor_disconnected_title" />
<SwitchPreferenceCompat
android:defaultValue="@bool/voice_announce_sensor_connected_default"
android:key="@string/voice_announce_sensor_connected_key"
android:title="@string/settings_announcements_sensor_connected_title" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_announcements_statistics_title">
+6 -7
View File
@@ -24,13 +24,12 @@
android:targetClass="de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserActivity"
android:targetPackage="@string/applicationId" />
</Preference>
<!-- Disabled as the used structure for KML/KMZ does not allow assigning markers correctly on import. -->
<!-- <Preference-->
<!-- android:title="@string/menu_export_all_one_file">-->
<!-- <intent-->
<!-- android:targetClass="de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserOneFileActivity"-->
<!-- android:targetPackage="@string/applicationId" />-->
<!-- </Preference>-->
<Preference
android:title="@string/menu_export_all_one_file">
<intent
android:targetClass="de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserOneFileActivity"
android:targetPackage="@string/applicationId" />
</Preference>
<Preference
android:key="@string/settings_default_export_directory_key"
android:title="@string/settings_default_export_uri_title">
+28
View File
@@ -4,6 +4,25 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/settings_recording_title">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/settings_recording_auto_pause_on_cadence_0_key"
android:title="@string/settings_recording_auto_pause_on_cadence_0_title"
android:summary="@string/settings_recording_auto_pause_on_cadence_0_summary" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="@string/settings_recording_prevent_idle_if_cadence_key"
android:title="@string/settings_recording_prevent_idle_if_cadence_title"
android:summary="@string/settings_recording_prevent_idle_if_cadence_summary" />
<ListPreference
android:defaultValue="@string/auto_pause_cadence_0_speed_default"
android:entryValues="@array/auto_pause_cadence_0_speed_values"
android:key="@string/settings_recording_auto_pause_on_cadence_0_speed_key"
android:title="@string/settings_recording_auto_pause_on_cadence_0_speed_title"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="@string/recording_distance_interval_default"
android:entryValues="@array/recording_distance_interval_values"
@@ -19,8 +38,17 @@
<ListPreference
android:defaultValue="@string/idle_duration_default"
android:entryValues="@array/idle_duration_values"
android:entries="@array/idle_duration_values"
android:key="@string/idle_duration_key"
android:title="@string/settings_recording_idle_timeout_title"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="@string/marker_idle_duration_default"
android:entryValues="@array/marker_idle_duration_values"
android:entries="@array/marker_idle_duration_values"
android:key="@string/marker_idle_duration_key"
android:title="@string/settings_recording_marker_idle_duration_title"
app:useSimpleSummaryProvider="true" />
</PreferenceScreen>
+1466
View File
File diff suppressed because it is too large Load Diff