Compare commits

..

83 Commits

Author SHA1 Message Date
Román Ginés cb71c40350 Release: v3.26.1 2022-03-22 13:37:35 +01:00
Román Ginés 22bde4af3c Bug fixed: contextual action bar didn't overlap toolbar in night theme. 2022-03-21 12:59:45 +01:00
Dennis Guse ecfcc180b9 Release: v3.26.0 2022-03-14 23:21:17 +01:00
mondstern 9c1af67e7e Translated using Weblate (Czech)
Currently translated at 99.4% (499 of 502 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/cs/
2022-03-14 18:52:57 +01:00
mondstern 873422540f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (502 of 502 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/vi/
2022-03-13 01:28:18 +01:00
vsevjednom-cz 6cf0b26058 Translated using Weblate (Czech)
Currently translated at 95.0% (477 of 502 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/cs/
2022-03-13 01:28:18 +01:00
Quang Trung a94b227767 Translated using Weblate (Vietnamese)
Currently translated at 96.8% (486 of 502 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/vi/
2022-03-13 01:28:17 +01:00
FTno c820a76102 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (502 of 502 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-03-13 01:28:17 +01:00
Peter Storch e61b065f2b fix #1139 CI build
- fix NPEs in TrackPointCreator.fill()
- fix locale dependant tests
- wait for UI components
- extract utility functions into EspressoUtils
2022-03-13 01:28:10 +01:00
dependabot[bot] 24d8e976da Bump mockito-android from 4.3.1 to 4.4.0
Bumps [mockito-android](https://github.com/mockito/mockito) from 4.3.1 to 4.4.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v4.3.1...v4.4.0)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-09 11:57:38 +01:00
Román Ginés 79e45f7aea Migration to Material Components theme and style.
New night theme mainly white on black.
Fixes #1122.
Fixes #957.
2022-03-03 10:02:34 +01:00
J. Lavoie 72f7b12da6 Translated using Weblate (French)
Currently translated at 87.6% (71 of 81 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/app-store-metadata-files/fr/
2022-03-03 00:19:26 +01:00
Mario 9973dd05bd Translated using Weblate (Spanish)
Currently translated at 100.0% (490 of 490 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es/
2022-03-03 00:19:26 +01:00
Mario 41072a8981 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (490 of 490 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es_419/
2022-03-03 00:19:26 +01:00
dependabot[bot] b1dcdf326c Bump gradle from 7.0.4 to 7.1.2
Bumps gradle from 7.0.4 to 7.1.2.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-03 00:19:23 +01:00
Dennis Guse c271dd32ac Merge pull request #1132 from spacecowboy/voiceHeartRate
Voice announcements: configurable, TtsSpan, and heart rate.
2022-03-02 15:17:22 +01:00
dependabot[bot] 5dfa867034 Bump actions/checkout from 2.4.0 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2.4.0 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2.4.0...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-02 15:01:08 +01:00
Román Ginés ada0f88538 Time on list of tracks uses 24 hour format. Fixes #1140. 2022-03-02 08:36:14 +01:00
Dennis Guse 4df0e2a65c Release: v3.25.1 2022-03-02 03:36:43 +01:00
Jonas Kalderstam 9e7e8c60a6 Voice announcement: added TtsSpans to announcements
Fixes #876
2022-03-01 10:09:00 +01:00
Jonas Kalderstam 1075021e21 Voice announcement: added heart rate info to voice announcements
Also added setting to toggle announcement of heart rate

Defaults to off to preserve current behavior
2022-03-01 09:58:29 +01:00
Peter Storch b5acfa022d try appCtx to persist NextCloud URI permission
for #1080
Also fixing a missing break in a switch statement.
2022-02-26 23:42:50 +01:00
dependabot[bot] 6fe38453b3 Bump gradle from 7.1.1 to 7.1.2
Bumps gradle from 7.1.1 to 7.1.2.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-25 12:54:54 +01:00
Dennis Guse 2692cc553c Release: v3.25.0 2022-02-22 03:11:50 +01:00
FTno 3db4dc9422 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (490 of 490 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-21 22:52:44 +01:00
FTno 01bb4429ad Translated using Weblate (Norwegian Bokmål)
Currently translated at 99.3% (487 of 490 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-21 22:52:44 +01:00
Vinicius ca817e789e Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.7% (485 of 486 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/pt_BR/
2022-02-21 22:52:44 +01:00
FTno 7bf2110a54 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (486 of 486 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-21 22:52:44 +01:00
Mario 78662a330f Translated using Weblate (Spanish)
Currently translated at 100.0% (486 of 486 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es/
2022-02-21 22:52:44 +01:00
Mario 8bfaa1d542 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (486 of 486 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es_419/
2022-02-21 22:52:44 +01:00
Mario 5a1bee13f9 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (486 of 486 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es_419/
2022-02-21 22:52:44 +01:00
Dennis Guse b01045abe4 CI: enable test and lint for PRs from forks. 2022-02-21 22:52:27 +01:00
dependabot[bot] d7fbae9321 Bump reactivecircus/android-emulator-runner from 2.22.0 to 2.23.0
Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.22.0 to 2.23.0.
- [Release notes](https://github.com/reactivecircus/android-emulator-runner/releases)
- [Changelog](https://github.com/ReactiveCircus/android-emulator-runner/blob/main/CHANGELOG.md)
- [Commits](https://github.com/reactivecircus/android-emulator-runner/compare/v2.22.0...v2.23.0)

---
updated-dependencies:
- dependency-name: reactivecircus/android-emulator-runner
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-18 06:44:15 +01:00
Dennis Guse ab5e6cbfcd Bugfix: emojis cannot be used as filenames.
Fixes #1129.
2022-02-17 18:36:06 +01:00
Dennis Guse 0848897ed0 Bugfix: speed showed sometimes 0.0 in UI.
Fixes #1117.
2022-02-17 18:19:17 +01:00
Arjan Schrijver 1e4e7b8c15 Allow starting the Dashboard API automatically from the StartRecording API
Fixes #1116.
2022-02-16 17:06:24 +01:00
Román Ginés 7ca6925e7d Recording without GPS: show a Snackbar to warn user that recording is not using GPS. 2022-02-15 17:20:44 +01:00
Dennis Guse 5c06c7548f GPS is now optional: request GPS access in TrackRecordingActivity.
Fixes #1061.
2022-02-14 22:17:10 +01:00
Dennis Guse 8001c03a74 Bugfix: do not leak TTS in voice announcements. 2022-02-14 22:01:52 +01:00
Dennis Guse 30f9b6ffdc Cleanup in VoiceAnnouncement: rename to start() and stop(). 2022-02-14 22:01:52 +01:00
Dennis Guse a7fd6b8dfa TrackRecordingService: call stopSelf after triggering post-workout export.
Might fix #1080.
2022-02-14 22:01:52 +01:00
Dennis Guse cc93384d1f TrackRecordingService: create/ release EGM2008. 2022-02-14 22:01:52 +01:00
Dennis Guse 78519883c0 Refactored ExportServiceResultReceiver to make TrackRecordingService simpler. 2022-02-14 22:01:52 +01:00
Dennis Guse 29051ac978 Cleanup: move creating manual TrackPoints into TrackRecordingManager. 2022-02-14 22:01:52 +01:00
Dennis Guse cf32c81035 Cleanup: extract RecordingData and RecordingStatus from TrackRecordingService. 2022-02-14 22:01:52 +01:00
Dennis Guse 094c97f78d Share: removed PNG share pic.
Fixes #1127.
2022-02-14 21:52:39 +01:00
FTno 8dca84456b Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (483 of 483 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-11 10:38:49 +01:00
Katto f962f27309 Translated using Weblate (Estonian)
Currently translated at 97.7% (469 of 480 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/et/
2022-02-07 07:28:00 +01:00
FTno 2f0826baf6 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (480 of 480 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-07 07:28:00 +01:00
Riku Viitanen b7ff0e2fac Translated using Weblate (Finnish)
Currently translated at 93.9% (451 of 480 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/fi/
2022-02-07 07:28:00 +01:00
Katto fe6ef29f75 Translated using Weblate (Estonian)
Currently translated at 97.7% (469 of 480 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/et/
2022-02-07 07:28:00 +01:00
Katto 152cf72e8a Translated using Weblate (Estonian)
Currently translated at 87.3% (69 of 79 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/app-store-metadata-files/et/
2022-02-07 07:28:00 +01:00
mondstern 4fd66a3830 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/vi/
2022-02-07 07:27:59 +01:00
mondstern 9f715207e1 Translated using Weblate (Ukrainian)
Currently translated at 99.1% (473 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/uk/
2022-02-07 07:27:59 +01:00
mondstern e63ddbc7ae Translated using Weblate (Slovak)
Currently translated at 99.5% (475 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/sk/
2022-02-07 07:27:59 +01:00
mondstern 06e5545ac0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/pt_BR/
2022-02-07 07:27:59 +01:00
mondstern 65413e0304 Translated using Weblate (Polish)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/pl/
2022-02-07 07:27:59 +01:00
FTno 5e268463e1 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/nb_NO/
2022-02-07 07:27:58 +01:00
mondstern d31ffad2c9 Translated using Weblate (Italian)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/it/
2022-02-07 07:27:58 +01:00
mondstern 46925a4c67 Translated using Weblate (Galician)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/gl/
2022-02-07 07:27:58 +01:00
mondstern 2a1aeb5dd8 Translated using Weblate (French)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/fr/
2022-02-07 07:27:58 +01:00
mondstern 454d5d16b5 Translated using Weblate (Basque)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/eu/
2022-02-07 07:27:58 +01:00
mondstern 45f51ff2dd Translated using Weblate (Estonian)
Currently translated at 98.5% (470 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/et/
2022-02-07 07:27:58 +01:00
Katto 785511913c Translated using Weblate (Estonian)
Currently translated at 98.5% (470 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/et/
2022-02-07 07:27:57 +01:00
mondstern b937e8ad9b Translated using Weblate (Spanish)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/es/
2022-02-07 07:27:57 +01:00
mondstern 40534cd7f5 Translated using Weblate (Danish)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/da/
2022-02-07 07:27:56 +01:00
mondstern f50b1c2f6c Translated using Weblate (Czech)
Currently translated at 100.0% (477 of 477 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://weblate.bubu1.eu/projects/OpenTracks/opentracks/cs/
2022-02-07 07:27:56 +01:00
dependabot[bot] 7c19621bfb Bump gradle from 7.1.0 to 7.1.1
Bumps gradle from 7.1.0 to 7.1.1.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 07:27:46 +01:00
Peter Storch 7c06327084 fix #1119 add new activities "swimming", "swimming open" and "workout" 2022-02-06 20:09:08 +01:00
Dennis Guse 19a958ccbb Cleanup. 2022-01-29 19:21:54 +01:00
Dennis Guse 2dc362c105 Cleanup. 2022-01-29 19:21:54 +01:00
Dennis Guse 708245bd50 TrackRecordingServices: managers register for sharedPreference changes. 2022-01-27 22:04:45 +01:00
Dennis Guse f4c07463b3 TrackDataHub: removed unused property. 2022-01-27 22:04:45 +01:00
Dennis Guse d8ffc1318f Refactor: move methods to NotificationManager. 2022-01-27 22:04:45 +01:00
Dennis Guse 5998f9127d TrackPointCreator needs to reset GPSHandler as it might have been running beforehand. 2022-01-27 22:04:45 +01:00
Dennis Guse 40fb9b05bc Lint: make missing plural quantity a warning as they have to be properly translated.
Occurred when upgrading to Android Gradle plugin 7.1.0.
2022-01-27 21:59:37 +01:00
Dennis Guse 8219bfe428 Remove unused plural.
Fixes #1105.
2022-01-27 21:59:37 +01:00
dependabot[bot] bcdea5b08b Bump preference from 1.1.1 to 1.2.0
Bumps preference from 1.1.1 to 1.2.0.

---
updated-dependencies:
- dependency-name: androidx.preference:preference
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-27 21:59:22 +01:00
dependabot[bot] 5e49a38dce Bump mockito-android from 4.3.0 to 4.3.1
Bumps [mockito-android](https://github.com/mockito/mockito) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v4.3.0...v4.3.1)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-26 19:58:16 +01:00
Dennis Guse 32e07c9d1f Reproducible builds: disable PNG crunching.
https://developer.android.com/studio/build/optimize-your-build#disable_crunching
2022-01-25 22:31:00 +01:00
Dennis Guse efc37527f3 Upgrade to Gradle 7.2/ Android Gradle Plugin 7.1. 2022-01-25 22:04:37 +01:00
dependabot[bot] 90a2710138 Bump mockito-android from 4.2.0 to 4.3.0
Bumps [mockito-android](https://github.com/mockito/mockito) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v4.2.0...v4.3.0)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-25 07:36:02 +01:00
Dennis Guse bf4dd96235 Update TrackPoint documentation. 2022-01-24 18:41:28 +01:00
198 changed files with 3786 additions and 2807 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2.4.0
uses: actions/checkout@v3
- uses: actions/setup-java@v2
with:
+4 -2
View File
@@ -1,6 +1,8 @@
name: Linting
on: [ push ]
on:
push:
pull_request_target:
jobs:
analyse:
@@ -9,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2.4.0
uses: actions/checkout@v3
- uses: actions/setup-java@v2
with:
+6 -3
View File
@@ -1,6 +1,9 @@
#https://github.com/marketplace/actions/android-emulator-runner
name: Test
on: [ push ]
on:
push:
pull_request_target:
jobs:
test:
timeout-minutes: 45
@@ -12,7 +15,7 @@ jobs:
fail-fast: false
steps:
- name: checkout
uses: actions/checkout@v2.4.0
uses: actions/checkout@v3
- uses: actions/setup-java@v2
with:
@@ -20,7 +23,7 @@ jobs:
java-version: '11'
- name: run tests
uses: reactivecircus/android-emulator-runner@v2.22.0
uses: reactivecircus/android-emulator-runner@v2.23.0
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
+1
View File
@@ -42,6 +42,7 @@ captures/
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/misc.xml
# IntelliJ IDEA
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="src/main/res/menu/aggregated_statistics.xml" value="0.10208333333333333" />
<entry key="src/main/res/xml/settings_gps.xml" value="0.22604166666666667" />
<entry key="src/main/res/xml/settings_sensors.xml" value="0.2101851851851852" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+14 -8
View File
@@ -118,27 +118,33 @@ __Only required permission:__
* _ACCESS_FINE_LOCATION_: required to use the GPS.
### API
The following functionalities by sending from an external application (e.g., [Automate](https://llamalab.com/automate/), [Tasker](https://tasker.joaoapps.com), or [Easer](https://github.com/renyuneyun/Easer)).
The API can be invoked by sending an Intent to start an activity.
Package (depends on the variant installed):
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 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`
Activity:
`Class`:
* **Start a recording:** `de.dennisguse.opentracks.publicapi.StartRecording`
* For testing via adb: `adb shell am start -n "package/activity"`
For testing via adb: `adb shell am start -n "package/class"`
The Start/Stop API is disabled by default to protect the user's privacy, but it can easily be enabled in the settings.
## Custom Dashboards (incl. map)
As of v3.3.1, OpenTracks supports custom dashboards.
The reference implementation is [OSMDashboard](https://github.com/OpenTracksApp/OSMDashboard), which presents an OpenStreetMap map (showing the current track, incl. updates).
The developer documentation is in [README_API.md](README_API.md).
As of v3.3.1, OpenTracks supports custom dashboards for displaying previously recorded and live tracks.
The reference implementation is [OSMDashboard](https://github.com/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).
## Project history
_OpenTracks_ is based upon Google _My Tracks app_ ([code](https://code.google.com/archive/p/mytracks/)).
+13 -6
View File
@@ -4,11 +4,13 @@
OpenTracks by itself only provides basic features for data visualization and also lacks functionality for analytics.
By itself OpenTracks provides a basic dashboard that can show summary of a track (while recording as well as afterwards).
Although this might be (mostly) sufficient, it has some limitations.
This include limited configurability (e.g., ordering of shown elements) and the lack of Internet access.
In fact, the later is considered a feature but severely limits the implementation presenting a map (at least in an easy way).
This includes limited configurability (e.g., ordering of shown elements) and the lack of internet access.
In fact, the latter is considered a feature but severely limits the implementation presenting a map (at least in an easy way).
For the time being, the OpenTracks Data API can be used to implement mainly custom dashboards.
The Data API is disabled by default to protect the user's privacy, but it can easily be enabled in the settings.
## Implementation
OpenTracks by default does not expose any data to 3rd-party applications.
On the request of the user (i.e., pressing a button), an Intent is created that exposes the `Track` data (it's summary), the `TrackPoint`s, and the `Waypoint`s of one or more selected tracks.
@@ -17,11 +19,16 @@ These URIs notify observers if on content changes (e.g., a new `TrackPoint` was
Limitations:
* only tracks selected by the user are exposed,
* `Waypoints` are not exposed,
* `Waypoint`s are not exposed,
* access is only granted temporarily and automatically revoked (see `FLAG_GRANT_READ_URI_PERMISSION`),
* no write access is possible, and
* neither track recordings can be started, paused, or stopped.
* no write access is possible
The Data API can also be started automatically by leveraging Extra fields in the explicit Intent for the Start/Stop API.
An application that's starting a recording with an explicit Intent can add the `STATS_TARGET_PACKAGE` and `STATS_TARGET_CLASS` Extra fields to let OpenTracks know it wants to receive the Data API URIs.
OpenTracks will then (if enabled in the settings) automatically return an explicit Intent to the Package + Class as defined in the received Extra fields, including the Data API URIs and read permissions.
By using explicit Intents for the calls and URIs with specific temporary read permissions, OpenTracks tries to protect against data leaking as much as possible.
All required information can be found in `IntentDashboardUtils.java`.
A _reference implementation_ is the [OSMDashboard](https://github.com/OpenTracksApp/OSMDashboard).
A _reference implementation_ is the [OSMDashboard](https://github.com/OpenTracksApp/OSMDashboard).
+6 -10
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.4'
classpath 'com.android.tools.build:gradle:7.1.2'
}
}
@@ -57,18 +57,13 @@ android {
viewBinding true
}
aaptOptions {
cruncherEnabled = false
}
defaultConfig {
applicationId "de.dennisguse.opentracks"
versionCode 4692
versionName "v3.24.1"
versionCode 4775
versionName "v3.26.1"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
minSdkVersion 21
targetSdkVersion 31
@@ -98,6 +93,7 @@ android {
}
release {
crunchPngs false
minifyEnabled false
}
@@ -124,7 +120,7 @@ dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'com.android.support.constraint:constraint-layout:2.0.4'
@@ -134,7 +130,7 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'org.mockito:mockito-android:4.2.0'
androidTestImplementation 'org.mockito:mockito-android:4.4.0'
androidTestUtil 'androidx.test:orchestrator:1.4.1'
}
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="22"
width="22"
fill="#000000"
version="1.0"
x="0px"
y="0px"
viewBox="0 0 21.84952 21.99934"
enable-background="new 0 0 99.316 99.997"
xml:space="preserve"
id="svg10"
sodipodi:docname="ic_activity_swimming.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
inkscape:export-filename="/home/peter/StudioProjects/OpenTracks/drawable-svg/ic_activity_swimming.png"
inkscape:export-xdpi="1309.09"
inkscape:export-ydpi="1309.09"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata16"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs14" /><sodipodi:namedview
pagecolor="#000000"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="1"
inkscape:pageshadow="2"
inkscape:window-width="1831"
inkscape:window-height="1039"
id="namedview12"
showgrid="false"
inkscape:pagecheckerboard="false"
inkscape:zoom="31.780381"
inkscape:cx="10.368032"
inkscape:cy="10.069105"
inkscape:window-x="33"
inkscape:window-y="3"
inkscape:window-maximized="0"
inkscape:current-layer="svg10"
inkscape:document-rotation="0" /><path
id="path2"
style="fill:#ffffff;stroke-width:0.20758"
d="m 18.327285,15.516652 c -1.017125,-0.01824 -1.903328,0.770666 -2.945647,0.500454 -0.952298,-0.271819 -1.950516,-0.765534 -2.949277,-0.338881 -0.938402,0.492101 -2.078868,0.493759 -3.0170251,0 C 8.4157577,15.251421 7.4164572,15.744755 6.4634103,16.017106 5.261142,16.31195 4.2317908,15.256338 3.0296629,15.558611 2.2388959,15.806745 1.0534821,16.11293 1.0534821,16.11293 v 1.764949 c 1.0226762,0.01947 1.9128198,-0.776355 2.9601398,-0.498166 1.4912769,0.589492 1.7842151,0.623743 2.7777974,0.375279 0.9553792,-0.44042 2.0942104,-0.606317 3.0559286,-0.07975 0.9566571,0.429062 1.9815791,0.09313 2.8934291,-0.263824 1.038124,-0.339466 1.95525,0.382078 2.955963,0.461915 1.087184,0.105691 2.019821,-0.784126 3.121615,-0.493622 0.55673,-0.01509 1.943197,1.034046 1.977683,0.148315 v -1.471252 c -0.878739,0.05089 -1.592095,-0.588897 -2.468753,-0.540124 z"
sodipodi:nodetypes="scccccccccccccccss" /><path
id="path2-1"
style="fill:#ffffff;stroke-width:0.20758"
d="m 18.327285,12.592022 c -1.017125,-0.01824 -1.903328,0.770666 -2.945647,0.500454 -0.952298,-0.271819 -1.950516,-0.765534 -2.949277,-0.338881 -0.938402,0.492101 -2.078868,0.493759 -3.0170249,0 C 8.4157578,12.326791 7.4164573,12.820125 6.4634104,13.092476 5.2611421,13.38732 4.2317909,12.331708 3.029663,12.633981 2.238896,12.882115 1.0534822,13.1883 1.0534822,13.1883 v 1.764949 c 1.0226762,0.01947 1.9128198,-0.776355 2.9601398,-0.498166 1.4912769,0.589492 1.7842151,0.623743 2.7777974,0.375279 0.9553792,-0.44042 2.0942104,-0.606317 3.0559286,-0.07975 0.956657,0.429062 1.981579,0.09313 2.893429,-0.263824 1.038124,-0.339466 1.95525,0.382078 2.955963,0.461915 1.087184,0.105691 2.019821,-0.784126 3.121615,-0.493622 0.55673,-0.01509 1.943197,1.034046 1.977683,0.148315 v -1.471252 c -0.878739,0.05089 -1.592095,-0.588897 -2.468753,-0.540124 z"
sodipodi:nodetypes="scccccccccccccccss" /><path
id="path851"
style="fill:#ffffff;stroke-width:1.16861"
d="m 15.771645,8.3604958 c 0.05505,1.1807818 -1.406378,2.0132882 -2.394197,1.3656158 -1.044532,-0.5537235 -1.017922,-2.2353035 0.04332,-2.7559408 1.018447,-0.5919723 2.378758,0.20782 2.350877,1.390325 z" /><path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.18448px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 4.3145569,12.014368 9.3210561,8.9478742 6.786081,6.2628496 11.908511,3.5970428 c 0.936726,-0.3746046 1.601464,0.5528762 1.096915,1.1662478 L 9.4152098,6.6598661 15.504839,12.51419 c -1.334475,-0.390685 -1.595745,-1.008768 -2.663055,-0.656789 -2.818488,0.929488 -2.219707,0.0047 -4.240291,0.02086 -2.2836952,0.01821 -1.8989609,1.154049 -4.2869361,0.136107 z"
id="path2791"
sodipodi:nodetypes="cccccccssc" /></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="22"
width="22"
fill="#000000"
version="1.0"
x="0px"
y="0px"
viewBox="0 0 21.84952 21.99934"
enable-background="new 0 0 99.316 99.997"
xml:space="preserve"
id="svg10"
sodipodi:docname="ic_activity_swimming_open.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
inkscape:export-filename="/home/peter/StudioProjects/OpenTracks/drawable-svg/ic_activity_swimming_open.png"
inkscape:export-xdpi="1309.09"
inkscape:export-ydpi="1309.09"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata16"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs14" /><sodipodi:namedview
pagecolor="#000000"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="1"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1047"
id="namedview12"
showgrid="false"
inkscape:pagecheckerboard="false"
inkscape:zoom="15.890191"
inkscape:cx="10.006173"
inkscape:cy="13.719156"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg10"
inkscape:document-rotation="0" /><path
id="path2"
style="fill:#ffffff;stroke-width:0.150857"
d="m 2.8183594,17.572266 c -0.1057708,4.65e-4 -0.2130618,0.01356 -0.3222656,0.04102 -0.5746803,0.180329 -1.4355469,0.402344 -1.4355469,0.402344 v 1.28125 c 0.7432174,0.01415 1.3912167,-0.563498 2.1523437,-0.361328 1.0837672,0.428407 1.295504,0.454005 2.0175782,0.273437 0.6943098,-0.32007 1.5217861,-0.441266 2.2207031,-0.05859 0.6952388,0.311815 1.4408397,0.06801 2.1035156,-0.191407 0.7544435,-0.246702 1.4211805,0.277917 2.1484375,0.335938 0.621051,-0.0088 1.351954,-0.399393 1.818402,-0.396552 0.536698,0.02254 1.051376,0.234242 1.498081,0.382763 0.357762,0.07663 0.717881,0.02104 0.970698,-0.07223 0.717277,-0.264623 1.521787,-0.441266 2.220704,-0.05859 0.695237,0.311815 1.440839,0.06801 2.103515,-0.191407 0.229629,-0.07509 0.450663,-0.07784 0.667969,-0.04492 v -1.314453 c -0.295925,-0.04096 -0.594072,-0.02791 -0.892578,0.09961 -0.681973,0.357628 -1.511565,0.358833 -2.19336,0 -0.726431,-0.310175 -1.451915,0.04817 -2.144531,0.246093 -0.116646,0.02861 -0.230986,0.0388 -0.34375,0.03711 v -0.0078 c -0.304634,0.02335 -0.610512,-0.122847 -0.878909,-0.189453 -1.061726,-0.598921 -2.285933,0.148857 -3.056646,0.16011 -0.510364,-0.111475 -1.41674,-0.556159 -2.1425779,-0.246093 -0.6819724,0.357628 -1.5115649,0.358833 -2.1933593,0 C 6.410287,17.389044 5.6848033,17.747388 4.9921875,17.945312 4.2276707,18.132803 3.558755,17.569011 2.8183594,17.572266 Z"
transform="matrix(0.99997,0,0,0.99997,-0.07491,0)"
sodipodi:nodetypes="sccccccccccsccccccccccccccs" /><path
id="path2-1-1"
style="fill:#ffffff;stroke-width:0.150857"
d="m 10.229667,15.445213 c -0.2340521,-0.01177 -0.6643302,0.02851 -0.8995889,0.129006 -0.6819724,0.357628 -1.5115649,0.358834 -2.1933593,0 C 6.410287,15.264044 5.6848033,15.622386 4.9921875,15.820312 4.1184541,16.034604 3.3697256,15.266655 2.4960938,15.486328 1.9214135,15.666657 1.0605469,15.890625 1.0605469,15.890625 v 1.28125 c 0.7432175,0.01415 1.3912167,-0.563498 2.1523437,-0.361328 1.0837672,0.428406 1.2955041,0.454006 2.0175782,0.273437 0.6943098,-0.320069 1.5217861,-0.441275 2.2207031,-0.05859 0.3325161,0.149133 0.6767832,0.169105 1.0175781,0.123047 1.1763193,-0.377141 1.78456,-0.510947 3.130219,0.05666 0.64547,0.05184 1.34431,-0.445591 2.223297,-0.423842 0.264657,-0.01332 1.092167,0.246652 1.478515,0.365234 h 0.002 c 0.560757,0.08029 1.135035,-0.115511 1.658204,-0.320312 0.754443,-0.246704 1.42118,0.277917 2.148437,0.335937 0.679991,0.06611 1.277834,-0.402757 1.941406,-0.40039 v -1.3125 c -0.0096,3.1e-4 -0.01967,-5.36e-4 -0.0293,0 -0.739183,-0.01326 -1.383132,0.559654 -2.140625,0.363281 -0.69207,-0.197542 -1.41674,-0.556158 -2.142578,-0.246094 -1.655465,0.798879 -2.131536,-0.136064 -3.123047,-0.109375 -0.566651,-0.01017 -1.078155,0.324314 -1.626953,0.392578 -0.768304,0.02005 -1.124822,-0.242268 -1.758614,-0.404396 z"
transform="matrix(0.99997,0,0,0.99997,-0.07491,0)"
sodipodi:nodetypes="cccccccccccccccccccccccccc" /><path
id="path851"
style="fill:#ffffff;stroke-width:0.849248"
d="m 11.681761,12.380821 c 0.04001,0.858093 -1.022037,1.463089 -1.7399012,0.992415 -0.759078,-0.4024 -0.73974,-1.624431 0.03148,-2.002787 0.7401222,-0.430195 1.7286822,0.151027 1.7084202,1.010372 z" /><path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.860781px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 3.355712,15.036148 3.6383028,-2.22847 -1.842207,-1.95125 3.722552,-1.9372846 c 0.680734,-0.2722312 1.1638102,0.4017839 0.797146,0.8475309 l -2.609068,1.3782717 4.4254312,4.254431 c -0.969784,-0.283917 -1.159653,-0.733088 -1.9352852,-0.477299 -2.04824,0.675474 -1.613096,0.0034 -3.081487,0.01516 -1.659598,0.01323 -1.380005,0.838666 -3.1153848,0.09891 z"
id="path2791"
sodipodi:nodetypes="cccccccssc" /><path
id="rect4320"
style="fill:#ffffff;stroke-width:1.00154;stroke-linecap:square;paint-order:markers fill stroke"
d="m 21.108524,13.306347 c -0.260342,0.07218 -0.504886,-0.363252 -0.886457,-0.333843 -0.682913,0.05263 -0.688525,0.273741 -1.170401,0.333103 -0.56376,-0.01179 -0.705141,-0.39106 -1.323093,-0.385461 -0.797244,0.05005 -0.816589,0.344784 -1.506419,0.327831 -0.635156,-0.006 -0.584758,-0.400757 -1.137589,-0.37363 -0.445887,0 -0.423469,0.363096 -1.030167,0.33682 0.804394,-1.992062 7.313411,-2.472785 7.054126,0.09518 z"
sodipodi:nodetypes="cscccccc" /><path
id="rect4573"
style="fill:#ffffff;stroke-width:1.00154;stroke-linecap:square;paint-order:markers fill stroke"
d="m 15.952342,6.3416747 0.804782,0.00553 c 0.791609,0.9558102 1.568049,2.3417023 2.288114,5.3258424 l -1.791332,0.368162 c 0.0085,-1.595974 -0.327662,-3.5171502 -1.301564,-5.6995303 z"
sodipodi:nodetypes="ccccc" /><path
id="rect5178"
style="fill:#ffffff;stroke-width:1.00154;stroke-linecap:square;paint-order:markers fill stroke"
d="M 15.714534,5.7993213 16.460602,5.694421 C 17.892566,5.5299173 18.930384,6.2178215 19.826278,7.1622728 18.315795,6.5191329 17.758674,6.2722082 16.194793,6.4666796 14.820535,6.8297473 13.804944,7.2159692 13.25106,7.9257197 13.396619,7.3313592 13.938011,6.4826988 15.714534,5.7993213 Z"
sodipodi:nodetypes="cccccc" /><path
id="rect5178-8"
style="fill:#ffffff;stroke-width:0.961457;stroke-linecap:square;paint-order:markers fill stroke"
d="m 16.165622,5.4722594 c -0.08973,0.080465 -0.17851,0.1681115 -0.26532,0.2648771 l -0.0964,0.1131325 0.390891,0.6164106 C 17.150623,5.4034465 17.686156,5.2561052 19.060064,4.8593949 18.032481,4.6520987 17.033002,4.6944293 16.165622,5.4722594 Z" /><path
id="rect5178-5"
style="fill:#ffffff;stroke-width:0.928756;stroke-linecap:square;paint-order:markers fill stroke"
d="M 16.314771,5.73079 C 16.294181,5.717361 16.273991,5.703711 16.252891,5.690635 L 15.674258,5.3521611 c -1.591186,-0.4463567 -2.388606,-0.054483 -2.781886,0.3520817 0.738652,-0.2696642 1.645743,-0.011014 2.795776,0.4705547 0.09419,0.057052 0.180726,0.1118664 0.264194,0.1668772 z" /></svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

+469
View File
@@ -0,0 +1,469 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
sodipodi:docname="ic_activity_workout.01.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
id="svg8"
version="1.1"
viewBox="0 0 5.8208333 5.8208333"
height="22"
width="22"
inkscape:export-filename="/home/peter/StudioProjects/OpenTracks/drawable-svg/ic_activity_workout.01.png"
inkscape:export-xdpi="1309.09"
inkscape:export-ydpi="1309.09"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<inkscape:path-effect
lpeversion="1"
is_visible="true"
id="path-effect3162"
effect="spiro" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.9999999,0,1 @ F,0,0,1,0,2.0000001,0,1"
lpeversion="1"
is_visible="true"
id="path-effect3149"
effect="fillet_chamfer" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2514"
is_visible="true"
lpeversion="1"
satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,4,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.97014234,0,1 @ F,0,0,1,0,0,0,1"
unit="px"
method="auto"
mode="F"
radius="0"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0.51887107,0,1 @ F,0,0,1,0,0.22687835,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0.51887107,0,1 @ F,0,0,1,0,0.22687835,0,1 @ F,0,0,1,0,0,0,1"
lpeversion="1"
is_visible="true"
id="path-effect950"
effect="fillet_chamfer" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0.34892149,0,1 @ F,0,0,1,0,0.18398697,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.34892149,0,1 @ F,0,0,1,0,0.18398697,0,1 @ F,0,1,1,0,0,0,1"
lpeversion="1"
is_visible="true"
id="path-effect946"
effect="fillet_chamfer" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.00309,0,1 @ F,0,0,1,0,2.00309,0,1"
lpeversion="1"
is_visible="true"
id="path-effect918"
effect="fillet_chamfer" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,7.0000002,0,1 @ F,0,0,1,0,0,0,1"
lpeversion="1"
is_visible="true"
id="path-effect912"
effect="fillet_chamfer" />
<inkscape:path-effect
hide_knots="false"
only_selected="false"
apply_with_radius="true"
apply_no_radius="true"
use_knot_distance="true"
flexible="false"
chamfer_steps="1"
radius="0"
mode="F"
method="auto"
unit="px"
satellites_param="F,0,0,1,0,0.49832891,0,1 @ F,0,0,1,0,0.49832891,0,1 @ F,0,1,1,0,0.5316463,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.49999976,0,1 @ F,0,0,1,0,0.50000024,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0000001,0,1 @ F,0,0,1,0,0.99999982,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0295214,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.5316463,0,1"
lpeversion="1"
is_visible="true"
id="path-effect894"
effect="fillet_chamfer" />
</defs>
<sodipodi:namedview
inkscape:window-maximized="1"
inkscape:window-y="0"
inkscape:window-x="0"
inkscape:window-height="1047"
inkscape:window-width="1920"
inkscape:guide-bbox="true"
showguides="false"
inkscape:snap-text-baseline="true"
inkscape:snap-center="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:object-paths="true"
inkscape:snap-bbox-midpoints="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="true"
inkscape:snap-bbox="true"
units="px"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer1"
inkscape:document-units="mm"
inkscape:cy="10.326952"
inkscape:cx="15.790447"
inkscape:zoom="31.664715"
inkscape:pageshadow="2"
inkscape:pageopacity="1"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#232629"
id="base"
inkscape:pagecheckerboard="0"
inkscape:snap-grids="false">
<inkscape:grid
dotted="true"
empspacing="22"
id="grid833"
type="xygrid" />
<inkscape:grid
originy="0.26458333"
originx="0.26458333"
spacingx="1.0583333"
spacingy="1.0583333"
empspacing="1"
id="grid835"
type="xygrid" />
<sodipodi:guide
inkscape:color="rgb(0,0,255)"
inkscape:locked="false"
inkscape:label=""
id="guide887"
orientation="0.96592583,0.25881905"
position="4.195,4.2333333" />
<sodipodi:guide
id="guide900"
orientation="0,-1"
position="0.26458333,1.9843751" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<g
transform="matrix(0.38576851,0,0,0.38576851,152.56385,-12.494408)"
id="g954">
<rect
style="fill:#cacaca;fill-opacity:1;stroke-width:14.6855;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect919"
width="68.791664"
height="79.375"
x="-357.1875"
y="-7.9375" />
<rect
style="fill:#e74c3c;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect851"
width="10.583333"
height="10.583333"
x="-354.54169"
y="-5.2916665" />
<rect
y="-5.2916665"
x="-343.95834"
height="10.583333"
width="10.583333"
id="rect853"
style="fill:#ed1515;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#f47750;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect855"
width="10.583333"
height="10.583333"
x="-333.375"
y="-5.2916665" />
<rect
y="-5.2916665"
x="-322.79169"
height="10.583333"
width="10.583333"
id="rect857"
style="fill:#fdbc4b;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#c9ce3b;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect859"
width="10.583333"
height="10.583333"
x="-312.20834"
y="-5.2916665" />
<rect
y="5.291667"
x="-354.54169"
height="10.583333"
width="10.583333"
id="rect861"
style="fill:#da4453;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#c0392b;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect863"
width="10.583333"
height="10.583333"
x="-343.95834"
y="5.291667" />
<rect
y="5.291667"
x="-333.375"
height="10.583333"
width="10.583333"
id="rect865"
style="fill:#d35400;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#f67400;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect867"
width="10.583333"
height="10.583333"
x="-322.79169"
y="5.291667" />
<rect
y="5.291667"
x="-312.20834"
height="10.583333"
width="10.583333"
id="rect869"
style="fill:#f39c1f;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
y="21.166666"
x="-354.54169"
height="10.583333"
width="10.583333"
id="rect871"
style="fill:#1cdc9a;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#2ecc71;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect873"
width="10.583333"
height="10.583333"
x="-343.95834"
y="21.166666" />
<rect
y="21.166666"
x="-333.375"
height="10.583333"
width="10.583333"
id="rect875"
style="fill:#1abc9c;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#3498db;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect877"
width="10.583333"
height="10.583333"
x="-322.79169"
y="21.166666" />
<rect
y="21.166666"
x="-312.20834"
height="10.583333"
width="10.583333"
id="rect879"
style="fill:#3daee9;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#10d116;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect881"
width="10.583333"
height="10.583333"
x="-354.54169"
y="31.75" />
<rect
y="31.75"
x="-343.95834"
height="10.583333"
width="10.583333"
id="rect883"
style="fill:#27ae60;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#16a085;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect885"
width="10.583333"
height="10.583333"
x="-333.375"
y="31.75" />
<rect
y="31.75"
x="-322.79169"
height="10.583333"
width="10.583333"
id="rect887"
style="fill:#2980b9;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#1d99f3;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect889"
width="10.583333"
height="10.583333"
x="-312.20834"
y="31.75" />
<rect
style="fill:#9b59b6;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect891"
width="10.583333"
height="10.583333"
x="-301.625"
y="21.166666" />
<rect
y="31.75"
x="-301.625"
height="10.583333"
width="10.583333"
id="rect893"
style="fill:#8e44ad;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#34495e;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect895"
width="10.583333"
height="10.583333"
x="-354.54166"
y="47.625" />
<rect
y="47.625"
x="-343.95831"
height="10.583333"
width="10.583333"
id="rect897"
style="fill:#31363b;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#7f8c8d;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect899"
width="10.583333"
height="10.583333"
x="-333.37497"
y="47.625" />
<rect
y="47.625"
x="-322.79166"
height="10.583333"
width="10.583333"
id="rect901"
style="fill:#bdc3c7;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#fcfcfc;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect903"
width="10.583333"
height="10.583333"
x="-312.20831"
y="47.625" />
<rect
y="58.208336"
x="-354.54166"
height="10.583333"
width="10.583333"
id="rect905"
style="fill:#2c3e50;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#232629;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect907"
width="10.583333"
height="10.583333"
x="-343.95831"
y="58.208336" />
<rect
y="58.208336"
x="-333.37497"
height="10.583333"
width="10.583333"
id="rect909"
style="fill:#4d4d4d;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
<rect
style="fill:#95a5a6;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552"
id="rect911"
width="10.583333"
height="10.583333"
x="-322.79166"
y="58.208336" />
<rect
y="58.208336"
x="-312.20831"
height="10.583333"
width="10.583333"
id="rect913"
style="fill:#eff0f1;fill-opacity:1;stroke-width:14.955;stroke-linecap:square;stroke-linejoin:round;stroke-dashoffset:531.552" />
</g>
<path
id="path17"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.730205;stroke-opacity:1"
d="m 12.521484,5.0957031 c -0.795342,0 -1.447265,0.6634257 -1.447265,1.4746094 0,0.811183 0.651923,1.4746094 1.447265,1.4746094 0.795342,0 1.445313,-0.663426 1.445313,-1.4746094 0,-0.8111837 -0.649971,-1.4746094 -1.445313,-1.4746094 z m 5.697266,2.2871094 c -1.005293,9.03e-5 -1.820222,0.8150191 -1.820312,1.8203125 0.001,0.2581612 0.02839,0.6283924 0.303378,0.93547 L 15.712713,10.886411 13.488221,8.9477292 10.263672,8.5957031 7.5078513,10.338038 5.7695312,12.576172 c -0.3754732,0.661347 0.1816517,1.249724 0.7773438,0.966797 l 1.7128929,-2.075723 1.9003931,-1.220112 -0.5761766,4.110288 -2.5878906,5.929687 1.484375,-0.0293 c 0.4801983,-0.907334 2.5996092,-5.441406 2.5996092,-5.441406 l 2.054688,0.941406 v 4.423829 h 1.445312 v -5.53125 L 12.75,13.876953 l 0.650391,-3.398437 2.285156,2.103515 1.747452,-1.734477 c 0.31097,0.238627 0.542791,0.17537 0.785751,0.175884 1.005294,-9e-5 1.820222,-0.815019 1.820312,-1.820313 -9e-5,-1.0052934 -0.815019,-1.8202222 -1.820312,-1.8203125 z m 0.0059,1.3535156 c 0.274357,9.402e-4 0.496232,0.2236881 0.496094,0.4980469 1.38e-4,0.2743588 -0.221737,0.4971067 -0.496094,0.4980469 C 17.94953,9.7325585 17.726466,9.5094953 17.726603,9.234375 17.726466,8.9592547 17.94953,8.7361915 18.22465,8.7363281 Z"
transform="scale(0.26458333)"
sodipodi:nodetypes="sssssccccccccccccccccccccccccccccccc" />
<path
id="path9403"
style="fill:#ffffff;stroke:none;stroke-width:0.264999;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none;paint-order:markers fill stroke"
d="M 1.0131059,0.53415337 C 0.73729957,0.54114168 0.64785326,0.76400686 0.64775351,1.04575 c 0.001003,0.049428 0.006811,0.095893 0.0170532,0.1405599 H 1.1996578 L 1.3303992,1.4400412 1.6714636,0.85558076 1.9071081,1.1801088 2.1840938,1.1826926 C 2.1968962,1.1377378 2.2034247,1.0922518 2.2032141,1.04575 2.2031128,0.75953474 2.1266608,0.53477144 1.8404456,0.53467013 1.540136,0.53761574 1.5284374,0.67900891 1.4327185,0.76204643 1.3421819,0.69057893 1.2614053,0.53055962 1.0265417,0.53415337 c -0.00447,1.59e-6 -0.00906,-1.1091e-4 -0.013436,0 z M 2.1840938,1.1826926 c -0.016723,0.058723 -0.044375,0.116296 -0.079582,0.173116 l 0.4873088,-0.00258 V 1.1863099 Z M 2.1045121,1.3558086 1.7965206,1.3573589 1.6740474,1.16874 1.3009436,1.7361472 1.0828691,1.3532248 H 0.7273352 C 0.8490025,1.5865985 1.0981629,1.7800149 1.3903438,2.0580913 1.669028,1.8167708 1.9577231,1.5927143 2.1045121,1.3558086 Z M 0.7273352,1.3532248 C 0.69965645,1.3001334 0.67827264,1.2450376 0.66480673,1.1863099 H 0.25914674 v 0.1669149 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,11 @@
v3.25.0: OpenTracks
Changes:
- GPS is now optional incl. GPS disabled warning
- PublicAPI: other applications can now receive recorded data if they start a recording (implemented for Gadgetbridge)
- New activities: swimming, swimming open, and workout
- Removed share pic functionality
Bugfix:
- UI: speed/elevation where jumping between 0.0 and the actual value
- Tracks with emojis can be exported
@@ -0,0 +1,6 @@
v3.25.1: OpenTracks
Bugfix:
- Automatic export: hopefully prevent silent failures
- Automatic export: prevent crash
@@ -0,0 +1,8 @@
v3.26.0: OpenTracks
Changes:
- Voice announcements are configurable
- Voice announcements of heart rate
Bugfix:
- Crash due to race condition
@@ -0,0 +1,4 @@
v3.26.1: OpenTracks
Bugfix:
- Contextual action bar didn't overlap toolbar in night theme.
@@ -1,11 +1,22 @@
OpenTracks on spordi jälgimise rakendus, mis austab täielikult teie privaatsust.
Spordijälgija, mis austab teie privaatsust.
Omadused:
* Spordi jälgimine: jälgige oma spordi- ja vabaõhutegevust.
* Fotod ja markerid: märkige jälgimise ajal huvipakkuvad kohad.
* Kõrguse suurenemine ja vähenemine: baromeetrilise anduri abil
* Kõrgus näitab EGM2008 (üle keskmise merepinna); eksporditakse WGS84 kujul.
* Häälteated
* Toetab Bluetooth LE andureid: südame löögisagedus, kiirus ja distants (jalgrattasõit), takistus (jalgrattasõit) ja jõumõõtja (jalgrattasõit).
* Andmete eksport: eksportida rajad kas KMZ (sh fotod), KML või GPX kujul.
* Interneti-ühendus puudub: Internetti ei kasutata
Sport ja välitegevused garanteerivad õnnelikkuse, vilumuse ja kindlustunde.
Väärtusta oma tervist, jälgides on treeninguid.
Salvestab kui lähed jooksma või jalutama ja annab teile jalgrattasõiduks suurema ekraani.
Märgi huvitavad asukohad oma teel, koos piltidega.
Hoia salvestatud statistika analüüsimiseks.
Jaga ainult neid andmeid, mida soovid, et teised näeksid.
* Häälteated.
* Toetab Bluetooth LE andureid: pulss, kiirus, distants (jalgrattasõit), rütm (jalgrattasõit) ja jõumõõtja (jalgrattasõit).
* Kõrguse tõus ja langus: baromeetrilise anduri abil.
* Kõrgus näitab EGM2008 (üle keskmise merepinna); eksporditakse WGS84-na.
* Ekspordit teekond kas KMZ- (koos fotodega), KML- või GPX-vormingus.
* Ei vaja internetile ligipääsu ega lisalubasid.
* Tume ja hele teema, austab süsteemi sätteid.
* Reklaamivaba.
Vabavara / Avatud Lähtekoodiga Tarkvara
mis tähendab, et võite lähtekoodi kasutada, uurida, muuta ja jagada.
Litsentsitud Apache 2.0
@@ -1 +1 @@
Spordijälgija, mis austab täielikult teie privaatsust.
Spordijälgija, mis austab teie privaatsust.
@@ -1 +1 @@
OpenTracks - Suivi d'activités sportives respectueux de votre vie privée.
Appli de suivi des activités sportives respectueuse de votre vie privée.
+1 -1
View File
@@ -1,6 +1,6 @@
#Wed Jul 28 23:10:13 CEST 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+2
View File
@@ -6,6 +6,8 @@
<issue id="ExtraTranslation" severity="error" />
<issue id="GifUsage" severity="error" />
<issue id="InconsistentArrays" severity="error" />
<issue id="MissingQuantity"
severity="warning" />
<issue id="MissingTranslation" severity="warning" />
<issue id="Proguard" severity="warning" />
<issue id="ScrollViewCount" severity="error" />
@@ -13,22 +13,20 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.anything;
import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition;
import static de.dennisguse.opentracks.util.EspressoUtils.veryLongTouch;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import static de.dennisguse.opentracks.util.EspressoUtils.withListSize;
import android.app.ActivityManager;
import android.app.Instrumentation;
import android.content.Context;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ListView;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.espresso.IdlingResource;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.action.MotionEvents;
import androidx.test.espresso.assertion.ViewAssertions;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -37,7 +35,6 @@ import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.After;
import org.junit.Before;
@@ -65,12 +62,12 @@ public class EspressoDeleteTrackTest {
Instrumentation instrumentation
= InstrumentationRegistry.getInstrumentation();
idlingResource = new MyIdlingResource(instrumentation.getTargetContext());
Espresso.registerIdlingResources(idlingResource);
IdlingRegistry.getInstance().register(idlingResource);
}
@After
public void unregisterIntentServiceIdlingResource() {
Espresso.unregisterIdlingResources(idlingResource);
IdlingRegistry.getInstance().unregister(idlingResource);
}
@Ignore("Test fails permanently")
@@ -140,87 +137,6 @@ public class EspressoDeleteTrackTest {
onView(withId(R.id.track_list)).check(ViewAssertions.matches(withListSize(countBefore - 1)));
}
private static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
return new TypeSafeMatcher<>() {
@Override
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
public static Matcher<View> withListSize (final int size) {
return new TypeSafeMatcher<>() {
@Override
public boolean matchesSafely(final View view) {
return ((ListView) view).getCount() == size;
}
@Override
public void describeTo(final Description description) {
description.appendText("ListView should have " + size + " items");
}
};
}
private static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
}
};
}
private static ViewAction veryLongTouch(final int duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Perform long touch.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
// Get view absolute position
int[] location = new int[2];
view.getLocationOnScreen(location);
// Offset coordinates by view position
float[] coordinates = new float[]{location[0] + 1, location[1] + 1};
// Send down event, pause, and send up
MotionEvent down = MotionEvents.sendDown(uiController, coordinates, new float[]{1f, 1f}).down;
uiController.loopMainThreadForAtLeast(duration_ms);
MotionEvents.sendUp(uiController, down, coordinates);
}
};
}
private int numberOfItemsListView() {
final int[] counts = new int[1];
@@ -2,52 +2,43 @@ package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.is;
import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition;
import static de.dennisguse.opentracks.util.EspressoUtils.veryLongTouch;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.action.MotionEvents;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.espresso.util.TreeIterables;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.core.IsInstanceOf;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.concurrent.TimeoutException;
import java.util.Locale;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoEditTrackRecordingTest {
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@@ -71,26 +62,10 @@ public class EspressoEditTrackRecordingTest {
trackControllerRecordButton.perform(waitFor(15000));
// open menu
ViewInteraction overflowMenuButton = onView(
allOf(withContentDescription("More options"),
childAtPosition(
childAtPosition(
withId(R.id.toolbar),
2),
2),
isDisplayed()));
overflowMenuButton.perform(click());
openContextualActionModeOverflowMenu();
// click on edit
ViewInteraction appCompatTextView = onView(
allOf(withId(R.id.title), withText("Edit"),
childAtPosition(
childAtPosition(
withId(R.id.content),
0),
0),
isDisplayed()));
appCompatTextView.perform(click());
// Click the item.
onView(withText(R.string.menu_edit)).perform(click());
// change name for "New Name"
ViewInteraction textInputEditText = onView(
@@ -147,112 +122,4 @@ public class EspressoEditTrackRecordingTest {
}
}
private static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
return new TypeSafeMatcher<>() {
@Override
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
public static ViewAction waitId(final int viewId, final long millis) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}
@Override
public String getDescription() {
return "wait for a specific view with id <" + viewId + "> during " + millis + " millis.";
}
@Override
public void perform(final UiController uiController, final View view) {
uiController.loopMainThreadUntilIdle();
final long startTime = System.currentTimeMillis();
final long endTime = startTime + millis;
final Matcher<View> viewMatcher = withId(viewId);
do {
for (View child : TreeIterables.breadthFirstViewTraversal(view)) {
// found view with required ID
if (viewMatcher.matches(child)) {
return;
}
}
uiController.loopMainThreadForAtLeast(50);
}
while (System.currentTimeMillis() < endTime);
// timeout happens
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(new TimeoutException())
.build();
}
};
}
private static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
}
};
}
private static ViewAction veryLongTouch(final int duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Perform long touch.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
// Get view absolute position
int[] location = new int[2];
view.getLocationOnScreen(location);
// Offset coordinates by view position
float[] coordinates = new float[]{location[0] + 1, location[1] + 1};
// Send down event, pause, and send up
MotionEvent down = MotionEvents.sendDown(uiController, coordinates, new float[]{1f, 1f}).down;
uiController.loopMainThreadForAtLeast(duration_ms);
MotionEvents.sendUp(uiController, down, coordinates);
}
};
}
}
@@ -1,5 +1,15 @@
package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.anything;
import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.view.MotionEvent;
import android.view.View;
@@ -12,21 +22,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import com.google.android.material.tabs.TabLayout;
import org.hamcrest.Matcher;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.anything;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoUITest {
@@ -136,43 +136,4 @@ public class EspressoUITest {
};
}
private static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
}
};
}
private static ViewAction selectTabAtIndex(final int index) {
return new ViewAction() {
@Override
public String getDescription() {
return "Selecting tab.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, View view) {
TabLayout tabLayout = (TabLayout) view;
tabLayout.getTabAt(index).select();
}
};
}
}
@@ -0,0 +1,55 @@
package de.dennisguse.opentracks;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import androidx.test.core.app.ApplicationProvider;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.util.Locale;
public class LocaleRule implements TestRule {
private final Locale[] mLocales;
private Locale mDeviceLocale;
public LocaleRule(Locale... locales) {
assert locales != null && locales.length > 0;
mLocales = locales;
}
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try {
mDeviceLocale = Locale.getDefault();
for (Locale locale : mLocales) {
setLocale(locale);
base.evaluate();
}
} finally {
if (mDeviceLocale != null) {
setLocale(mDeviceLocale);
}
}
}
};
}
private void setLocale(Locale locale) {
Resources resources = ApplicationProvider.getApplicationContext().getResources();
Locale.setDefault(locale);
Configuration config = resources.getConfiguration();
config.setLocale(locale);
DisplayMetrics displayMetrics = resources.getDisplayMetrics();
resources.updateConfiguration(config, displayMetrics);
}
}
@@ -125,7 +125,7 @@ public class TestDataUtil {
trackPoint.setAltitude(i * ALTITUDE_INTERVAL);
trackPoint.setSpeed(Speed.of(5f + (i / 10f)));
trackPoint.setHeartRate(100f + i);
trackPoint.setHeartRate(100f + i % 80);
trackPoint.setCadence(300f + i);
trackPoint.setPower(400f + i);
trackPoint.setAltitudeGain(ALTITUDE_GAIN);
@@ -105,7 +105,7 @@ public class ExportImportTest {
tmpFile = File.createTempFile("test", "test", context.getFilesDir());
tmpFileUri = Uri.fromFile(tmpFile);
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(10), Distance.of(200), true);
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(200), true);
TrackRecordingServiceTestUtils.resetService(mServiceRule, context);
}
@@ -44,7 +44,7 @@ public class GPXImportTest {
@Before
public void setUp() {
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(10), Distance.of(200), true);
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(200), true);
}
@After
@@ -42,7 +42,7 @@ public class KMLImportTest {
@Before
public void setUp() {
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(10), Distance.of(200), true);
trackImporter = new TrackImporter(context, contentProviderUtils, Distance.of(200), true);
}
@After
@@ -123,7 +123,7 @@ public class TrackRecordingServiceTestStateMachine {
assertEquals(GpsStatusValue.GPS_NONE, service.getGpsStatusObservable().getValue());
// when
service.tryStartGps();
service.tryStartSensors();
Thread.sleep(1000);
// then
@@ -132,7 +132,7 @@ public class TrackRecordingServiceTestStateMachine {
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
// when
service.stopGps(true);
service.stopSensorsAndShutdown();
Thread.sleep(1000);
// then
@@ -153,7 +153,7 @@ public class TrackRecordingServiceTestStateMachine {
// then
assertTrue(service.isRecording());
assertEquals(new TrackRecordingService.RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertEquals(new RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertNotEquals(TrackRecordingService.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
@@ -165,7 +165,7 @@ public class TrackRecordingServiceTestStateMachine {
// then
assertTrue(service.isRecording());
assertTrue(service.isPaused());
assertEquals(new TrackRecordingService.RecordingStatus(trackId, true), service.getRecordingStatusObservable().getValue());
assertEquals(new RecordingStatus(trackId, true), service.getRecordingStatusObservable().getValue());
assertNotEquals(TrackRecordingService.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_NONE, service.getGpsStatusObservable().getValue()); //TODO The GPS is kept in pause, right? Might be a bug.
@@ -177,7 +177,7 @@ public class TrackRecordingServiceTestStateMachine {
// then
assertTrue(service.isRecording());
assertFalse(service.isPaused());
assertEquals(new TrackRecordingService.RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertEquals(new RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertNotEquals(TrackRecordingService.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
@@ -201,7 +201,7 @@ public class TrackRecordingServiceTestStateMachine {
// then
assertTrue(service.isRecording());
assertFalse(service.isPaused());
assertEquals(new TrackRecordingService.RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertEquals(new RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
Thread.sleep(1000); //TODO Figure out why we need to wait here until the update is happening
assertNotEquals(TrackRecordingService.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
@@ -215,7 +215,7 @@ public class TrackRecordingServiceTestStateMachine {
// when
service.pauseCurrentTrack();
Thread.sleep(1000);
Thread.sleep(2000);
// then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
@@ -252,13 +252,13 @@ public class TrackRecordingServiceTestStateMachine {
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
// when
service.stopGps(false); //TODO Should be ignored as service is recording
service.stopSensors(); //TODO Should be ignored as service is recording
Thread.sleep(1000);
// then
assertTrue(service.isRecording());
assertFalse(service.isPaused());
assertEquals(new TrackRecordingService.RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
assertEquals(new RecordingStatus(trackId, false), service.getRecordingStatusObservable().getValue());
Thread.sleep(1000); //TODO Figure out why we need to wait here until the update is happening
assertNotEquals(TrackRecordingService.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
@@ -30,7 +30,6 @@ public class TrackRecordingServiceTestUtils {
service.getTrackPointCreator().setRemoteSensorManager(new BluetoothRemoteSensorManager(context, service.getTrackPointCreator()));
service.getTrackPointCreator().setClock(Clock.systemUTC());
service.endCurrentTrack();
service.sharedPreferenceChangeListener.onSharedPreferenceChanged(null, null);
}
static void sendGPSLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, long speed) {
@@ -60,11 +60,11 @@ public class VoiceAnnouncementManagerTest {
// when
TrackStatistics statistics = new TrackStatistics();
statistics.setTotalDistance(Distance.ofKilometer(13));
voiceAnnouncementManager.restore(statistics);
voiceAnnouncementManager.start(statistics);
assertEquals(Distance.of(15000), voiceAnnouncementManager.getNextTotalDistance());
statistics.setTotalDistance(Distance.of(15100));
voiceAnnouncementManager.restore(statistics);
voiceAnnouncementManager.start(statistics);
assertEquals(Distance.of(20000), voiceAnnouncementManager.getNextTotalDistance());
}
@@ -80,11 +80,11 @@ public class VoiceAnnouncementManagerTest {
// when
TrackStatistics statistics = new TrackStatistics();
statistics.setTotalTime(Duration.ofSeconds(91));
voiceAnnouncementManager.restore(statistics);
voiceAnnouncementManager.start(statistics);
assertEquals(Duration.ofSeconds(95), voiceAnnouncementManager.getNextTotalTime());
statistics.setTotalTime(Duration.ofSeconds(95));
voiceAnnouncementManager.restore(statistics);
voiceAnnouncementManager.start(statistics);
assertEquals(Duration.ofSeconds(100), voiceAnnouncementManager.getNextTotalTime());
}
@@ -8,32 +8,40 @@ import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.Locale;
import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.intervals.IntervalStatistics;
import de.dennisguse.opentracks.util.StringUtils;
@RunWith(AndroidJUnit4.class)
public class VoiceAnnouncementUtilsTest {
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
private final Context context = ApplicationProvider.getApplicationContext();
private ContentProviderUtils contentProviderUtils;
@Before
public void setUp() {
contentProviderUtils = new ContentProviderUtils(context);
PreferencesUtils.setVoiceAnnounceHeartRate(false);
}
@Test
@@ -46,10 +54,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null, null).toString();
// then
assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 18.4 kilometers per hour", announcement);
assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Speed 18.4 kilometers per hour.", announcement);
}
@Test
@@ -62,23 +70,16 @@ public class VoiceAnnouncementUtilsTest {
IntervalStatistics.Interval lastInterval;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000), Distance.of(0));
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, null).toString();
// then
Assert.assertEquals(
"total distance " +
StringUtils.getDistanceParts(context, stats.getTotalDistance(), true).first +
" kilometers in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " +
StringUtils.getSpeedParts(context, stats.getAverageMovingSpeed(), true, true).first +
" kilometers per hour Lap speed of " +
StringUtils.getSpeedParts(context, lastInterval.getSpeed(), true, true).first +
" kilometers per hour",
announcement);
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour.", announcement);
}
@Test
@@ -91,10 +92,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null, null).toString();
// then
assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 3 minutes 15 seconds per kilometer", announcement);
assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Pace 3 minutes 15 seconds per kilometer.", announcement);
}
@Test
@@ -107,23 +108,16 @@ public class VoiceAnnouncementUtilsTest {
IntervalStatistics.Interval lastInterval;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000), Distance.of(0));
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval, null).toString();
// then
assertEquals(
"total distance " +
StringUtils.getDistanceParts(context, stats.getTotalDistance(), true).first +
" kilometers in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " +
buildAndGetTimeText(stats.getAverageMovingSpeed().toPace(true), true) +
" per kilometer Lap time of " +
buildAndGetTimeText(lastInterval.getSpeed().toPace(true), true) +
" per kilometer",
announcement);
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Pace 1 minute 10 seconds per kilometer. Lap time 1 minute 10 seconds per kilometer.", announcement);
}
@Test
@@ -136,10 +130,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null, null).toString();
// then
assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 11.4 miles per hour", announcement);
assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Speed 11.4 miles per hour.", announcement);
}
@Test
@@ -152,23 +146,16 @@ public class VoiceAnnouncementUtilsTest {
IntervalStatistics.Interval lastInterval;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000), Distance.of(0));
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval, null).toString();
// then
assertEquals(
"total distance " +
StringUtils.getDistanceParts(context, stats.getTotalDistance(), false).first +
" miles in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " +
StringUtils.getSpeedParts(context, stats.getAverageMovingSpeed(), false, true).first +
" miles per hour Lap speed of " +
StringUtils.getSpeedParts(context, lastInterval.getSpeed(), false, true).first +
" miles per hour",
announcement);
assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Speed 31.8 miles per hour. Lap speed 31.8 miles per hour.", announcement);
}
@Test
@@ -181,10 +168,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null, null).toString();
// then
assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 5 minutes 15 seconds per mile", announcement);
assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Pace 5 minutes 15 seconds per mile.", announcement);
}
@Test
@@ -197,52 +184,40 @@ public class VoiceAnnouncementUtilsTest {
IntervalStatistics.Interval lastInterval;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000), Distance.of(0));
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval, null).toString();
// then
//assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 5 minutes 15 seconds per mile Lap time of 1 minute 53 seconds per mile", announcement);
assertEquals(
"total distance " +
StringUtils.getDistanceParts(context, stats.getTotalDistance(), false).first +
" miles in " + buildAndGetTimeText(stats.getTotalTime(), false) + " at " +
buildAndGetTimeText(stats.getAverageMovingSpeed().toPace(false), true) +
" per mile Lap time of " +
buildAndGetTimeText(lastInterval.getSpeed().toPace(false), true) +
" per mile",
announcement);
assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Pace 1 minute 53 seconds per mile. Lap time 1 minute 53 seconds per mile.", announcement);
}
/**
* Builds an returns the text representing the duration's time.
*
* @param duration Duration object.
* @param showFromMinutes show minutes tough it's 0.
* @return text representing the duratin's time.
*/
private String buildAndGetTimeText(Duration duration, boolean showFromMinutes) {
long hours = Math.abs(duration.getSeconds()) / 3600;
long minutes = (Math.abs(duration.getSeconds()) % 3600) / 60;
long seconds = Math.abs(duration.getSeconds()) % 60;
String hUnit = hours > 1 || hours == 0 ? "hours" : "hour";
String mUnit = minutes > 1 || minutes == 0 ? "minutes" : "minute";
String sUnit = seconds > 1 || seconds == 0 ? "seconds" : "second";
@Test
public void getAnnouncement_heart_rate_and_sensor_statistics() {
PreferencesUtils.setVoiceAnnounceHeartRate(true);
String res = hours > 0 ? hours + " " + hUnit : "";
if (hours > 0) {
res += minutes > 0 || showFromMinutes ? " " + minutes + " " + mUnit : "";
} else {
res += minutes > 0 || showFromMinutes ? minutes + " " + mUnit : "";
int numberOfPoints = 1000;
Pair<Track.Id, TrackStatistics> trackWithStats = TestDataUtil.buildTrackWithTrackPoints(contentProviderUtils, numberOfPoints);
Track.Id trackId = trackWithStats.first;
TrackStatistics stats = trackWithStats.second;
IntervalStatistics.Interval lastInterval;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
if (hours > 0 || minutes > 0 || showFromMinutes) {
res += " " + seconds + " " + sUnit;
} else {
res += seconds + " " + sUnit;
}
return res;
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, sensorStatistics).toString();
// then
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Current heart rate 133 bpm.", announcement);
}
}
}
@@ -24,6 +24,7 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -31,7 +32,9 @@ import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.Locale;
import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
@@ -47,6 +50,9 @@ import de.dennisguse.opentracks.util.StringUtils;
@RunWith(AndroidJUnit4.class)
public class DescriptionGeneratorTest {
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
private static final Instant START_TIME = Instant.ofEpochMilli(1288721514000L);
private DescriptionGenerator descriptionGenerator;
@@ -28,6 +28,7 @@ import java.time.Duration;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
/**
@@ -67,6 +68,7 @@ public class TrackStatisticsTest {
assertEquals(0.0, statistics.getMaxSpeed().toMPS(), 0.0);
assertEquals(0.0, statistics.getAverageSpeed().toMPS(), 0.0);
assertEquals(0.0, statistics.getAverageMovingSpeed().toMPS(), 0.0);
assertNull(statistics.getAverageHeartRate());
}
@Test
@@ -91,6 +93,8 @@ public class TrackStatisticsTest {
statistics.setMinAltitude(1200.0); // Resulting min altitude
statistics2.setMaxAltitude(3575.0); // Resulting max altitude
statistics2.setMinAltitude(2800.0);
statistics.setAverageHeartRate(HeartRate.of(100f));
statistics2.setAverageHeartRate(HeartRate.of(200f));
// when
statistics.merge(statistics2);
@@ -105,6 +109,7 @@ public class TrackStatisticsTest {
assertEquals(Speed.of(statistics.getTotalDistance(), statistics.getMovingTime()).toMPS(), statistics.getMaxSpeed().toMPS(), 0.001);
assertEquals(1200.0, statistics.getMinAltitude(), 0.001);
assertEquals(3575.0, statistics.getMaxAltitude(), 0.001);
assertEquals(150.0, statistics.getAverageHeartRate().getBPM(), 0.001);
}
@Test
@@ -22,8 +22,6 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
@RunWith(AndroidJUnit4.class)
public class TrackStatisticsUpdaterTest {
private static final Distance GPS_DISTANCE = Distance.of(50);
@Test
public void empty() {
// when
@@ -42,6 +40,7 @@ public class TrackStatisticsUpdaterTest {
assertNull(statistics.getTotalAltitudeGain());
assertNull(statistics.getTotalAltitudeLoss());
assertNull(statistics.getAverageHeartRate());
}
@Test
@@ -68,6 +67,7 @@ public class TrackStatisticsUpdaterTest {
assertNull(statistics.getTotalAltitudeGain());
assertNull(statistics.getTotalAltitudeLoss());
assertNull(statistics.getAverageHeartRate());
}
@Test
@@ -93,6 +93,7 @@ public class TrackStatisticsUpdaterTest {
assertEquals(14.226, statistics.getMaxSpeed().toMPS(), 0.01);
assertEquals(14.226, statistics.getAverageMovingSpeed().toMPS(), 0.01);
assertEquals(9.84, statistics.getAverageSpeed().toMPS(), 0.01);
assertEquals(106.85f, statistics.getAverageHeartRate().getBPM(), 0.01);
}
@Test
@@ -9,6 +9,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.util.Pair;
import android.view.View;
@@ -73,7 +74,7 @@ public class EspressoAggregatedFilterTest {
withParent(allOf(withId(R.id.filter_items),
withParent(IsInstanceOf.<View>instanceOf(android.view.ViewGroup.class)))),
isDisplayed()));
checkBox.check(matches(isDisplayed()));
checkBox.perform(waitFor(2000));
// check there's an edit text for "from date"
onView(withId(R.id.filter_date_edit_text_from)).check(matches(isDisplayed()));
@@ -81,4 +82,5 @@ public class EspressoAggregatedFilterTest {
// check there's an edit text for "to date"
onView(withId(R.id.filter_date_edit_text_to)).check(matches(isDisplayed()));
}
}
@@ -4,31 +4,22 @@ package de.dennisguse.opentracks.ui.customRecordingLayout;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.matcher.ViewMatchers.assertThat;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.CoreMatchers.is;
import static de.dennisguse.opentracks.util.EspressoUtils.veryLongTouch;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewAssertion;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.action.MotionEvents;
import androidx.test.espresso.matcher.ViewMatchers;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -82,51 +73,4 @@ public class EspressoCustomLayoutTest {
}
}
private static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
return new TypeSafeMatcher<>() {
@Override
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
private static ViewAction veryLongTouch(final int duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Perform long touch.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
// Get view absolute position
int[] location = new int[2];
view.getLocationOnScreen(location);
// Offset coordinates by view position
float[] coordinates = new float[]{location[0] + 1, location[1] + 1};
// Send down event, pause, and send up
MotionEvent down = MotionEvents.sendDown(uiController, coordinates, new float[]{1f, 1f}).down;
uiController.loopMainThreadForAtLeast(duration_ms);
MotionEvents.sendUp(uiController, down, coordinates);
}
};
}
}
@@ -149,7 +149,7 @@ public class IntervalStatisticsTest {
}
private void whenAndThen(Pair<Track.Id, TrackStatistics> trackWithStats, int numberOfPoints, float distanceInterval) {
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(distanceInterval), Distance.of(0));
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(distanceInterval));
Track.Id trackId = trackWithStats.first;
TrackStatistics trackStatistics = trackWithStats.second;
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
@@ -0,0 +1,173 @@
package de.dennisguse.opentracks.util;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.util.TreeIterables.breadthFirstViewTraversal;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ListView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.action.MotionEvents;
import androidx.test.espresso.util.HumanReadables;
import com.google.android.material.tabs.TabLayout;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.util.concurrent.TimeoutException;
public class EspressoUtils {
public static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
return new TypeSafeMatcher<>() {
@Override
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
public static Matcher<View> withListSize (final int size) {
return new TypeSafeMatcher<>() {
@Override
public boolean matchesSafely(final View view) {
return ((ListView) view).getCount() == size;
}
@Override
public void describeTo(final Description description) {
description.appendText("ListView should have " + size + " items");
}
};
}
public static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
}
};
}
public static ViewAction veryLongTouch(final int duration_ms) {
return new ViewAction() {
@Override
public String getDescription() {
return "Perform long touch.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
// Get view absolute position
int[] location = new int[2];
view.getLocationOnScreen(location);
// Offset coordinates by view position
float[] coordinates = new float[]{location[0] + 1, location[1] + 1};
// Send down event, pause, and send up
MotionEvent down = MotionEvents.sendDown(uiController, coordinates, new float[]{1f, 1f}).down;
uiController.loopMainThreadForAtLeast(duration_ms);
MotionEvents.sendUp(uiController, down, coordinates);
}
};
}
public static ViewAction waitId(final int viewId, final long millis) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}
@Override
public String getDescription() {
return "wait for a specific view with id <" + viewId + "> during " + millis + " millis.";
}
@Override
public void perform(final UiController uiController, final View view) {
uiController.loopMainThreadUntilIdle();
final long startTime = System.currentTimeMillis();
final long endTime = startTime + millis;
final Matcher<View> viewMatcher = withId(viewId);
do {
for (View child : breadthFirstViewTraversal(view)) {
// found view with required ID
if (viewMatcher.matches(child)) {
return;
}
}
uiController.loopMainThreadForAtLeast(50);
}
while (System.currentTimeMillis() < endTime);
// timeout happens
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(new TimeoutException())
.build();
}
};
}
public static ViewAction selectTabAtIndex(final int index) {
return new ViewAction() {
@Override
public String getDescription() {
return "Selecting tab.";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, View view) {
TabLayout tabLayout = (TabLayout) view;
tabLayout.getTabAt(index).select();
}
};
}
}
@@ -15,6 +15,8 @@
*/
package de.dennisguse.opentracks.util;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -22,8 +24,6 @@ import org.junit.runners.JUnit4;
import java.io.File;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
/**
* Tests for {@link FileUtils}.
*
@@ -101,6 +101,13 @@ public class FileUtilsTest {
assertEquals(expected, FileUtils.sanitizeFileName(name));
}
@Test
public void testSanitizeFileName_emoji() {
String name = "\uD83C\uDF5B-Food";
String expected = "_-Food";
assertEquals(expected, FileUtils.sanitizeFileName(name));
}
/**
* Tests {@link FileUtils#truncateFileName(File, String, String)}.
* Verifies the a long file name is truncated.
@@ -24,6 +24,7 @@ import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -45,6 +46,7 @@ import java.util.Locale;
import java.util.TimeZone;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
@@ -56,6 +58,9 @@ import de.dennisguse.opentracks.data.models.Speed;
@RunWith(AndroidJUnit4.class)
public class StringUtilsTest {
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
private final Context context = ApplicationProvider.getApplicationContext();
/**
@@ -23,11 +23,14 @@ import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.OffsetDateTime;
import java.util.Locale;
import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
@@ -40,6 +43,9 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
@RunWith(AndroidJUnit4.class)
public class TrackNameUtilsTest {
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
private static final Track.Id TRACK_ID = new Track.Id(1L);
private static final OffsetDateTime START_TIME = OffsetDateTime.parse("2022-01-02T10:15:30.1234+01:00");
@@ -22,7 +22,7 @@ import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.customRecordingLayout.Layout;
@@ -39,7 +39,7 @@ public class StatisticDataBuilderTest extends TestCase {
private TrackStatistics trackStatisticsMock;
@Mock
private TrackRecordingService.RecordingData recordingDataMock;
private RecordingData recordingDataMock;
@Mock
private SensorStatistics sensorStatisticsMock;
@@ -33,6 +33,8 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import de.dennisguse.opentracks.databinding.ControllerFragmentBinding;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.ui.util.ActivityUtils;
@@ -57,8 +59,8 @@ public class ControllerFragment extends Fragment implements View.OnTouchListener
private TransitionDrawable transition;
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private TrackRecordingService.RecordingStatus recordingStatus;
private TrackRecordingService.RecordingData recordingData;
private RecordingStatus recordingStatus;
private RecordingData recordingData;
private final TrackRecordingServiceConnection.Callback bindChangedCallback = service -> {
service.getRecordingStatusObservable()
@@ -237,7 +239,7 @@ public class ControllerFragment extends Fragment implements View.OnTouchListener
void recordStop();
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
viewBinding.controllerRecord.setImageResource(recordingStatus.isRecordingAndNotPaused() ? R.drawable.ic_button_pause : R.drawable.button_record);
@@ -254,7 +256,7 @@ public class ControllerFragment extends Fragment implements View.OnTouchListener
viewBinding.controllerTotalTime.setVisibility(recordingStatus.isRecording() ? View.VISIBLE : View.INVISIBLE);
}
private void onTotalTimeChanged(TrackRecordingService.RecordingData recordingData) {
private void onTotalTimeChanged(RecordingData recordingData) {
this.recordingData = recordingData;
viewBinding.controllerTotalTime.setText(StringUtils.formatElapsedTimeWithHour(this.recordingData.getTrackStatistics().getTotalTime()));
@@ -16,13 +16,11 @@
package de.dennisguse.opentracks;
import android.Manifest;
import android.app.ActivityOptions;
import android.app.SearchManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.location.LocationManager;
@@ -33,15 +31,11 @@ import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.cursoradapter.widget.ResourceCursorAdapter;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
@@ -58,6 +52,7 @@ import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.TracksColumns;
import de.dennisguse.opentracks.databinding.TrackListBinding;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.services.handlers.GpsStatusValue;
@@ -95,7 +90,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
private boolean metricUnits = true;
private GpsStatusValue gpsStatusValue = TrackRecordingService.STATUS_GPS_DEFAULT;
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
// Callback when an item is selected in the contextual action mode
private final ActivityUtils.ContextualActionModeCallback contextualActionModeCallback = new ActivityUtils.ContextualActionModeCallback() {
@@ -144,7 +139,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
//TODO Not cool to do this in a callback that might be called more than once!
service.tryStartGps();
service.tryStartSensors();
};
@Override
@@ -212,8 +207,6 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
ActivityUtils.configureListViewContextualMenu(viewBinding.trackList, contextualActionModeCallback);
loadData(getIntent());
requestGPSPermissions();
}
@Override
@@ -300,7 +293,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
} else {
TrackRecordingService trackRecordingService = trackRecordingServiceConnection.getServiceIfBound();
if (trackRecordingService != null) {
trackRecordingService.stopGpsAndShutdown(); //TODO Handle this in TrackRecordingServiceConnection
trackRecordingService.stopSensorsAndShutdown(); //TODO Handle this in TrackRecordingServiceConnection
}
trackRecordingServiceConnection.unbindAndStop(this);
}
@@ -391,26 +384,6 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
return recordingStatus.getTrackId();
}
private void requestGPSPermissions() {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
return;
}
ActivityResultLauncher<String[]> locationPermissionRequest = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> {
Boolean fineLocationGranted = result.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false);
Boolean coarseLocationGranted = result.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false);
if (fineLocationGranted == null || !fineLocationGranted
|| coarseLocationGranted == null || !coarseLocationGranted) {
Toast.makeText(this, R.string.permission_gps_failed, Toast.LENGTH_SHORT).show();
finish();
}
}
);
String[] permissions = new String[]{Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION};
locationPermissionRequest.launch(permissions);
}
/**
* Updates the menu items with the icon specified.
*
@@ -569,7 +542,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
updateGpsMenuItem(true, recordingStatus.isRecording());
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
}
}
@@ -17,7 +17,6 @@
package de.dennisguse.opentracks;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
@@ -40,10 +39,10 @@ import de.dennisguse.opentracks.data.TrackDataHub;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.databinding.TrackRecordedBinding;
import de.dennisguse.opentracks.fragments.StatisticsRecordedFragment;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackDeleteService;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.share.ShareUtils;
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
@@ -76,22 +75,13 @@ public class TrackRecordedActivity extends AbstractTrackDeleteActivity implement
private TrackRecordedBinding viewBinding;
private Track.Id trackId;
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private final TrackRecordingServiceConnection.Callback bindCallback = service -> service.getRecordingStatusObservable()
.observe(TrackRecordedActivity.this, this::onRecordingStatusChanged);
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() {
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.recording_distance_interval_key, key)) {
trackDataHub.setRecordingDistanceInterval(PreferencesUtils.getRecordingDistanceInterval());
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -119,8 +109,6 @@ public class TrackRecordedActivity extends AbstractTrackDeleteActivity implement
protected void onStart() {
super.onStart();
PreferencesUtils.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
trackDataHub.start();
}
@@ -143,7 +131,6 @@ public class TrackRecordedActivity extends AbstractTrackDeleteActivity implement
super.onStop();
trackRecordingServiceConnection.unbind(this);
trackDataHub.stop();
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
}
@Override
@@ -313,7 +300,7 @@ public class TrackRecordedActivity extends AbstractTrackDeleteActivity implement
startPostponedEnterTransition();
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
}
}
@@ -1,8 +1,8 @@
package de.dennisguse.opentracks;
import android.Manifest;
import android.app.AlertDialog;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.os.Build;
import android.os.Bundle;
@@ -11,12 +11,16 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayoutMediator;
import java.util.List;
@@ -29,8 +33,10 @@ import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.databinding.TrackRecordingBinding;
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
import de.dennisguse.opentracks.fragments.StatisticsRecordingFragment;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.services.handlers.GpsStatusValue;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.ui.intervals.IntervalsFragment;
@@ -38,6 +44,7 @@ import de.dennisguse.opentracks.ui.markers.MarkerEditActivity;
import de.dennisguse.opentracks.ui.markers.MarkerListActivity;
import de.dennisguse.opentracks.util.IntentDashboardUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.PermissionUtils;
import de.dennisguse.opentracks.util.TrackIconUtils;
import de.dennisguse.opentracks.util.TrackUtils;
@@ -57,6 +64,8 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
private static final String CURRENT_TAB_TAG_KEY = "current_tab_tag_key";
private Snackbar snackbar;
// The following are setFrequency in onCreate
private ContentProviderUtils contentProviderUtils;
private TrackRecordingServiceConnection trackRecordingServiceConnection;
@@ -68,50 +77,43 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
@Deprecated
private Track.Id trackId;
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private final TrackRecordingServiceConnection.Callback bindChangedCallback = new TrackRecordingServiceConnection.Callback() {
@Override
public void onConnected(TrackRecordingService service) {
private final TrackRecordingServiceConnection.Callback bindChangedCallback = service -> {
service.getRecordingStatusObservable()
.observe(TrackRecordingActivity.this, this::onRecordingStatusChanged);
service.getRecordingStatusObservable()
.observe(TrackRecordingActivity.this, status -> onRecordingStatusChanged(status));
service.getGpsStatusObservable()
.observe(TrackRecordingActivity.this, this::onGpsStatusChanged);
if (!service.isRecording()) {
if (trackId == null) {
// trackId isn't initialized -> leads a new recording.
trackId = service.startNewTrack();
} else {
// trackId is initialized -> resumes the track.
service.resumeTrack(trackId);
}
// A recording track is on.
trackDataHub.loadTrack(trackId);
trackDataHub.setRecordingStatus(recordingStatus);
if (!service.isRecording()) {
if (trackId == null) {
// trackId isn't initialized -> leads a new recording.
trackId = service.startNewTrack();
} else {
// trackId is initialized -> resumes the track.
service.resumeTrack(trackId);
}
// A recording track is on.
trackDataHub.loadTrack(trackId);
trackDataHub.setRecordingStatus(recordingStatus);
}
};
private final OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new OnSharedPreferenceChangeListener() {
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.stats_show_on_lockscreen_while_recording_key, key)) {
setLockscreenPolicy();
}
if (PreferencesUtils.isKey(R.string.stats_keep_screen_on_while_recording_key, key)) {
setScreenOnPolicy();
}
if (PreferencesUtils.isKey(R.string.stats_fullscreen_while_recording_key, key)) {
setFullscreenPolicy();
}
if (PreferencesUtils.isKey(R.string.recording_distance_interval_key, key)) {
trackDataHub.setRecordingDistanceInterval(PreferencesUtils.getRecordingDistanceInterval());
}
if (key == null) return;
runOnUiThread(TrackRecordingActivity.this::invalidateOptionsMenu);
private final OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> {
if (PreferencesUtils.isKey(R.string.stats_show_on_lockscreen_while_recording_key, key)) {
setLockscreenPolicy();
}
if (PreferencesUtils.isKey(R.string.stats_keep_screen_on_while_recording_key, key)) {
setScreenOnPolicy();
}
if (PreferencesUtils.isKey(R.string.stats_fullscreen_while_recording_key, key)) {
setFullscreenPolicy();
}
if (key == null) return;
runOnUiThread(TrackRecordingActivity.this::invalidateOptionsMenu); //TODO Should not be necessary
};
private MenuItem insertMarkerMenuItem;
@@ -144,6 +146,8 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
if (savedInstanceState != null) {
viewBinding.trackDetailActivityViewPager.setCurrentItem(savedInstanceState.getInt(CURRENT_TAB_TAG_KEY));
}
requestGPSPermissions();
}
@Override
@@ -320,10 +324,6 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
setTitle(getString(isPaused ? R.string.generic_paused : R.string.generic_recording));
}
public void chooseActivityType(String category) {
ChooseActivityTypeDialogFragment.showDialog(getSupportFragmentManager(), category);
}
@Override
public void onChooseActivityTypeDone(String iconValue) {
Track track = contentProviderUtils.getTrack(trackId);
@@ -398,7 +398,7 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
}
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
trackDataHub.setRecordingStatus(recordingStatus);
@@ -406,4 +406,38 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc
setLockscreenPolicy();
setScreenOnPolicy();
}
}
private void requestGPSPermissions() {
if (PermissionUtils.hasGPSPermission(this)) {
return;
}
ActivityResultLauncher<String[]> locationPermissionRequest = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> {
Boolean fineLocationGranted = result.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false);
Boolean coarseLocationGranted = result.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false);
if (fineLocationGranted == null || !fineLocationGranted
|| coarseLocationGranted == null || !coarseLocationGranted) {
Toast.makeText(this, R.string.permission_gps_failed, Toast.LENGTH_SHORT).show();
}
}
);
String[] permissions = new String[]{Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION};
locationPermissionRequest.launch(permissions);
}
private void onGpsStatusChanged(GpsStatusValue gpsStatusValue) {
if (gpsStatusValue.isGpsStarted() && snackbar != null && snackbar.isShown()) {
snackbar.dismiss();
return;
}
if (gpsStatusValue != GpsStatusValue.GPS_DISABLED) {
return;
}
snackbar = Snackbar
.make(viewBinding.trackRecordingCoordinatorLayout,
getString(R.string.gps_recording_status, getString(gpsStatusValue.message), getString(R.string.gps_recording_without_signal)),
Snackbar.LENGTH_INDEFINITE)
.setAction(getString(R.string.generic_dismiss), v -> {});
snackbar.show();
}
}
@@ -120,10 +120,10 @@ public class StatisticsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
return;
}
this.value.setText(statisticData.hasValue() ? statisticData.getValue() : context.getString(R.string.value_unknown));
this.value.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.StatsPrimaryValue : R.style.StatsSecondaryValue);
this.value.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryValue : R.style.TextAppearance_OpenTracks_SecondaryValue);
this.unit.setText(statisticData.getUnit());
this.descMain.setText(statisticData.getField().getTitle());
this.descMain.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.StatsPrimaryLabel : R.style.StatsSecondaryLabel);
this.descMain.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryHeader : R.style.TextAppearance_OpenTracks_SecondaryHeader);
if (statisticData.hasDescription()) {
this.descSecondary.setVisibility(View.VISIBLE);
this.descSecondary.setText(statisticData.getDescription());
@@ -147,8 +147,8 @@ public class StatisticsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
if (statisticData == null) {
return;
}
this.value.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.StatsPrimaryValue : R.style.StatsSecondaryValue);
this.descMain.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.StatsPrimaryLabel : R.style.StatsSecondaryLabel);
this.value.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryValue : R.style.TextAppearance_OpenTracks_SecondaryValue);
this.descMain.setTextAppearance(context, statisticData.getField().isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryHeader : R.style.TextAppearance_OpenTracks_SecondaryHeader);
}
}
@@ -66,8 +66,6 @@ public class ShareContentProvider extends CustomContentProvider {
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA.getName() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_AND_SENSORDATA);
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getName() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES);
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.SHARE_PICTURE_PNG.getName() + "/*/*", URI_SHARE_PICTURE);
}
/**
@@ -96,7 +94,7 @@ public class ShareContentProvider extends CustomContentProvider {
Uri uri = Uri.parse(TracksColumns.CONTENT_URI + "/" + trackFileFormat.getName() + "/" + trackIdBuilder + "/" + Uri.encode(trackName) + "." + trackFileFormat.getExtension());
String mime = getTypeMime(uri);
Log.d(TAG, "Created uri " + uri.toString() + " with MIME " + mime);
Log.d(TAG, "Created uri " + uri + " with MIME " + mime);
return new Pair<>(uri, mime);
}
@@ -148,9 +146,6 @@ public class ShareContentProvider extends CustomContentProvider {
case URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES:
return TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES;
case URI_SHARE_PICTURE:
return TrackFileFormat.SHARE_PICTURE_PNG;
default:
throw new RuntimeException("Could not derive TrackFileFormat from Uri " + uri);
}
@@ -33,7 +33,6 @@ import java.util.HashSet;
import java.util.Set;
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.Speed;
import de.dennisguse.opentracks.data.models.Track;
@@ -41,6 +40,7 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.data.tables.TrackPointsColumns;
import de.dennisguse.opentracks.data.tables.TracksColumns;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.handlers.EGM2008CorrectionManager;
import de.dennisguse.opentracks.stats.TrackStatistics;
@@ -84,9 +84,7 @@ public class TrackDataHub {
private Track.Id selectedTrackId;
private Distance recordingDistanceInterval;
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
// Track points sampling state
private int numLoadedPoints;
@@ -410,14 +408,10 @@ public class TrackDataHub {
return handlerThread != null;
}
public void setRecordingStatus(TrackRecordingService.RecordingStatus recordingStatus) {
public void setRecordingStatus(RecordingStatus recordingStatus) {
this.recordingStatus = recordingStatus;
}
public void setRecordingDistanceInterval(Distance recordingDistanceInterval) {
this.recordingDistanceInterval = recordingDistanceInterval;
}
public interface Listener {
/**
@@ -27,20 +27,10 @@ import java.time.Instant;
import java.util.Objects;
/**
* This class extends the standard Android location with extra information.
* Sensor and/or location information for a specific point in time.
* <p>
* NOTE: default location will be latitude=0.0 and longitude=0.0.
* For {@link TrackPoint}s with `type == null` this is not meaningful.
* <p>
* NOTE: For Locations provided by the GPS.
* We are replacing the GPS-provided time using the system time.
* Then we have the same timestamps for the user-driven events (aka start, pause, resume) and restore {@link de.dennisguse.opentracks.stats.TrackStatistics}.
* Drawbacks:
* * GPS-provided timestamp might be more precise (but also have GPS week rollover)
* * System clock might be changed (and thus non-monotonic)
* TODO: if these might be a problem, we need to store both timestamps.
*
* @author Sandor Dornbush
* Time is created using the {@link de.dennisguse.opentracks.services.handlers.MonotonicClock}, because system time jump backwards.
* GPS time is ignored as for non-GPS events, we could not create timestamps.
*/
public class TrackPoint {
@@ -78,7 +78,7 @@ class ChooseActivityTypeImageAdapter extends BaseAdapter {
}
if (position == selected) {
imageView.setBackgroundColor(ContextCompat.getColor(parent.getContext(), R.color.colorAccent));
imageView.setBackgroundColor(ContextCompat.getColor(parent.getContext(), R.color.opentracks_secondary_color));
} else {
imageView.setBackgroundColor(Color.TRANSPARENT);
}
@@ -20,8 +20,8 @@ import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.adapters.StatisticsAdapter;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.databinding.StatisticsRecordingBinding;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.settings.PreferencesUtils;
@@ -44,8 +44,9 @@ public class StatisticsRecordingFragment extends Fragment {
}
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private TrackRecordingService.RecordingData recordingData = TrackRecordingService.NOT_RECORDING;
private TrackPoint latestTrackPoint;
private RecordingData recordingData = TrackRecordingService.NOT_RECORDING;
private Layout layout;
private StatisticsRecordingBinding viewBinding;
@@ -75,8 +76,10 @@ public class StatisticsRecordingFragment extends Fragment {
}
};
private final TrackRecordingServiceConnection.Callback bindChangedCallback = service -> service.getRecordingDataObservable()
.observe(StatisticsRecordingFragment.this, this::onRecordingDataChanged);
private final TrackRecordingServiceConnection.Callback bindChangedCallback = service -> {
service.getRecordingDataObservable()
.observe(StatisticsRecordingFragment.this, this::onRecordingDataChanged);
};
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -158,7 +161,7 @@ public class StatisticsRecordingFragment extends Fragment {
}
}
private void onRecordingDataChanged(TrackRecordingService.RecordingData recordingData) {
private void onRecordingDataChanged(RecordingData recordingData) {
String oldCategory = this.recordingData.getTrackCategory();
String newCategory = recordingData.getTrackCategory();
this.recordingData = recordingData;
@@ -167,11 +170,6 @@ public class StatisticsRecordingFragment extends Fragment {
sharedPreferenceChangeListener.onSharedPreferenceChanged(null, getString(R.string.stats_rate_key));
}
latestTrackPoint = recordingData.getLatestTrackPoint();
if (latestTrackPoint != null && latestTrackPoint.hasLocation() && !latestTrackPoint.isRecent()) {
latestTrackPoint = null;
}
updateUI();
}
}
@@ -1,7 +1,6 @@
package de.dennisguse.opentracks.io.file;
import android.content.Context;
import android.graphics.Bitmap;
import java.util.Locale;
@@ -10,7 +9,6 @@ import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.io.file.exporter.GPXTrackExporter;
import de.dennisguse.opentracks.io.file.exporter.KMLTrackExporter;
import de.dennisguse.opentracks.io.file.exporter.KmzTrackExporter;
import de.dennisguse.opentracks.io.file.exporter.SharePictureTrackExporter;
import de.dennisguse.opentracks.io.file.exporter.TrackExporter;
/**
@@ -102,22 +100,6 @@ public enum TrackFileFormat {
public String getExtension() {
return "gpx";
}
},
SHARE_PICTURE_PNG {
@Override
public TrackExporter createTrackExporter(Context context) {
return new SharePictureTrackExporter(context, Bitmap.CompressFormat.PNG);
}
@Override
public String getMimeType() {
return "image/png";
}
public String getExtension() {
return "png";
}
};
private static final String MIME_KMZ = "application/vnd.google-earth.kmz";
@@ -310,37 +310,31 @@ public class ExportActivity extends FragmentActivity implements ExportServiceRes
viewBinding.exportProgressAlertIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_dialog_success_24dp));
viewBinding.exportProgressAlertMsg.setText(getString(R.string.generic_completed));
}
}
@Override
public void onReceiveResult(int resultCode, Bundle resultData) {
if (resultData == null) {
throw new RuntimeException(TAG + ": onReceiveResult resultData NULL");
}
Track.Id trackId = resultData.getParcelable(ExportServiceResultReceiver.RESULT_EXTRA_TRACK_ID);
public void onExportSuccess(Track.Id trackId) {
Track track = contentProviderUtils.getTrack(trackId);
switch (resultCode) {
case ExportServiceResultReceiver.RESULT_CODE_ERROR:
trackExportErrorCount++;
trackErrors.add(track.getName());
break;
case ExportServiceResultReceiver.RESULT_CODE_SUCCESS:
if (ExportUtils.isExportFileExists(track.getUuid(), trackFileFormat.getExtension(), directoryFiles)) {
trackExportOverwrittenCount++;
} else {
trackExportSuccessCount++;
}
break;
default:
throw new RuntimeException(TAG + ": export service result code invalid: " + resultCode);
if (ExportUtils.isExportFileExists(track.getUuid(), trackFileFormat.getExtension(), directoryFiles)) {
trackExportOverwrittenCount++;
} else {
trackExportSuccessCount++;
}
onExportCompleted(track);
}
@Override
public void onExportError(Track.Id trackId) {
Track track = contentProviderUtils.getTrack(trackId);
trackExportErrorCount++;
trackErrors.add(track.getName());
onExportCompleted(track);
}
private void conflict(Track track) {
PendingConflict newConflict = new PendingConflict(track);
conflictsQueue.add(newConflict);
@@ -6,6 +6,8 @@ import android.os.ResultReceiver;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.Track;
/**
* Create a new ResultReceive to receive results.
* Your {@link #onReceiveResult} method will be called from the thread running <var>handler</var> if given, or from an arbitrary thread if null.
@@ -26,10 +28,24 @@ public class ExportServiceResultReceiver extends ResultReceiver {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
receiver.onReceiveResult(resultCode, resultData);
Track.Id trackId = resultData.getParcelable(ExportServiceResultReceiver.RESULT_EXTRA_TRACK_ID);
switch (resultCode) {
case RESULT_CODE_SUCCESS:
receiver.onExportSuccess(trackId);
break;
case RESULT_CODE_ERROR:
receiver.onExportError(trackId);
break;
default:
throw new RuntimeException("Unknown resultCode.");
}
}
public interface Receiver {
void onReceiveResult(int resultCode, Bundle resultData);
default void onExportSuccess(Track.Id trackId) {
}
default void onExportError(Track.Id trackId) {
}
}
}
@@ -1,88 +0,0 @@
package de.dennisguse.opentracks.io.file.exporter;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import androidx.annotation.NonNull;
import java.io.OutputStream;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.databinding.TrackSharePictureBinding;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.StringUtils;
/**
* Create a share picture for a track.
* Here a layout is inflated and converted to PNG.
* <p>
* TODO
* If this classes is used with non-visual context (e.g., ApplicationContext), it may not work in the future.
* API31 introduces `ncorrectContextUseViolation`, which may thrown during runtime.
* This code was tested on API30.
* Some more information: https://stackoverflow.com/questions/68539083/inflate-layout-from-applicationcontext-i-e-in-a-contentprovider/68567186#68567186
* <p>
* Future plan: draw directly on a view.
*/
public class SharePictureTrackExporter implements TrackExporter {
private static final String TAG = SharePictureTrackExporter.class.getSimpleName();
private final static int imageWidth = 1024;
private final Context context;
private final Bitmap.CompressFormat compressFormat;
public SharePictureTrackExporter(Context context, Bitmap.CompressFormat compressFormat) {
this.context = context;
this.compressFormat = compressFormat;
}
@Override
public boolean writeTrack(Track[] tracks, @NonNull OutputStream outputStream) {
if (tracks.length > 1) {
Log.e(TAG, "Not yet implemented.");
return false;
}
return writeTrack(tracks[0], outputStream);
}
@Override
public boolean writeTrack(Track track, @NonNull OutputStream outputStream) {
int imageHeight = imageWidth / 4 * 3;
TrackSharePictureBinding viewBinding = TrackSharePictureBinding.inflate(LayoutInflater.from(context));
viewBinding.sharePictureCategory.setText(track.getCategory());
boolean metricUnits = PreferencesUtils.isMetricUnits();
TrackStatistics trackStatistics = track.getTrackStatistics();
viewBinding.sharePictureTotalTime.setText(StringUtils.formatElapsedTimeWithHour(track.getTrackStatistics().getTotalTime()));
viewBinding.sharePictureTotalDistance.setText(StringUtils.formatDistance(context, track.getTrackStatistics().getTotalDistance(), metricUnits));
viewBinding.sharePictureGain.setText(StringUtils.formatAltitudeChange(context, trackStatistics.getTotalAltitudeGain(), metricUnits));
// prepare rendering
View view = viewBinding.getRoot();
int widthSpec = View.MeasureSpec.makeMeasureSpec(imageWidth, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(imageHeight, View.MeasureSpec.EXACTLY);
view.measure(widthSpec, heightSpec);
view.layout(0, 0, imageWidth, imageHeight);
// draw
Canvas canvas = new Canvas();
Bitmap bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888);
canvas.setBitmap(bitmap);
view.draw(canvas);
// store
return bitmap.compress(compressFormat, 100, outputStream);
}
}
@@ -54,10 +54,7 @@ public abstract class DirectoryChooserActivity extends AppCompatActivity {
protected void onActivityResultCustom(@NonNull Intent resultData) {
Uri directoryUri = resultData.getData();
int takeFlags = resultData.getFlags();
takeFlags &= (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(directoryUri, takeFlags);
IntentUtils.persistDirectoryAccessPermission(this, directoryUri, resultData.getFlags());
startActivity(createNextActivityIntent(directoryUri));
}
@@ -111,11 +108,11 @@ public abstract class DirectoryChooserActivity extends AppCompatActivity {
Uri oldDirectoryUri = PreferencesUtils.getDefaultExportDirectoryUri();
Uri newDirectoryUri = resultData.getData();
if (oldDirectoryUri != null && !newDirectoryUri.equals(oldDirectoryUri)) {
IntentUtils.releaseDirectoryAccessPermission(getApplicationContext(), oldDirectoryUri);
IntentUtils.releaseDirectoryAccessPermission(this, oldDirectoryUri);
}
PreferencesUtils.setDefaultExportDirectoryUri(newDirectoryUri);
IntentUtils.persistDirectoryAccessPermission(getApplicationContext(), newDirectoryUri, resultData.getFlags());
IntentUtils.persistDirectoryAccessPermission(this, newDirectoryUri, resultData.getFlags());
}
@Override
@@ -57,7 +57,7 @@ public class ImportService extends JobIntentService {
Distance recordingDistanceInterval = PreferencesUtils.getRecordingDistanceInterval();
boolean preventReimport = PreferencesUtils.getPreventReimportTracks();
TrackImporter trackImporter = new TrackImporter(this, new ContentProviderUtils(this), recordingDistanceInterval, maxRecordingDistance, preventReimport);
TrackImporter trackImporter = new TrackImporter(this, new ContentProviderUtils(this), maxRecordingDistance, preventReimport);
if (TrackFileFormat.GPX.getExtension().equals(fileExtension)) {
trackIds.addAll(new XMLImporter(new GpxTrackImporter(this, trackImporter)).importFile(this, file.getUri()));
@@ -51,7 +51,6 @@ public class TrackImporter {
private final Context context;
private final ContentProviderUtils contentProviderUtils;
private final Distance recordingDistanceInterval;
private final Distance maxRecordingDistance;
private final boolean preventReimport;
@@ -62,10 +61,9 @@ public class TrackImporter {
private final List<TrackPoint> trackPoints = new LinkedList<>();
private final List<Marker> markers = new LinkedList<>();
public TrackImporter(Context context, ContentProviderUtils contentProviderUtils, Distance recordingDistanceInterval, Distance maxRecordingDistance, boolean preventReimport) {
public TrackImporter(Context context, ContentProviderUtils contentProviderUtils, Distance maxRecordingDistance, boolean preventReimport) {
this.context = context;
this.contentProviderUtils = contentProviderUtils;
this.recordingDistanceInterval = recordingDistanceInterval;
this.maxRecordingDistance = maxRecordingDistance;
this.preventReimport = preventReimport;
}
@@ -1,11 +1,25 @@
package de.dennisguse.opentracks.publicapi;
import android.os.Bundle;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.IntentDashboardUtils;
public class StartRecording extends AbstractAPIActivity {
private static final String TAG = StartRecording.class.getSimpleName();
protected void execute(TrackRecordingService service) {
service.startNewTrack();
Track.Id trackId = service.startNewTrack();
if ((trackId != null) && (PreferencesUtils.isPublicAPIDashboardEnabled())) {
Bundle bundle = getIntent().getExtras();
if (bundle != null) {
String targetPackage = bundle.getString("STATS_TARGET_PACKAGE", null);
String targetClass = bundle.getString("STATS_TARGET_CLASS", null);
IntentDashboardUtils.startDashboard(this, true, targetPackage, targetClass, trackId);
}
}
}
@Override
@@ -0,0 +1,79 @@
package de.dennisguse.opentracks.services;
import androidx.annotation.NonNull;
import java.util.Objects;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet;
import de.dennisguse.opentracks.stats.TrackStatistics;
public class RecordingData {
private final Track track;
private final TrackPoint latestTrackPoint;
private final SensorDataSet sensorDataSet;
/**
* {@link Track} and {@link TrackPoint} must be immutable (i.e., their content does not change).
*/
public RecordingData(Track track, TrackPoint lastTrackPoint, SensorDataSet sensorDataSet) {
this.track = track;
this.latestTrackPoint = lastTrackPoint;
this.sensorDataSet = sensorDataSet;
}
public Track getTrack() {
return track;
}
public String getTrackCategory() {
if (track == null) {
return "";
}
return track.getCategory();
}
@NonNull
public TrackStatistics getTrackStatistics() {
if (track == null) {
return new TrackStatistics();
}
return track.getTrackStatistics();
}
public TrackPoint getLatestTrackPoint() {
return latestTrackPoint;
}
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordingData that = (RecordingData) o;
return Objects.equals(track, that.track) && Objects.equals(latestTrackPoint, that.latestTrackPoint) && Objects.equals(sensorDataSet, that.sensorDataSet);
}
@Override
public int hashCode() {
return Objects.hash(track, latestTrackPoint, sensorDataSet);
}
@NonNull
@Override
public String toString() {
return "RecordingData{" +
"track=" + track +
", latestTrackPoint=" + latestTrackPoint +
", sensorDataSet=" + sensorDataSet +
'}';
}
}
@@ -0,0 +1,73 @@
package de.dennisguse.opentracks.services;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.util.Objects;
import de.dennisguse.opentracks.data.models.Track;
public class RecordingStatus {
private final Track.Id trackId;
private final boolean paused;
@VisibleForTesting
RecordingStatus(Track.Id trackId, boolean paused) {
this.trackId = trackId;
this.paused = paused;
}
public Track.Id getTrackId() {
return trackId;
}
public boolean isRecording() {
return trackId != null;
}
public boolean isPaused() {
return paused;
}
public boolean isRecordingAndNotPaused() {
return isRecording() && !isPaused();
}
static RecordingStatus notRecording() {
return new RecordingStatus(null, false);
}
static RecordingStatus record(@NonNull Track.Id trackId) {
return new RecordingStatus(trackId, false);
}
RecordingStatus pause() {
return new RecordingStatus(getTrackId(), true);
}
public RecordingStatus stop() {
return TrackRecordingService.STATUS_DEFAULT;
}
@NonNull
@Override
public String toString() {
return "RecordingStatus{" +
"trackId=" + trackId +
", paused=" + paused +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordingStatus that = (RecordingStatus) o;
return paused == that.paused && Objects.equals(trackId, that.trackId);
}
@Override
public int hashCode() {
return Objects.hash(trackId, paused);
}
}
@@ -2,6 +2,7 @@ package de.dennisguse.opentracks.services;
import android.content.ContentUris;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.sqlite.SQLiteException;
import android.net.Uri;
import android.util.Log;
@@ -25,7 +26,7 @@ import de.dennisguse.opentracks.stats.TrackStatisticsUpdater;
import de.dennisguse.opentracks.util.TrackIconUtils;
import de.dennisguse.opentracks.util.TrackNameUtils;
class TrackRecordingManager {
class TrackRecordingManager implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = TrackRecordingManager.class.getSimpleName();
@@ -39,6 +40,9 @@ class TrackRecordingManager {
private TrackStatisticsUpdater trackStatisticsUpdater;
private TrackPoint lastTrackPoint;
private TrackPoint lastTrackPointUIWithSpeed;
private TrackPoint lastTrackPointUIWithAltitude;
private TrackPoint lastStoredTrackPoint;
private TrackPoint lastStoredTrackPointWithLocation;
@@ -47,8 +51,18 @@ class TrackRecordingManager {
contentProviderUtils = new ContentProviderUtils(context);
}
Track.Id start(TrackPoint segmentStartTrackPoint, ZoneOffset zoneOffset) {
public void start() {
PreferencesUtils.registerOnSharedPreferenceChangeListener(this);
}
public void stop() {
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(this);
}
Track.Id startNewTrack(TrackPointCreator trackPointCreator) {
TrackPoint segmentStartTrackPoint = trackPointCreator.createSegmentStartManual();
// Create new track
ZoneOffset zoneOffset = ZoneOffset.systemDefault().getRules().getOffset(segmentStartTrackPoint.getTime());
Track track = new Track(zoneOffset);
trackId = contentProviderUtils.insertTrack(track);
track.setId(trackId);
@@ -69,7 +83,7 @@ class TrackRecordingManager {
}
//TODO Handle non-existing trackId? Start a new track or exception?
void resume(@NonNull Track.Id resumeTrackId, @NonNull TrackPoint segmentStartTrackPoint) {
void resumeExistingTrack(@NonNull Track.Id resumeTrackId, @NonNull TrackPointCreator trackPointCreator) {
trackId = resumeTrackId;
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
@@ -78,19 +92,15 @@ class TrackRecordingManager {
}
trackStatisticsUpdater = new TrackStatisticsUpdater(track.getTrackStatistics());
onNewTrackPoint(segmentStartTrackPoint);
onNewTrackPoint(trackPointCreator.createSegmentStartManual());
lastTrackPoint = null;
lastStoredTrackPoint = null;
lastStoredTrackPointWithLocation = null;
reset();
}
void pause(TrackPointCreator trackPointCreator) {
insertTrackPoint(trackPointCreator.createSegmentEnd());
lastTrackPoint = null;
lastStoredTrackPoint = null;
lastStoredTrackPointWithLocation = null;
reset();
}
void end(TrackPointCreator trackPointCreator) {
@@ -100,17 +110,15 @@ class TrackRecordingManager {
trackId = null;
trackStatisticsUpdater = null;
lastTrackPoint = null;
lastStoredTrackPoint = null;
lastStoredTrackPointWithLocation = null;
reset();
}
Pair<Track, Pair<TrackPoint, SensorDataSet>> get(TrackPointCreator trackPointCreator) {
Pair<Track, Pair<TrackPoint, SensorDataSet>> getDataForUI(TrackPointCreator trackPointCreator) {
if (trackPointCreator == null) {
return null;
}
TrackStatisticsUpdater tmpTrackStatisticsUpdater = new TrackStatisticsUpdater(trackStatisticsUpdater);
Pair<TrackPoint, SensorDataSet> current = trackPointCreator.createCurrentTrackPoint(lastTrackPoint);
Pair<TrackPoint, SensorDataSet> current = trackPointCreator.createCurrentTrackPoint(lastTrackPointUIWithSpeed, lastTrackPointUIWithAltitude, lastStoredTrackPointWithLocation);
tmpTrackStatisticsUpdater.addTrackPoint(current.first);
@@ -154,6 +162,12 @@ class TrackRecordingManager {
* @return TrackPoint was stored?
*/
boolean onNewTrackPoint(@NonNull TrackPoint trackPoint) {
if (trackPoint.hasSpeed()) {
lastTrackPointUIWithSpeed = trackPoint;
}
if (trackPoint.hasAltitude()) {
lastTrackPointUIWithAltitude = trackPoint;
}
//Storing trackPoint
// Always insert the first segment location
@@ -242,7 +256,17 @@ class TrackRecordingManager {
}
}
public void onSharedPreferenceChanged(String key) {
private void reset() {
lastTrackPoint = null;
lastTrackPointUIWithSpeed = null;
lastTrackPointUIWithAltitude = null;
lastStoredTrackPoint = null;
lastStoredTrackPointWithLocation = null;
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.recording_distance_interval_key, key)) {
recordingDistanceInterval = PreferencesUtils.getRecordingDistanceInterval();
}
@@ -16,75 +16,55 @@
package de.dennisguse.opentracks.services;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.os.Bundle;
import android.os.Handler;
import android.os.PowerManager.WakeLock;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.TaskStackBuilder;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.time.Duration;
import java.time.ZoneOffset;
import java.util.Objects;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.TrackRecordingActivity;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.io.file.exporter.ExportServiceResultReceiver;
import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet;
import de.dennisguse.opentracks.services.announcement.VoiceAnnouncementManager;
import de.dennisguse.opentracks.services.handlers.EGM2008CorrectionManager;
import de.dennisguse.opentracks.services.handlers.GpsStatusValue;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.ExportUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.SystemUtils;
/**
* A background service that registers a location listener and records track points.
* Track points are saved to the {@link CustomContentProvider}.
*
* @author Leif Hendrik Wilden
*/
public class TrackRecordingService extends Service implements TrackPointCreator.Callback, ExportServiceResultReceiver.Receiver {
public class TrackRecordingService extends Service implements TrackPointCreator.Callback {
private static final String TAG = TrackRecordingService.class.getSimpleName();
private static final Duration RECORDING_DATA_UPDATE_INTERVAL = Duration.ofSeconds(1);
private static final Duration RECORDING_DATA_UPDATE_INTERVAL = Duration.ofMillis(100);
public static final RecordingStatus STATUS_DEFAULT = RecordingStatus.notRecording();
public static final RecordingData NOT_RECORDING = new RecordingData(null, null, null);
public static final GpsStatusValue STATUS_GPS_DEFAULT = GpsStatusValue.GPS_NONE;
public class Binder extends android.os.Binder {
private Binder() {
super();
}
public TrackRecordingService getService() {
return TrackRecordingService.this;
}
}
private final Binder binder = new Binder();
// The following variables are setFrequency in onCreate:
private VoiceAnnouncementManager voiceAnnouncementManager;
private TrackRecordingServiceNotificationManager notificationManager;
private TrackRecordingManager trackRecordingManager;
private final EGM2008CorrectionManager egm2008CorrectionManager = new EGM2008CorrectionManager();
private Handler handler;
private final Runnable updateRecordingData = new Runnable() {
@Override
public void run() {
@@ -100,38 +80,24 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
}
};
@Deprecated
//TODO Workaround as service is not stopped on API23; thus sharedpreferences are not reset between tests.
@VisibleForTesting
final OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new OnSharedPreferenceChangeListener() {
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.stats_units_key, key)) {
boolean metricUnits = PreferencesUtils.isMetricUnits();
notificationManager.setMetricUnits(metricUnits);
}
if (PreferencesUtils.isKey(R.string.voice_announcement_frequency_key, key)) {
voiceAnnouncementManager.setFrequency(PreferencesUtils.getVoiceAnnouncementFrequency());
}
if (PreferencesUtils.isKey(new int[]{R.string.voice_announcement_distance_key, R.string.stats_units_key}, key)) {
voiceAnnouncementManager.setFrequency(PreferencesUtils.getVoiceAnnouncementDistance());
}
trackPointCreator.onSharedPreferenceChanged(key);
trackRecordingManager.onSharedPreferenceChanged(key);
}
};
// The following variables are set when recording:
private WakeLock wakeLock;
private TrackPointCreator trackPointCreator; //TODO Move to TrackRecordingManager?
// The following variables are set in onCreate:
private RecordingStatus recordingStatus;
private MutableLiveData<RecordingStatus> recordingStatusObservable;
private MutableLiveData<GpsStatusValue> gpsStatusObservable;
private MutableLiveData<RecordingData> recordingDataObservable;
// The following variables are set when recording:
private WakeLock wakeLock;
private Handler handler;
private TrackPointCreator trackPointCreator;
private TrackRecordingManager trackRecordingManager;
private VoiceAnnouncementManager voiceAnnouncementManager;
private TrackRecordingServiceNotificationManager notificationManager;
private EGM2008CorrectionManager egm2008CorrectionManager;
@Override
public void onCreate() {
super.onCreate();
@@ -143,14 +109,13 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
gpsStatusObservable = new MutableLiveData<>(STATUS_GPS_DEFAULT);
recordingDataObservable = new MutableLiveData<>(NOT_RECORDING);
egm2008CorrectionManager = new EGM2008CorrectionManager();
trackRecordingManager = new TrackRecordingManager(this);
trackRecordingManager.start();
trackPointCreator = new TrackPointCreator(this);
voiceAnnouncementManager = new VoiceAnnouncementManager(this);
notificationManager = new TrackRecordingServiceNotificationManager(this);
PreferencesUtils.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
}
@Override
@@ -158,18 +123,19 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
handler.removeCallbacksAndMessages(null); //Some tests do not finish the recording completely
handler = null;
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
trackPointCreator.stop();
trackPointCreator = null;
trackRecordingManager.stop();
trackRecordingManager = null;
// Reverse order from onCreate
showNotification(false); //TODO Why?
notificationManager.stop();
notificationManager = null;
egm2008CorrectionManager = null;
try {
voiceAnnouncementManager.shutdown();
voiceAnnouncementManager.stop();
} finally {
voiceAnnouncementManager = null;
}
@@ -195,27 +161,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
return binder;
}
public boolean isRecording() {
return recordingStatus.isRecording();
}
public boolean isPaused() {
return recordingStatus.isPaused();
}
public Marker.Id insertMarker(String name, String category, String description, String photoUrl) {
if (!isRecording() || isPaused()) {
return null;
}
return trackRecordingManager.insertMarker(name, category, description, photoUrl);
}
/**
* Starts a new track.
*
* @return the track id
*/
public Track.Id startNewTrack() {
if (isRecording()) {
Log.w(TAG, "Ignore startNewTrack. Already recording.");
@@ -223,26 +168,17 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
}
// Set recording status
TrackPoint segmentStartManual = trackPointCreator.createSegmentStartManual();
ZoneOffset zoneOffset = ZoneOffset.systemDefault().getRules().getOffset(segmentStartManual.getTime());
Track.Id trackId = trackRecordingManager.start(segmentStartManual, zoneOffset);
Track.Id trackId = trackRecordingManager.startNewTrack(trackPointCreator);
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
return trackId;
}
/**
* Resumes the track identified by trackId.
* It results in a pause/continue.
*
* @param trackId the id of the track to be resumed.
*/
public void resumeTrack(Track.Id trackId) {
trackPointCreator.resetSensorData();
trackRecordingManager.resume(trackId, trackPointCreator.createSegmentStartManual());
trackPointCreator.reset();
trackRecordingManager.resumeExistingTrack(trackId, trackPointCreator);
// Set recording status
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
@@ -258,25 +194,22 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
resumeTrack(recordingStatus.getTrackId());
}
/**
* Common code for starting a new track or resuming a track.
*/
private void startRecording() {
// Update instance variables
handler.postDelayed(updateRecordingData, RECORDING_DATA_UPDATE_INTERVAL.toMillis());
startGps();
startSensors();
voiceAnnouncementManager.restore(trackRecordingManager.getTrackStatistics());
voiceAnnouncementManager.start(trackRecordingManager.getTrackStatistics());
}
public void tryStartGps() {
public void tryStartSensors() {
if (isRecording()) return;
startGps();
startSensors();
}
private void startGps() {
private void startSensors() {
wakeLock = SystemUtils.acquireWakeLock(this, wakeLock);
trackPointCreator.start(this);
showNotification(true);
@@ -301,7 +234,9 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
endRecording(true);
ExportUtils.postWorkoutExport(this, trackId, new ExportServiceResultReceiver(new Handler(), this));
ExportUtils.postWorkoutExport(this, trackId);
stopSelf();
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
@@ -321,11 +256,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
notificationManager.updateContent(getString(R.string.generic_paused));
}
/**
* Common code for ending a track or pausing a track.
*
* @param trackStopped true if track is stopped, false if track is paused
*/
private void endRecording(boolean trackStopped) {
stopUpdateRecordingData();
if (!trackStopped) {
@@ -334,33 +264,28 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
recordingDataObservable.postValue(NOT_RECORDING);
}
// Shutdown periodic tasks
voiceAnnouncementManager.shutdown();
voiceAnnouncementManager.stop();
// Update instance variables
trackPointCreator.stop();
stopGps(trackStopped);
stopSensors();
}
public void stopGpsAndShutdown() {
stopGps(true);
public void stopSensorsAndShutdown() {
if (isRecording()) {
return;
}
stopSensors();
stopSelf();
}
/**
* Stops gps.
*
* @param shutdown true to shutdown self
*/
void stopGps(boolean shutdown) {
void stopSensors() {
if (!isRecording()) return;
trackPointCreator.stop();
showNotification(false);
wakeLock = SystemUtils.releaseWakeLock(wakeLock);
if (shutdown) {
stopSelf();
}
}
@Override
@@ -387,36 +312,10 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
private void showNotification(boolean isGpsStarted) {
if (isRecording()) {
Intent intent = IntentUtils.newIntent(this, TrackRecordingActivity.class);
int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pendingIntent = TaskStackBuilder.create(this)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, pendingIntentFlags);
notificationManager.updatePendingIntent(pendingIntent);
notificationManager.updateContent(getString(R.string.gps_starting));
startForeground(TrackRecordingServiceNotificationManager.NOTIFICATION_ID, notificationManager.getNotification());
startForeground(TrackRecordingServiceNotificationManager.NOTIFICATION_ID, notificationManager.setRecording(this));
}
if (!isRecording() && isGpsStarted) {
Intent intent = IntentUtils.newIntent(this, TrackListActivity.class);
int pendingIntentFlags = 0;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
pendingIntentFlags = PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT;
}
PendingIntent pendingIntent = TaskStackBuilder.create(this)
.addParentStack(TrackListActivity.class)
.addNextIntent(intent)
.getPendingIntent(0, pendingIntentFlags);
notificationManager.updatePendingIntent(pendingIntent);
notificationManager.updateContent(getString(R.string.gps_starting));
startForeground(TrackRecordingServiceNotificationManager.NOTIFICATION_ID, notificationManager.getNotification());
startForeground(TrackRecordingServiceNotificationManager.NOTIFICATION_ID, notificationManager.setGPSonlyStarted(this));
}
if (!isRecording() && !isGpsStarted) {
stopForeground(true);
@@ -424,6 +323,14 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
}
}
public Marker.Id insertMarker(String name, String category, String description, String photoUrl) {
if (!isRecording() || isPaused()) {
return null;
}
return trackRecordingManager.insertMarker(name, category, description, photoUrl);
}
@Deprecated
@VisibleForTesting
public TrackPointCreator getTrackPointCreator() {
@@ -460,7 +367,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
return;
}
Pair<Track, Pair<TrackPoint, SensorDataSet>> data = trackRecordingManager.get(trackPointCreator);
Pair<Track, Pair<TrackPoint, SensorDataSet>> data = trackRecordingManager.getDataForUI(trackPointCreator);
if (data == null) {
Log.w(TAG, "Requesting data if not recording is taking place, should not be done.");
return;
@@ -488,160 +395,13 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
recordingStatusObservable.postValue(recordingStatus);
}
@Override
public void onReceiveResult(final int resultCode, final Bundle resultData) {
Log.w(TAG, "onReceiveResult: " + resultCode);
if (resultCode != ExportServiceResultReceiver.RESULT_CODE_SUCCESS) {
Intent intent = new Intent(this, SettingsActivity.class);
intent.putExtra(SettingsActivity.EXTRAS_CHECK_EXPORT_DIRECTORY, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
@Deprecated //TODO Should be @VisibleForTesting
public boolean isRecording() {
return recordingStatus.isRecording();
}
public class Binder extends android.os.Binder {
private Binder() {
super();
}
public TrackRecordingService getService() {
return TrackRecordingService.this;
}
}
public static class RecordingStatus {
private final Track.Id trackId;
private final boolean paused;
@VisibleForTesting
RecordingStatus(Track.Id trackId, boolean paused) {
this.trackId = trackId;
this.paused = paused;
}
public Track.Id getTrackId() {
return trackId;
}
public boolean isRecording() {
return trackId != null;
}
public boolean isPaused() {
return paused;
}
public boolean isRecordingAndNotPaused() {
return isRecording() && !isPaused();
}
private static RecordingStatus notRecording() {
return new RecordingStatus(null, false);
}
private static RecordingStatus record(@NonNull Track.Id trackId) {
return new RecordingStatus(trackId, false);
}
private RecordingStatus pause() {
return new RecordingStatus(getTrackId(), true);
}
public RecordingStatus stop() {
return STATUS_DEFAULT;
}
@NonNull
@Override
public String toString() {
return "RecordingStatus{" +
"trackId=" + trackId +
", paused=" + paused +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordingStatus that = (RecordingStatus) o;
return paused == that.paused && Objects.equals(trackId, that.trackId);
}
@Override
public int hashCode() {
return Objects.hash(trackId, paused);
}
}
public static class RecordingData {
private final Track track;
private final TrackPoint latestTrackPoint;
private final SensorDataSet sensorDataSet;
/**
* {@link Track} and {@link TrackPoint} must be immutable (i.e., their content does not change).
*/
public RecordingData(Track track, TrackPoint lastTrackPoint, SensorDataSet sensorDataSet) {
this.track = track;
this.latestTrackPoint = lastTrackPoint;
this.sensorDataSet = sensorDataSet;
}
public Track getTrack() {
return track;
}
public String getTrackCategory() {
if (track == null) {
return "";
}
return track.getCategory();
}
@NonNull
public TrackStatistics getTrackStatistics() {
if (track == null) {
return new TrackStatistics();
}
return track.getTrackStatistics();
}
public TrackPoint getLatestTrackPoint() {
return latestTrackPoint;
}
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordingData that = (RecordingData) o;
return Objects.equals(track, that.track) && Objects.equals(latestTrackPoint, that.latestTrackPoint) && Objects.equals(sensorDataSet, that.sensorDataSet);
}
@Override
public int hashCode() {
return Objects.hash(track, latestTrackPoint, sensorDataSet);
}
@NonNull
@Override
public String toString() {
return "RecordingData{" +
"track=" + track +
", latestTrackPoint=" + latestTrackPoint +
", sensorDataSet=" + sensorDataSet +
'}';
}
@VisibleForTesting
public boolean isPaused() {
return recordingStatus.isPaused();
}
}
@@ -5,21 +5,28 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.core.app.TaskStackBuilder;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.TrackRecordingActivity;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.StringUtils;
/**
* Manages the content of the notification shown by {@link TrackRecordingService}.
*/
class TrackRecordingServiceNotificationManager {
class TrackRecordingServiceNotificationManager implements SharedPreferences.OnSharedPreferenceChangeListener {
static final int NOTIFICATION_ID = 123;
@@ -34,6 +41,7 @@ class TrackRecordingServiceNotificationManager {
private Boolean metricUnits = null;
TrackRecordingServiceNotificationManager(Context context) {
PreferencesUtils.registerOnSharedPreferenceChangeListener(this);
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID, context.getString(R.string.app_name), NotificationManager.IMPORTANCE_HIGH);
@@ -54,6 +62,10 @@ class TrackRecordingServiceNotificationManager {
.setSmallIcon(R.drawable.ic_logo_color_24dp);
}
void stop() {
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(this);
}
@VisibleForTesting
TrackRecordingServiceNotificationManager(NotificationManager notificationManager, NotificationCompat.Builder notificationBuilder) {
this.notificationManager = notificationManager;
@@ -84,19 +96,50 @@ class TrackRecordingServiceNotificationManager {
notificationBuilder.setOnlyAlertOnce(true);
}
void updatePendingIntent(PendingIntent pendingIntent) {
Notification setRecording(Context context) {
Intent intent = IntentUtils.newIntent(context, TrackRecordingActivity.class);
int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, pendingIntentFlags);
updateContent(context.getString(R.string.gps_starting));
notificationBuilder.setContentIntent(pendingIntent);
updateNotification();
return getNotification();
}
Notification setGPSonlyStarted(Context context) {
Intent intent = IntentUtils.newIntent(context, TrackListActivity.class);
int pendingIntentFlags = 0;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
pendingIntentFlags = PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT;
}
PendingIntent pendingIntent = TaskStackBuilder.create(context)
.addParentStack(TrackListActivity.class)
.addNextIntent(intent)
.getPendingIntent(0, pendingIntentFlags);
updateContent(context.getString(R.string.gps_starting));
notificationBuilder.setContentIntent(pendingIntent);
updateNotification();
return getNotification();
}
void cancelNotification() {
notificationManager.cancel(NOTIFICATION_ID);
}
Notification getNotification() {
return notificationBuilder.build();
}
void setMetricUnits(boolean metricUnits) {
this.metricUnits = metricUnits;
}
@@ -104,4 +147,15 @@ class TrackRecordingServiceNotificationManager {
private void updateNotification() {
notificationManager.notify(NOTIFICATION_ID, getNotification());
}
private Notification getNotification() {
return notificationBuilder.build();
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.stats_units_key, key)) {
setMetricUnits(PreferencesUtils.isMetricUnits());
}
}
}
@@ -21,6 +21,7 @@ import android.media.AudioManager;
import android.media.MediaPlayer;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.text.Spannable;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -34,6 +35,7 @@ import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.intervals.IntervalStatistics;
@@ -165,21 +167,24 @@ public class VoiceAnnouncement {
boolean isMetricUnits = PreferencesUtils.isMetricUnits();
boolean isReportSpeed = PreferencesUtils.isReportSpeed(track.getCategory());
Distance minGPSDistance = PreferencesUtils.getRecordingDistanceInterval();
//TODO Do not load all trackpoints for every announcement
TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(track.getId(), null);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.one(isMetricUnits), minGPSDistance);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.one(isMetricUnits));
intervalStatistics.addTrackPoints(trackPointIterator);
IntervalStatistics.Interval lastInterval = intervalStatistics.getLastInterval();
SensorStatistics sensorStatistics = null;
if (track.getId() != null) {
sensorStatistics = contentProviderUtils.getSensorStats(track.getId());
}
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval);
Spannable announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval, sensorStatistics);
// We don't care about the utterance id. It is supplied here to force onUtteranceCompleted to be called.
tts.speak(announcement, TextToSpeech.QUEUE_FLUSH, null, "not used");
}
public void shutdown() {
public void stop() {
if (tts != null) {
tts.shutdown();
tts = null;
@@ -15,6 +15,7 @@
*/
package de.dennisguse.opentracks.services.announcement;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -23,9 +24,11 @@ import androidx.annotation.VisibleForTesting;
import java.time.Duration;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.TrackStatistics;
/**
@@ -33,7 +36,7 @@ import de.dennisguse.opentracks.stats.TrackStatistics;
*
* @author Sandor Dornbush
*/
public class VoiceAnnouncementManager {
public class VoiceAnnouncementManager implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = VoiceAnnouncementManager.class.getSimpleName();
@@ -55,12 +58,16 @@ public class VoiceAnnouncementManager {
public VoiceAnnouncementManager(@NonNull TrackRecordingService trackRecordingService) {
this.trackRecordingService = trackRecordingService;
PreferencesUtils.registerOnSharedPreferenceChangeListener(this);
}
public void restore(@Nullable TrackStatistics trackStatistics) {
public void start(@Nullable TrackStatistics trackStatistics) {
voiceAnnouncement = new VoiceAnnouncement(trackRecordingService);
voiceAnnouncement.start();
update(trackStatistics);
}
void update(@Nullable TrackStatistics trackStatistics) {
this.trackStatistics = trackStatistics;
updateNextDuration();
updateNextTaskDistance();
@@ -88,21 +95,22 @@ public class VoiceAnnouncementManager {
}
}
public void shutdown() {
public void stop() {
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(this);
if (voiceAnnouncement != null) {
voiceAnnouncement.shutdown();
voiceAnnouncement.stop();
voiceAnnouncement = null;
}
}
public void setFrequency(Duration frequency) {
this.totalTimeFrequency = frequency;
restore(this.trackStatistics);
update(this.trackStatistics);
}
public void setFrequency(Distance frequency) {
this.distanceFrequency = frequency;
restore(this.trackStatistics);
update(this.trackStatistics);
}
public void updateNextTaskDistance() {
@@ -141,4 +149,15 @@ public class VoiceAnnouncementManager {
public Distance getNextTotalDistance() {
return nextTotalDistance;
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PreferencesUtils.isKey(R.string.voice_announcement_frequency_key, key)) {
setFrequency(PreferencesUtils.getVoiceAnnouncementFrequency());
}
if (PreferencesUtils.isKey(new int[]{R.string.voice_announcement_distance_key, R.string.stats_units_key}, key)) {
setFrequency(PreferencesUtils.getVoiceAnnouncementDistance());
}
}
}
@@ -1,7 +1,14 @@
package de.dennisguse.opentracks.services.announcement;
import android.content.Context;
import static android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE;
import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceHeartRate;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.TtsSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.time.Duration;
@@ -9,6 +16,7 @@ import java.time.Duration;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.intervals.IntervalStatistics;
@@ -17,68 +25,149 @@ class VoiceAnnouncementUtils {
private VoiceAnnouncementUtils() {
}
static String getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval) {
static Spannable getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval, @Nullable SensorStatistics sensorStatistics) {
SpannableStringBuilder builder = new SpannableStringBuilder();
Distance distance = trackStatistics.getTotalDistance();
Speed distancePerTime = trackStatistics.getAverageMovingSpeed();
Speed currentDistancePerTime = currentInterval != null ? currentInterval.getSpeed() : null;
int perUnitStringId = isMetricUnits ? R.string.voice_per_kilometer : R.string.voice_per_mile;
int totalDistanceId = isMetricUnits ? R.plurals.voiceTotalDistanceKilometers : R.plurals.voiceTotalDistanceMiles;
double distanceInUnit = distance.toKM_Miles(isMetricUnits);
String totalDistance = context.getResources().getQuantityString(totalDistanceId, getQuantityCount(distanceInUnit), distanceInUnit);
int distanceId = isMetricUnits ? R.plurals.voiceDistanceKilometers : R.plurals.voiceDistanceMiles;
builder.append(context.getString(R.string.total_distance));
long distanceIntegerPart = (long) distanceInUnit;
// Extract the decimal part
String distanceFractionalPart = String.format("%.2f", (distanceInUnit - distanceIntegerPart)).substring(2);
// Units should always be english singular for TTS.
// See https://developer.android.com/reference/android/text/style/TtsSpan?hl=en#TYPE_MEASURE
appendDecimalUnit(builder, context.getResources().getQuantityString(distanceId, getQuantityCount(distanceInUnit), distanceInUnit), distanceIntegerPart, distanceFractionalPart, isMetricUnits ? "kilometer" : "mile");
// Punctuation helps introduce natural pauses in TTS
builder.append(".");
if (distance.isZero()) {
return totalDistance;
return builder;
}
// Announce time
Duration movingTime = trackStatistics.getMovingTime();
if (!movingTime.isZero()) {
appendDuration(context, builder, movingTime);
builder.append(".");
}
String rate;
String currentRate;
String currentRateMsg;
if (isReportSpeed) {
int speedId = isMetricUnits ? R.plurals.voiceSpeedKilometersPerHour : R.plurals.voiceSpeedMilesPerHour;
double speedInUnit = distancePerTime.to(isMetricUnits);
rate = context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit);
double currentDistancePerTimeInUnit = currentDistancePerTime != null ? currentDistancePerTime.to(isMetricUnits) : 0;
currentRate = context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit);
currentRateMsg = context.getString(R.string.voice_speed_lap, currentRate);
builder.append(" ")
.append(context.getString(R.string.speed));
long speedIntegerPart = (long) speedInUnit;
// Extract the decimal part
String speedFractionalPart = String.format("%.1f", (speedInUnit - speedIntegerPart)).substring(2);
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit), speedIntegerPart, speedFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour");
builder.append(".");
if (currentDistancePerTime != null) {
double currentDistancePerTimeInUnit = currentDistancePerTime.to(isMetricUnits);
if (currentDistancePerTimeInUnit > 0) {
builder.append(" ")
.append(context.getString(R.string.lap_speed));
long currentDistanceIntegerPart = (long) currentDistancePerTimeInUnit;
// Extract the decimal part
String currentDistanceFractionalPart = String.format("%.1f", (currentDistancePerTimeInUnit - currentDistanceIntegerPart)).substring(2);
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit), currentDistanceIntegerPart, currentDistanceFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour");
builder.append(".");
}
}
} else {
Duration time = distancePerTime.toPace(isMetricUnits);
int paceId = isMetricUnits ? R.string.voice_pace_per_kilometer : R.string.voice_pace_per_mile;
rate = context.getString(paceId, getAnnounceTime(context, time));
builder.append(" ")
.append(context.getString(R.string.pace));
appendDuration(context, builder, time);
builder.append(" ")
.append(context.getString(perUnitStringId))
.append(".");
Duration currentTime = currentDistancePerTime != null ? currentDistancePerTime.toPace(isMetricUnits) : Duration.ofMillis(0);
currentRate = context.getString(paceId, getAnnounceTime(context, currentTime));
currentRateMsg = context.getString(R.string.voice_pace_lap, currentRate);
if (!currentTime.isZero()) {
builder.append(" ")
.append(context.getString(R.string.lap_time));
appendDuration(context, builder, currentTime);
builder.append(" ")
.append(context.getString(perUnitStringId))
.append(".");
}
}
currentRateMsg = currentInterval == null ? "" : " " + currentRateMsg;
if (shouldVoiceAnnounceHeartRate()) {
if (sensorStatistics != null && sensorStatistics.hasHeartRate()) {
int averageHeartRate = Math.round(sensorStatistics.getAvgHeartRate().getBPM());
return context.getString(R.string.voice_template, totalDistance, getAnnounceTime(context, trackStatistics.getMovingTime()), rate) + currentRateMsg;
}
builder.append(" ")
.append(context.getString(R.string.average_heart_rate));
appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, averageHeartRate), averageHeartRate);
builder.append(".");
}
//TODO We might need to localize this using strings.xml if order is relevant.
private static String getAnnounceTime(Context context, Duration duration) {
String result = "";
if (currentInterval != null && currentInterval.hasAverageHeartRate()) {
int currentHeartRate = Math.round(currentInterval.getAverageHeartRate().getBPM());
int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60);
if (hours != 0) {
String hoursText = context.getResources()
.getQuantityString(R.plurals.voiceHours, hours, hours);
result += hoursText + " ";
builder.append(" ")
.append(context.getString(R.string.current_heart_rate));
appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, currentHeartRate), currentHeartRate);
builder.append(".");
}
}
String minutesText = context.getResources()
.getQuantityString(R.plurals.voiceMinutes, minutes, minutes);
String secondsText = context.getResources()
.getQuantityString(R.plurals.voiceSeconds, seconds, seconds);
return result + minutesText + " " + secondsText;
return builder;
}
static int getQuantityCount(double d) {
return (int) d;
}
private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) {
int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60);
if (hours > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceHours, hours, hours), hours, null, "hour");
}
if (minutes > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceMinutes, minutes, minutes), minutes, null, "minute");
}
if (seconds > 0 || duration.isZero()) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceSeconds, seconds, seconds), seconds, null, "second");
}
}
/**
* Speaks as: 98.14 [UNIT] - ninety eight point one four [UNIT with correct plural form]
*/
private static void appendDecimalUnit(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long integerPart, @Nullable String fractionalPart, @NonNull String unit) {
TtsSpan.MeasureBuilder measureBuilder = new TtsSpan.MeasureBuilder()
.setUnit(unit);
if (fractionalPart == null) {
measureBuilder.setNumber(integerPart);
} else {
measureBuilder.setIntegerPart(integerPart)
.setFractionalPart(fractionalPart);
}
builder.append(" ")
.append(localizedText, measureBuilder.build(), SPAN_INCLUSIVE_EXCLUSIVE);
}
/**
* Speaks as: 98 - ninety eight
*/
private static void appendCardinal(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long number) {
builder.append(" ")
.append(localizedText, new TtsSpan.CardinalBuilder().setNumber(number).build(), SPAN_INCLUSIVE_EXCLUSIVE);
}
}
@@ -1,6 +1,7 @@
package de.dennisguse.opentracks.services.handlers;
import android.content.Context;
import android.content.SharedPreferences;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
@@ -17,25 +18,27 @@ import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.LocationUtils;
import de.dennisguse.opentracks.util.PermissionUtils;
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public class GPSHandler implements LocationListener, GpsStatus.GpsStatusListener {
public class GPSHandler implements LocationListener, GpsStatus.GpsStatusListener, SharedPreferences.OnSharedPreferenceChangeListener {
private final String TAG = GPSHandler.class.getSimpleName();
private LocationManager locationManager;
private final TrackPointCreator trackPointCreator;
private Context context;
private LocationManager locationManager;
private GpsStatus gpsStatus;
private Duration gpsInterval;
private Distance thresholdHorizontalAccuracy;
private TrackPoint lastTrackPoint;
public GPSHandler(TrackPointCreator trackPointCreator) {
this.trackPointCreator = trackPointCreator;
}
public void onStart(@NonNull Context context) {
onSharedPreferenceChanged(null);
this.context = context;
PreferencesUtils.registerOnSharedPreferenceChangeListener(this);
gpsStatus = new GpsStatus(context, this);
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
registerLocationListener();
@@ -49,19 +52,23 @@ public class GPSHandler implements LocationListener, GpsStatus.GpsStatusListener
@SuppressWarnings({"MissingPermission"})
//TODO upgrade to AGP7.0.0/API31 started complaining about removeUpdates.
public void onStop() {
lastTrackPoint = null;
if (locationManager != null) {
locationManager.removeUpdates(this);
if (locationManager != null && context != null) {
if (PermissionUtils.hasGPSPermission(context)) {
locationManager.removeUpdates(this);
}
locationManager = null;
context = null;
}
if (gpsStatus != null) {
gpsStatus.stop();
gpsStatus = null;
}
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(this);
}
public void onSharedPreferenceChanged(String key) {
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
boolean registerListener = false;
if (PreferencesUtils.isKey(R.string.min_recording_interval_key, key)) {
@@ -120,7 +127,6 @@ public class GPSHandler implements LocationListener, GpsStatus.GpsStatusListener
return;
}
lastTrackPoint = trackPoint;
trackPointCreator.onNewTrackPoint(trackPoint);
}
@@ -143,31 +149,23 @@ public class GPSHandler implements LocationListener, GpsStatus.GpsStatusListener
}
private void registerLocationListener() {
if (locationManager == null) {
Log.e(TAG, "locationManager is null.");
if (locationManager == null || context == null) {
Log.e(TAG, "Not started.");
return;
}
try {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, gpsInterval.toMillis(), 0, this);
} catch (SecurityException e) {
Log.e(TAG, "Could not register location listener; permissions not granted.", e);
if (PermissionUtils.hasGPSPermission(context)) {
try {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, gpsInterval.toMillis(), 0, this);
} catch (SecurityException e) {
Log.e(TAG, "Could not register location listener; permissions not granted.", e);
}
}
}
TrackPoint getLastTrackPoint() {
return lastTrackPoint;
}
Distance getThresholdHorizontalAccuracy() {
return thresholdHorizontalAccuracy;
}
/**
* Called from {@link GpsStatus} to inform that GPS status has changed from prevStatus to currentStatus.
*
* @param prevStatus previous {@link GpsStatusValue}.
* @param currentStatus current {@link GpsStatusValue}.
*/
@Override
public void onGpsStatusChanged(GpsStatusValue prevStatus, GpsStatusValue currentStatus) {
trackPointCreator.sendGpsStatus(currentStatus);
@@ -56,7 +56,7 @@ class GpsStatus {
public void stop() {
stopped = true;
sendStatus(gpsStatus, GpsStatusValue.GPS_NONE);
sendStatus(gpsStatus, GpsStatusValue.GPS_DISABLED);
}
}
@@ -57,6 +57,7 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
remoteSensorManager.start();
altitudeSumManager.start(context);
}
private boolean isStarted() {
@@ -70,7 +71,7 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
gpsHandler.onStop();
}
public void resetSensorData() {
public void reset() {
if (remoteSensorManager == null || altitudeSumManager == null) {
Log.d(TAG, "No recording running and no reset necessary.");
return;
@@ -84,8 +85,15 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
Log.w(TAG, "Not started, should not be called.");
return null;
}
SensorDataSet sensorDataSet = remoteSensorManager.fill(trackPoint);
altitudeSumManager.fill(trackPoint);
SensorDataSet sensorDataSet = null;
BluetoothRemoteSensorManager localRemoteSensorManager = remoteSensorManager;
if (localRemoteSensorManager != null) {
sensorDataSet = localRemoteSensorManager.fill(trackPoint);
}
AltitudeSumManager localAltitudeSumManager = altitudeSumManager;
if (localAltitudeSumManager != null) {
localAltitudeSumManager.fill(trackPoint);
}
return sensorDataSet;
}
@@ -106,10 +114,6 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
this.context = null;
}
public void onSharedPreferenceChanged(String key) {
gpsHandler.onSharedPreferenceChanged(key);
}
/**
* Got a new TrackPoint from Bluetooth only; contains no GPS location.
*/
@@ -123,7 +127,7 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
boolean stored = service.newTrackPoint(trackPoint, gpsHandler.getThresholdHorizontalAccuracy());
if (stored) {
resetSensorData();
reset();
}
}
@@ -134,34 +138,32 @@ public class TrackPointCreator implements BluetoothRemoteSensorManager.SensorDat
public TrackPoint createSegmentEnd() {
TrackPoint segmentEnd = TrackPoint.createSegmentEndWithTime(createNow());
fill(segmentEnd);
resetSensorData();
reset();
return segmentEnd;
}
public Pair<TrackPoint, SensorDataSet> createCurrentTrackPoint(@Nullable TrackPoint lastValidTrackPoint) {
public Pair<TrackPoint, SensorDataSet> createCurrentTrackPoint(@Nullable TrackPoint lastTrackPointUISpeed, @Nullable TrackPoint lastTrackPointUIAltitude, @Nullable TrackPoint lastStoredTrackPointWithLocation) {
TrackPoint currentTrackPoint = new TrackPoint(TrackPoint.Type.TRACKPOINT, createNow());
TrackPoint lastTrackPoint = gpsHandler.getLastTrackPoint();
if (lastTrackPoint != null && lastTrackPoint.hasLocation()) {
currentTrackPoint.setSpeed(lastTrackPoint.getSpeed());
currentTrackPoint.setAltitude(lastTrackPoint.getAltitude());
if (lastTrackPoint.hasBearing()) {
currentTrackPoint.setBearing(lastTrackPoint.getBearing());
}
if (lastTrackPointUISpeed != null) {
currentTrackPoint.setSpeed(lastTrackPointUISpeed.getSpeed());
}
if (lastValidTrackPoint != null && lastValidTrackPoint.hasLocation()) {
if (lastTrackPointUIAltitude != null) {
currentTrackPoint.setAltitude(lastTrackPointUIAltitude.getAltitude());
}
if (lastStoredTrackPointWithLocation != null && lastStoredTrackPointWithLocation.hasLocation()) {
//We are taking the coordinates from the last stored TrackPoint, so the distance is monotonously increasing.
currentTrackPoint.setLongitude(lastValidTrackPoint.getLongitude());
currentTrackPoint.setLatitude(lastValidTrackPoint.getLatitude());
currentTrackPoint.setLongitude(lastStoredTrackPointWithLocation.getLongitude());
currentTrackPoint.setLatitude(lastStoredTrackPointWithLocation.getLatitude());
}
SensorDataSet sensorDataSet = fill(currentTrackPoint);
return new Pair<>(currentTrackPoint, sensorDataSet);
}
//TODO Limit visibility
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public Instant createNow() {
Instant createNow() {
return Instant.now(clock);
}
@@ -1,7 +1,5 @@
package de.dennisguse.opentracks.settings;
import android.content.Intent;
import android.content.UriPermission;
import android.net.Uri;
import android.os.Bundle;
@@ -10,7 +8,6 @@ import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import java.util.List;
import java.util.Locale;
import de.dennisguse.opentracks.R;
@@ -76,7 +73,7 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
return getString(R.string.not_set);
}
return directoryUri.toString() + (directory.canWrite() ? "" : getString(R.string.export_dir_not_writable));
return directoryUri + (directory.canWrite() ? "" : getString(R.string.export_dir_not_writable));
});
}
}
@@ -7,6 +7,7 @@ import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
@@ -14,7 +15,7 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
private static final String TAG = MainSettingsFragment.class.getSimpleName();
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private final TrackRecordingServiceConnection.Callback bindServiceCallback =
service -> service.getRecordingStatusObservable()
@@ -56,6 +57,11 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
return true;
});
findPreference(getString(R.string.settings_api_key)).setOnPreferenceClickListener(preference -> {
((SettingsActivity) getActivity()).openScreen(getString(R.string.settings_api_key));
return true;
});
findPreference(getString(R.string.settings_open_tracks_key)).setOnPreferenceClickListener(preference -> {
((SettingsActivity) getActivity()).openScreen(getString(R.string.settings_open_tracks_key));
return true;
@@ -98,7 +104,7 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
super.onDisplayPreferenceDialog(preference);
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
this.recordingStatus = status;
if (isAdded()) {
updatePrefsDependOnRecording();
@@ -172,6 +172,13 @@ public class PreferencesUtils {
setString(keyId, resources.getString(valueId));
}
@VisibleForTesting
public static void setBoolean(int keyId, Boolean value) {
Editor editor = sharedPreferences.edit();
editor.putBoolean(getKey(keyId), value);
editor.apply();
}
static void setInt(int keyId, int value) {
Editor editor = sharedPreferences.edit();
editor.putInt(getKey(keyId), value);
@@ -182,6 +189,10 @@ public class PreferencesUtils {
return getBoolean(R.string.publicapi_enabled_key, resources.getBoolean(R.bool.publicapi_enabled_default));
}
public static boolean isPublicAPIDashboardEnabled() {
return getBoolean(R.string.publicapi_dashboard_enabled_key, resources.getBoolean(R.bool.publicapi_dashboard_enabled_default));
}
public static boolean isMetricUnits() {
final String STATS_UNIT = resources.getString(R.string.stats_units_default);
return STATS_UNIT.equals(getString(R.string.stats_units_key, STATS_UNIT));
@@ -312,6 +323,15 @@ public class PreferencesUtils {
return getFloat(R.string.voice_speed_rate_key, DEFAULT);
}
public static boolean shouldVoiceAnnounceHeartRate() {
return getBoolean(R.string.voice_announce_heart_rate_key, false);
}
@VisibleForTesting
public static void setVoiceAnnounceHeartRate(boolean value) {
setBoolean(R.string.voice_announce_heart_rate_key, value);
}
public static Distance getRecordingDistanceInterval() {
return Distance.of(getInt(R.string.recording_distance_interval_key, getRecordingDistanceIntervalDefaultInternal()));
}
@@ -0,0 +1,21 @@
package de.dennisguse.opentracks.settings;
import android.os.Bundle;
import androidx.preference.PreferenceFragmentCompat;
import de.dennisguse.opentracks.R;
public class PublicAPISettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.settings_public_api);
}
@Override
public void onStart() {
super.onStart();
((SettingsActivity) getActivity()).getSupportActionBar().setTitle(R.string.settings_api_title);
}
}
@@ -84,6 +84,8 @@ public class SettingsActivity extends AbstractActivity implements ChooseActivity
fragment = new AnnouncementsSettingsFragment();
} else if (key.equals(getString(R.string.settings_import_export_key))) {
fragment = new ImportExportSettingsFragment();
} else if (key.equals(getString(R.string.settings_api_key))) {
fragment = new PublicAPISettingsFragment();
} else if (key.equals(getString(R.string.settings_open_tracks_key))) {
fragment = new OpenTracksSettingsFragment();
}
@@ -2,8 +2,6 @@ package de.dennisguse.opentracks.settings;
import android.os.Bundle;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ScrollView;
import androidx.annotation.NonNull;
@@ -17,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView;
import java.util.stream.IntStream;
import de.dennisguse.opentracks.AbstractActivity;
import de.dennisguse.opentracks.ui.util.ArrayAdapterFilterDisabled;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.databinding.ActivitySettingsCustomLayoutBinding;
import de.dennisguse.opentracks.ui.customRecordingLayout.DataField;
@@ -80,23 +79,16 @@ public class SettingsCustomLayoutEditActivity extends AbstractActivity implement
itemTouchHelper.attachToRecyclerView(recyclerViewVisible);
// Spinner with items per row.
ArrayAdapter<Integer> spinnerAdapter = new ArrayAdapter<>(this,
ArrayAdapterFilterDisabled<Integer> rowsOptionAdapter = new ArrayAdapterFilterDisabled<>(this,
android.R.layout.simple_spinner_dropdown_item,
IntStream.of(getResources().getIntArray(R.array.stats_custom_layout_fields_columns_per_row)).boxed().toArray(Integer[]::new));
viewBinding.spinnerOptions.setAdapter(spinnerAdapter);
viewBinding.spinnerOptions.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
numColumns = position + 1;
gridLayoutManager.setSpanCount(numColumns);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
viewBinding.rowsOptions.setAdapter(rowsOptionAdapter);
viewBinding.rowsOptions.setOnItemClickListener((parent, view, position, id) -> {
numColumns = position + 1;
gridLayoutManager.setSpanCount(numColumns);
});
viewBinding.spinnerOptions.setSelection(numColumns - 1);
viewBinding.rowsOptions.setText(rowsOptionAdapter.getItem(numColumns - 1).toString(), false);
// Recycler view with not visible stats.
layoutFieldsHidden = StatisticsUtils.filterVisible(layout, false);
@@ -9,14 +9,12 @@ import android.util.Pair;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.ShareContentProvider;
import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.FileUtils;
@@ -47,6 +45,7 @@ public class ShareUtils {
trackDescription = track == null ? "" : new DescriptionGenerator(context).generateTrackDescription(track, false);
}
String mime = "";
ArrayList<Uri> uris = new ArrayList<>();
for (Track.Id trackId : trackIds) {
Track track = contentProviderUtils.getTrack(trackId);
@@ -56,14 +55,14 @@ public class ShareUtils {
}
String trackName = FileUtils.sanitizeFileName(track.getName());
Pair<Uri, String> uriTrackFile = ShareContentProvider.createURI(trackId, trackName, PreferencesUtils.getExportTrackFileFormat());
Pair<Uri, String> uriSharePicture = ShareContentProvider.createURI(trackId, trackName, TrackFileFormat.SHARE_PICTURE_PNG);
Pair<Uri, String> uriAndMime = ShareContentProvider.createURI(trackId, trackName, PreferencesUtils.getExportTrackFileFormat());
uris.addAll(Arrays.asList(uriSharePicture.first, uriTrackFile.first));
uris.add(uriAndMime.first);
mime = uriAndMime.second;
}
return new Intent(Intent.ACTION_SEND_MULTIPLE)
.setType("image/*")
.setType(mime)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.share_track_subject))
@@ -25,6 +25,7 @@ import java.time.Instant;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
@@ -56,6 +57,8 @@ public class TrackStatistics {
private Speed maxSpeed;
private Float totalAltitudeGain_m = null;
private Float totalAltitudeLoss_m = null;
// The average heart rate seen on this track
private HeartRate avgHeartRate = null;
public TrackStatistics() {
reset();
@@ -76,6 +79,7 @@ public class TrackStatistics {
altitudeExtremities.set(other.altitudeExtremities.getMin(), other.altitudeExtremities.getMax());
totalAltitudeGain_m = other.totalAltitudeGain_m;
totalAltitudeLoss_m = other.totalAltitudeLoss_m;
avgHeartRate = other.avgHeartRate;
}
@VisibleForTesting
@@ -108,6 +112,19 @@ public class TrackStatistics {
stopTime = stopTime.isAfter(other.stopTime) ? stopTime : other.stopTime;
}
if (avgHeartRate == null) {
avgHeartRate = other.avgHeartRate;
} else {
if (other.avgHeartRate != null) {
// Using total time as weights for the averaging.
// Important to do this before total time is updated
avgHeartRate = HeartRate.of(
(totalTime.getSeconds() * avgHeartRate.getBPM() + other.totalTime.getSeconds() * other.avgHeartRate.getBPM())
/ (totalTime.getSeconds() + other.totalTime.getSeconds())
);
}
}
totalDistance = totalDistance.plus(other.totalDistance);
totalTime = totalTime.plus(other.totalTime);
movingTime = movingTime.plus(other.movingTime);
@@ -230,6 +247,15 @@ public class TrackStatistics {
return totalTime.minus(movingTime);
}
public boolean hasAverageHeartRate() {
return avgHeartRate != null;
}
@Nullable
public HeartRate getAverageHeartRate() {
return avgHeartRate;
}
/**
* Gets the average speed.
* This calculation only takes into account the displacement until the last point that was accounted for in statistics.
@@ -287,6 +313,12 @@ public class TrackStatistics {
}
}
public void setAverageHeartRate(HeartRate heartRate) {
if (heartRate != null) {
avgHeartRate = heartRate;
}
}
public boolean hasTotalAltitudeGain() {
return totalAltitudeGain_m != null;
}
@@ -26,6 +26,7 @@ import java.util.List;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
@@ -64,6 +65,8 @@ public class TrackStatisticsUpdater {
private final AltitudeRingBuffer altitudeBuffer;
private final SpeedRingBuffer speedBuffer;
private float averageHeartRateBPM;
private Duration totalHeartRateDuration = Duration.ZERO;
// The current segment's statistics
private final TrackStatistics currentSegment;
@@ -85,6 +88,7 @@ public class TrackStatisticsUpdater {
altitudeBuffer = new AltitudeRingBuffer(ALTITUDE_SMOOTHING_FACTOR);
speedBuffer = new SpeedRingBuffer(SPEED_SMOOTHING_FACTOR);
resetAverageHeartRate();
}
public TrackStatisticsUpdater(TrackStatisticsUpdater toCopy) {
@@ -95,6 +99,7 @@ public class TrackStatisticsUpdater {
this.speedBuffer = new SpeedRingBuffer(toCopy.speedBuffer);
this.lastTrackPoint = toCopy.lastTrackPoint;
resetAverageHeartRate();
}
public TrackStatistics getTrackStatistics() {
@@ -142,6 +147,17 @@ public class TrackStatisticsUpdater {
currentSegment.updateAltitudeExtremities(newAverage);
}
// Update heart rate
if (trackPoint.hasHeartRate() && lastTrackPoint != null) {
Duration trackPointDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime());
Duration newTotalDuration = totalHeartRateDuration.plus(trackPointDuration);
averageHeartRateBPM = (totalHeartRateDuration.toMillis() * averageHeartRateBPM + trackPointDuration.toMillis() * trackPoint.getHeartRate().getBPM()) / newTotalDuration.toMillis();
totalHeartRateDuration = newTotalDuration;
currentSegment.setAverageHeartRate(HeartRate.of(averageHeartRateBPM));
}
// Update total distance
if (trackPoint.hasSensorDistance()) {
// Sensor-based distance/speed
@@ -184,6 +200,12 @@ public class TrackStatisticsUpdater {
lastTrackPoint = null;
altitudeBuffer.reset();
speedBuffer.reset();
resetAverageHeartRate();
}
private void resetAverageHeartRate() {
averageHeartRateBPM = 0.0f;
totalHeartRateDuration = Duration.ZERO;
}
/**
@@ -5,11 +5,10 @@ import android.content.Context;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.CheckBox;
import android.widget.DatePicker;
import android.widget.GridLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -17,6 +16,8 @@ import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.textfield.TextInputEditText;
import java.time.LocalDate;
@@ -61,17 +62,17 @@ public class FilterDialogFragment extends DialogFragment {
LayoutInflater inflater = LayoutInflater.from(getActivity());
View layout = inflater.inflate(R.layout.fragment_filter_dialog, null, false);
GridLayout itemsLayout = layout.findViewById(R.id.filter_items);
MaterialButtonToggleGroup itemsLayout = layout.findViewById(R.id.filter_items);
builder.setView(layout);
for (FilterItem item : filterItems) {
View view = inflater.inflate(R.layout.fragment_filter_dialog_item, null);
CheckBox checkBox = view.findViewById(R.id.filter_dialog_check_button);
checkBox.setText(item.value);
checkBox.setChecked(item.isChecked);
checkBox.setTag(item.id);
checkBox.setOnClickListener(v -> item.isChecked = !item.isChecked);
MaterialButton button = view.findViewById(R.id.filter_dialog_check_button);
button.setText(item.value);
button.setChecked(item.isChecked);
button.setTag(item.id);
button.setOnClickListener(v -> item.isChecked = !item.isChecked);
itemsLayout.addView(view);
}
@@ -43,8 +43,8 @@ public class SettingsCustomLayoutEditAdapter extends RecyclerView.Adapter<Recycl
viewHolder.title.setText(field.getTitle());
viewHolder.value.setText(StatisticsUtils.emptyValue(context, field.getKey()));
viewHolder.title.setTextAppearance(context, field.isVisible() ? (field.isPrimary() ? R.style.StatsPrimaryLabel : R.style.StatsSecondaryLabel) : R.style.StatsLabelHidden);
viewHolder.value.setTextAppearance(context, field.isVisible() ? (field.isPrimary() ? R.style.StatsPrimaryValue : R.style.StatsSecondaryValue) : R.style.StatsValueHidden);
viewHolder.title.setTextAppearance(context, field.isVisible() ? (field.isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryHeader : R.style.TextAppearance_OpenTracks_SecondaryHeader) : R.style.TextAppearance_OpenTracks_HiddenHeader);
viewHolder.value.setTextAppearance(context, field.isVisible() ? (field.isPrimary() ? R.style.TextAppearance_OpenTracks_PrimaryValue : R.style.TextAppearance_OpenTracks_SecondaryValue) : R.style.TextAppearance_OpenTracks_HiddenValue);
viewHolder.statusIcon.setVisibility(field.isVisible() ? View.GONE : View.VISIBLE);
viewHolder.statusIcon.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_baseline_visibility_off_24));
viewHolder.moveIcon.setVisibility(field.isVisible() ? View.VISIBLE : View.GONE);
@@ -8,6 +8,7 @@ import java.util.List;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.stats.TrackStatistics;
@@ -17,16 +18,13 @@ public class IntervalStatistics {
private TrackStatisticsUpdater trackStatisticsUpdater = new TrackStatisticsUpdater();
private final List<Interval> intervalList;
private final Distance distanceInterval;
private final Distance minGPSDistance;
private Interval interval, lastInterval;
/**
* @param distanceInterval distance of every interval.
* @param minGPSDistance the setting value for GPS distance.
*/
public IntervalStatistics(Distance distanceInterval, Distance minGPSDistance) {
public IntervalStatistics(Distance distanceInterval) {
this.distanceInterval = distanceInterval;
this.minGPSDistance = minGPSDistance;
interval = new Interval();
lastInterval = new Interval();
@@ -105,6 +103,7 @@ public class IntervalStatistics {
private Duration time = Duration.ofSeconds(0);
private Float gain_m;
private Float loss_m;
private HeartRate avgHeartRate;
public Interval() {
}
@@ -120,6 +119,7 @@ public class IntervalStatistics {
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
avgHeartRate = i.avgHeartRate;
}
public Interval(Interval i) {
@@ -127,6 +127,7 @@ public class IntervalStatistics {
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
avgHeartRate = i.avgHeartRate;
}
private void adjust(double adjustFactor) {
@@ -158,11 +159,20 @@ public class IntervalStatistics {
return loss_m;
}
public boolean hasAverageHeartRate() {
return avgHeartRate != null;
}
public HeartRate getAverageHeartRate() {
return avgHeartRate;
}
private void add(TrackStatistics trackStatistics, @Nullable TrackPoint lastTrackPoint) {
distance = distance.plus(trackStatistics.getTotalDistance());
time = time.plus(trackStatistics.getTotalTime());
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
avgHeartRate = trackStatistics.getAverageHeartRate();
if (lastTrackPoint == null) {
return;
}
@@ -179,6 +189,7 @@ public class IntervalStatistics {
time = trackStatistics.getTotalTime();
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
avgHeartRate = trackStatistics.getAverageHeartRate();
}
}
}
@@ -22,7 +22,6 @@ import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.tables.TrackPointsColumns;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/**
* This model is used to load intervals for a track.
@@ -35,7 +34,6 @@ public class IntervalStatisticsModel extends AndroidViewModel {
private MutableLiveData<List<IntervalStatistics.Interval>> intervalsLiveData;
private IntervalStatistics intervalStatistics;
private Distance distanceInterval;
private final Distance minGPSDistance;
private final ContentResolver contentResolver;
private ContentObserver trackPointsTableObserver;
private TrackPoint.Id lastTrackPointId;
@@ -46,7 +44,6 @@ public class IntervalStatisticsModel extends AndroidViewModel {
public IntervalStatisticsModel(@NonNull Application application) {
super(application);
minGPSDistance = PreferencesUtils.getRecordingDistanceInterval();
contentResolver = getApplication().getContentResolver();
handlerThread = new HandlerThread(TAG);
handlerThread.start();
@@ -75,7 +72,7 @@ public class IntervalStatisticsModel extends AndroidViewModel {
intervalsLiveData = new MutableLiveData<>();
distanceInterval = interval.getDistance(metricUnits);
intervalStatistics = new IntervalStatistics(distanceInterval, minGPSDistance);
intervalStatistics = new IntervalStatistics(distanceInterval);
loadIntervalStatistics(trackId);
}
@@ -114,7 +111,7 @@ public class IntervalStatisticsModel extends AndroidViewModel {
lastTrackPointId = null;
distanceInterval = interval.getDistance(metricUnits);
intervalStatistics = new IntervalStatistics(distanceInterval, minGPSDistance);
intervalStatistics = new IntervalStatistics(distanceInterval);
loadIntervalStatistics(trackId);
}
@@ -5,7 +5,6 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
@@ -33,6 +32,7 @@ public class IntervalsFragment extends Fragment {
private static final String FROM_TOP_TO_BOTTOM_KEY = "fromTopToBottom";
private static final String TRACK_ID_KEY = "trackId";
private static final String SELECTED_INTERVAL_KEY = "selectedIntervalKey";
private IntervalStatisticsModel viewModel;
protected IntervalStatisticsAdapter.StackMode stackModeListView;
@@ -41,7 +41,7 @@ public class IntervalsFragment extends Fragment {
private Track.Id trackId;
private boolean metricUnits;
private IntervalStatisticsAdapter adapter;
private ArrayAdapter<IntervalStatisticsModel.IntervalOption> spinnerAdapter;
private ArrayAdapter<IntervalStatisticsModel.IntervalOption> intervalsAdapter;
private boolean isReportSpeed;
@@ -50,8 +50,8 @@ public class IntervalsFragment extends Fragment {
protected final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> {
if (PreferencesUtils.isKey(R.string.stats_units_key, key) || PreferencesUtils.isKey(R.string.stats_rate_key, key)) {
updateIntervals(PreferencesUtils.isMetricUnits(), selectedInterval);
if (spinnerAdapter != null) {
spinnerAdapter.notifyDataSetChanged();
if (intervalsAdapter != null) {
intervalsAdapter.notifyDataSetChanged();
}
}
};
@@ -76,6 +76,15 @@ public class IntervalsFragment extends Fragment {
super.onCreate(savedInstanceState);
stackModeListView = getArguments().getBoolean(FROM_TOP_TO_BOTTOM_KEY, true) ? IntervalStatisticsAdapter.StackMode.STACK_FROM_TOP : IntervalStatisticsAdapter.StackMode.STACK_FROM_BOTTOM;
trackId = getArguments().getParcelable(TRACK_ID_KEY);
if (savedInstanceState != null) {
selectedInterval = (IntervalStatisticsModel.IntervalOption) savedInstanceState.getSerializable(SELECTED_INTERVAL_KEY);
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(SELECTED_INTERVAL_KEY, selectedInterval);
}
@Override
@@ -93,7 +102,7 @@ public class IntervalsFragment extends Fragment {
// TODO handle empty view: before we did viewBinding.intervalList.setEmptyView(viewBinding.intervalListEmptyView);
viewBinding.intervalList.setAdapter(adapter);
spinnerAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, IntervalStatisticsModel.IntervalOption.values()) {
intervalsAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, IntervalStatisticsModel.IntervalOption.values()) {
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
@@ -112,17 +121,13 @@ public class IntervalsFragment extends Fragment {
}
};
viewBinding.spinnerIntervals.setAdapter(spinnerAdapter);
viewBinding.spinnerIntervals.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
updateIntervals(metricUnits, IntervalStatisticsModel.IntervalOption.values()[i]);
}
viewBinding.intervalsDropdown.setAdapter(intervalsAdapter);
viewBinding.intervalsDropdown.setOnItemClickListener((parent, view1, position, id) -> updateIntervals(metricUnits, IntervalStatisticsModel.IntervalOption.values()[position]));
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
}
});
viewBinding.intervalsDropdown.setText(
getContext().getString(R.string.value_integer_kilometer, Integer.parseInt(selectedInterval != null ? selectedInterval.toString() : IntervalStatisticsModel.IntervalOption.values()[0].toString())),
false
);
}
@Override
@@ -173,6 +178,7 @@ public class IntervalsFragment extends Fragment {
if (viewModel == null) {
return;
}
viewBinding.intervalRate.setText(isReportSpeed ? getString(R.string.stats_speed) : getString(R.string.stats_pace));
LiveData<List<IntervalStatistics.Interval>> liveData = viewModel.getIntervalStats(trackId, metricUnits, selectedInterval);
liveData.observe(getActivity(), intervalList -> adapter.swapData(intervalList, metricUnits, isReportSpeed));
@@ -38,6 +38,7 @@ import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.databinding.MarkerListBinding;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.share.ShareUtils;
@@ -59,7 +60,7 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
private ContentProviderUtils contentProviderUtils;
private TrackRecordingService.RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private Track track;
private MarkerResourceCursorAdapter resourceCursorAdapter;
@@ -284,7 +285,7 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
// Do nothing
}
private void onRecordingStatusChanged(TrackRecordingService.RecordingStatus status) {
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
}
@@ -0,0 +1,35 @@
package de.dennisguse.opentracks.ui.util;
import android.content.Context;
import android.widget.ArrayAdapter;
import android.widget.Filter;
import androidx.annotation.NonNull;
// https://github.com/material-components/material-components-android/issues/1464
public class ArrayAdapterFilterDisabled<T> extends ArrayAdapter<T> {
public ArrayAdapterFilterDisabled(@NonNull Context context, int resource, @NonNull T[] objects) {
super(context, resource, objects);
}
@NonNull
@Override
public Filter getFilter() {
return new NeverFilter();
}
private class NeverFilter extends Filter {
protected FilterResults performFiltering(CharSequence prefix) {
return new FilterResults();
}
protected void publishResults(CharSequence constraint, FilterResults results) {
if (ArrayAdapterFilterDisabled.this.getCount() > 0) {
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
}
}
@@ -0,0 +1,22 @@
package de.dennisguse.opentracks.ui.util;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.textfield.MaterialAutoCompleteTextView;
// https://github.com/material-components/material-components-android/issues/1464
public class AutoCompleteTextViewForDropdown extends MaterialAutoCompleteTextView {
public AutoCompleteTextViewForDropdown(@NonNull final Context context, @Nullable final AttributeSet attributeSet) {
super(context, attributeSet);
}
@Override
public boolean getFreezesText() {
return false;
}
}
@@ -17,11 +17,9 @@
package de.dennisguse.opentracks.ui.util;
import android.content.Context;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.time.OffsetDateTime;
@@ -77,11 +75,9 @@ public class ListItemUtils {
TextView timeDistanceTextView = view.findViewById(R.id.list_item_time_distance);
String timeDistanceText;
if (isRecording) {
timeDistanceTextView.setTextColor(context.getResources().getColor(isPaused ? android.R.color.white : R.color.recording_text));
timeDistanceText = context.getString(isPaused ? R.string.generic_paused : R.string.generic_recording);
} else {
// Match list_item_time_distance in list_item.xml
timeDistanceTextView.setTextAppearance(context, R.style.TextSmall);
timeDistanceText = getTimeDistance(totalTime, totalDistance);
if (markerCount > 0) {
timeDistanceText += " \u2027";
@@ -111,7 +107,7 @@ public class ListItemUtils {
String timeValue = null;
if (!isRecording) {
dateValue = StringUtils.formatDateTodayRelative(context, offsetDateTime);
timeValue = offsetDateTime.format(DateTimeFormatter.ofPattern("hh:mm"));
timeValue = offsetDateTime.format(DateTimeFormatter.ofPattern("HH:mm"));
}
setTextView(context, dateTextView, dateValue, hasPhoto);
setTextView(context, timeTextView, timeValue, hasPhoto);
@@ -132,10 +128,6 @@ public class ListItemUtils {
timeDistanceTextView.setSingleLine(true);
setTextView(context, categoryDescriptionTextView, categoryDescription, hasPhoto);
}
// Adjust iconImageView layout gravity
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) iconImageView.getLayoutParams();
params.gravity = timeDistanceTextView.getVisibility() == View.GONE && markerCountIcon.getVisibility() == View.GONE ? Gravity.TOP : Gravity.CENTER_VERTICAL;
}
/**
@@ -42,14 +42,14 @@ public class ThemeUtils {
}
public static int getFontSizeSmallInPx(Context context) {
TypedArray typedArray = context.obtainStyledAttributes(R.style.TextSmall, new int[]{android.R.attr.textSize});
TypedArray typedArray = context.obtainStyledAttributes(R.style.TextAppearance_MaterialComponents_Body2, new int[]{android.R.attr.textSize});
int fontSize = typedArray.getDimensionPixelSize(0, 12);
typedArray.recycle();
return fontSize;
}
public static int getFontSizeMediumInPx(Context context) {
TypedArray typedArray = context.obtainStyledAttributes(R.style.TextMedium, new int[]{android.R.attr.textSize});
TypedArray typedArray = context.obtainStyledAttributes(R.style.TextAppearance_MaterialComponents_Body1, new int[]{android.R.attr.textSize});
int fontSize = typedArray.getDimensionPixelSize(0, 15);
typedArray.recycle();
return fontSize;
@@ -2,8 +2,10 @@ package de.dennisguse.opentracks.util;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Handler;
import android.provider.DocumentsContract;
import android.util.Log;
import android.widget.Toast;
@@ -25,12 +27,13 @@ import de.dennisguse.opentracks.io.file.exporter.ExportService;
import de.dennisguse.opentracks.io.file.exporter.ExportServiceResultReceiver;
import de.dennisguse.opentracks.io.file.exporter.TrackExporter;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
public class ExportUtils {
private static final String TAG = ExportUtils.class.getSimpleName();
public static void postWorkoutExport(Context context, Track.Id trackId, ExportServiceResultReceiver resultReceiver) {
public static void postWorkoutExport(Context context, Track.Id trackId) {
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
@@ -40,6 +43,16 @@ public class ExportUtils {
return;
}
ExportServiceResultReceiver resultReceiver = new ExportServiceResultReceiver(new Handler(), new ExportServiceResultReceiver.Receiver() {
@Override
public void onExportError(Track.Id trackId) {
Intent intent = new Intent(context, SettingsActivity.class);
intent.putExtra(SettingsActivity.EXTRAS_CHECK_EXPORT_DIRECTORY, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
});
ExportService.enqueue(context, resultReceiver, trackId, trackFileFormat, directory.getUri());
}
}
@@ -146,7 +146,7 @@ public class FileUtils {
for (int i = 0; i < name.length(); i++) {
int codePoint = name.codePointAt(i);
char character = name.charAt(i);
if (Character.isLetterOrDigit(character) || codePoint > 127 || isSpecialFat32(character) || character == '.') {
if (Character.isLetterOrDigit(character) || isSpecialFat32(character) || character == '.') {
builder.appendCodePoint(codePoint);
} else {
builder.append("_");
@@ -4,6 +4,7 @@ import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -23,6 +24,7 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
* The receiving {@link android.app.Activity} gets temporary access to the {@link TracksColumns} and the {@link TrackPointsColumns} (incl. update).
*/
public class IntentDashboardUtils {
private static final String TAG = IntentDashboardUtils.class.getSimpleName();
private static final String ACTION_DASHBOARD = "Intent.OpenTracks-Dashboard";
@@ -58,6 +60,15 @@ public class IntentDashboardUtils {
* @param trackIds the track ids
*/
public static void startDashboard(Context context, boolean isRecording, Track.Id... trackIds) {
startDashboard(context, isRecording, null, null, trackIds);
}
/**
* Send intent to show tracks on a map (needs an another app) as resource URIs.
* By providing a targetPackage and targetClass an explicit intent can be sent,
* thus bypassing the need for the user to select an app.
*/
public static void startDashboard(Context context, boolean isRecording, String targetPackage, String targetClass, Track.Id... trackIds) {
if (trackIds.length == 0) {
return;
}
@@ -87,6 +98,13 @@ public class IntentDashboardUtils {
clipData.addItem(new ClipData.Item(uris.get(MARKERS_URI_INDEX)));
intent.setClipData(clipData);
if ((targetPackage != null) && (targetClass != null)) {
Log.i(TAG, "Starting dashboard activity with explicit intent (package=" + targetPackage + ", class=" + targetClass + ")");
intent.setClassName(targetPackage, targetClass);
} else {
Log.i(TAG, "Starting dashboard activity with generic intent (package=" + targetPackage + ", class=" + targetClass + ")");
}
context.startActivity(intent);
}
@@ -77,7 +77,7 @@ public class IntentUtils {
public static void persistDirectoryAccessPermission(Context context, Uri directoryUri, int existingFlags) {
int newFlags = existingFlags & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
context.getContentResolver().takePersistableUriPermission(directoryUri, newFlags);
context.getApplicationContext().getContentResolver().takePersistableUriPermission(directoryUri, newFlags);
}
public static void releaseDirectoryAccessPermission(Context context, final Uri documentUri) {
@@ -85,7 +85,7 @@ public class IntentUtils {
return;
}
context.getContentResolver().getPersistedUriPermissions().stream()
context.getApplicationContext().getContentResolver().getPersistedUriPermissions().stream()
.map(UriPermission::getUri)
.filter(documentUri::equals)
.forEach(u -> context.getContentResolver().releasePersistableUriPermission(u, 0));
@@ -96,7 +96,7 @@ public class IntentUtils {
return null;
}
try {
return DocumentFile.fromTreeUri(context, directoryUri);
return DocumentFile.fromTreeUri(context.getApplicationContext(), directoryUri);
} catch (Exception e) {
Log.w(TAG, "Could not decode directory: " + e.getMessage());
}
@@ -0,0 +1,18 @@
package de.dennisguse.opentracks.util;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import androidx.core.content.ContextCompat;
public class PermissionUtils {
private PermissionUtils() {
}
public static boolean hasGPSPermission(Context context) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED;
}
}
@@ -52,6 +52,9 @@ public class TrackIconUtils {
private static final String INLINE_SKATING = "INLINES_SKATING";
private static final String SKATE_BOARDING = "SKATE_BOARDING";
private static final String CLIMBING = "CLIMBING";
private static final String SWIMMING = "SWIMMING";
private static final String SWIMMING_OPEN = "SWIMMING_OPEN";
private static final String WORKOUT = "WORKOUT";
private static final int ACTIVITY_UNKNOWN_LOGO = R.drawable.ic_logo_24dp;
@@ -71,6 +74,8 @@ public class TrackIconUtils {
private static final int[] SKATE_BOARDING_LIST = new int[]{R.string.activity_type_skate_boarding};
private static final int[] KAYAKING_LIST = new int[]{R.string.activity_type_kayaking};
private static final int[] WALK_LIST = new int[]{R.string.activity_type_hiking, R.string.activity_type_off_trail_hiking, R.string.activity_type_speed_walking, R.string.activity_type_trail_hiking, R.string.activity_type_walking};
private static final int[] SWIM_LIST = new int[]{R.string.activity_type_swimming, R.string.activity_type_swimming_open};
private static final int[] WORKOUT_LIST = new int[]{R.string.activity_type_workout};
// List of icons whose sports associated use speed (in km/h or mi/h).
private static final int[] SPEED_ICON = {
@@ -110,6 +115,9 @@ public class TrackIconUtils {
MAP.put(KAYAK, new Pair<>(R.string.activity_type_kayaking, R.drawable.ic_activity_kayaking_24dp));
MAP.put(BOAT, new Pair<>(R.string.activity_type_boat, R.drawable.ic_activity_boat_24dp));
MAP.put(SAILING, new Pair<>(R.string.activity_type_sailing, R.drawable.ic_activity_sailing_24dp));
MAP.put(SWIMMING, new Pair<>(R.string.activity_type_swimming, R.drawable.ic_activity_swimming_24dp));
MAP.put(SWIMMING_OPEN, new Pair<>(R.string.activity_type_swimming_open, R.drawable.ic_activity_swimming_open_24dp));
MAP.put(WORKOUT, new Pair<>(R.string.activity_type_workout, R.drawable.ic_activity_workout_24dp));
}
private TrackIconUtils() {
@@ -208,6 +216,12 @@ public class TrackIconUtils {
if (inList(resources, activityType, WALK_LIST)) {
return WALK;
}
if (inList(resources, activityType, SWIM_LIST)) {
return SWIMMING;
}
if (inList(resources, activityType, WORKOUT_LIST)) {
return WORKOUT;
}
return UNKNOWN;
}
@@ -15,7 +15,7 @@ import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.customRecordingLayout.DataField;
@@ -24,7 +24,7 @@ import de.dennisguse.opentracks.util.StringUtils;
public class StatisticDataBuilder {
public static List<StatisticData> fromRecordingData(@NonNull Context context, @NonNull TrackRecordingService.RecordingData recordingData, @NonNull Layout layout, boolean metricUnits) {
public static List<StatisticData> fromRecordingData(@NonNull Context context, @NonNull RecordingData recordingData, @NonNull Layout layout, boolean metricUnits) {
List<StatisticData> statisticDataList = layout.getFields().stream()
.filter(DataField::isVisible)
.map(field -> build(context, recordingData, field.getKey(), field.isPrimary(), metricUnits))
@@ -34,7 +34,7 @@ public class StatisticDataBuilder {
return statisticDataList;
}
private static StatisticData build(@NonNull Context context, @NonNull TrackRecordingService.RecordingData recordingData, @NonNull String fieldKey, boolean isPrimary, boolean metricUnits) {
private static StatisticData build(@NonNull Context context, @NonNull RecordingData recordingData, @NonNull String fieldKey, boolean isPrimary, boolean metricUnits) {
final TrackPoint latestTrackPoint = recordingData.getLatestTrackPoint();
final SensorDataSet sensorDataSet = recordingData.getSensorDataSet();
final TrackStatistics trackStatistics = recordingData.getTrackStatistics();
@@ -153,7 +153,7 @@ public class StatisticDataBuilder {
/**
* Builds a list of StatisticData with sensors configured but not in statisticDataList.
*/
private static List<StatisticData> getSensorStatsDataIfNeeded(Context context, TrackRecordingService.RecordingData recordingData, List<StatisticData> statisticDataList, boolean metricUnits) {
private static List<StatisticData> getSensorStatsDataIfNeeded(Context context, RecordingData recordingData, List<StatisticData> statisticDataList, boolean metricUnits) {
List<StatisticData> sensorDataList = new ArrayList<>();
SensorDataSet sensorDataSet = recordingData.getSensorDataSet();
if (sensorDataSet == null) {
@@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData;
import java.util.List;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.RecordingData;
import de.dennisguse.opentracks.ui.customRecordingLayout.Layout;
public class StatisticsDataModel extends AndroidViewModel {
@@ -26,7 +26,7 @@ public class StatisticsDataModel extends AndroidViewModel {
return statsData;
}
public void update(TrackRecordingService.RecordingData recordingData, Layout layout, boolean metricUnit) {
public void update(RecordingData recordingData, Layout layout, boolean metricUnit) {
new Thread(() -> {
List<StatisticData> statisticDataList = StatisticDataBuilder.fromRecordingData(getApplication(), recordingData, layout, metricUnit);
statsData.postValue(statisticDataList);
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/colorAccentTransparent" android:state_activated="true" />
<item android:drawable="@color/opentracks_secondary_transparent" android:state_activated="true" />
<item android:drawable="@android:color/transparent" />
</selector>

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