Compare commits

...

106 Commits

Author SHA1 Message Date
Dennis Guse aa63474114 Release: v4.28.1 2026-08-08 12:06:32 +02:00
CI/CD Bot by pstorch cf545dc17b chore(deps): update dependency gradle to v9.7.0 2026-08-06 14:36:45 +00:00
Dennis Guse a542f45661 Bugfix: HealthConnect doesn't provide granted permissions in time.
Fixes #2376.
2026-08-05 19:16:00 +02:00
Dennis Guse 1eeb706330 Bugfix: HealthConnect requires unique timestamps for locations.
Part of #2376.
2026-08-05 19:13:16 +02:00
Dennis Guse 01ea65742b HealthConnect: send total distance, elevation gain and speed. 2026-08-04 17:25:46 +02:00
Dennis Guse e9456919cb Bug: don't crash in settings if HealthConnect is not installed.
Part of #2376.
2026-08-03 20:27:47 +02:00
Priit Jõerüüt 2a8e1c99b6 Translated using Weblate (Estonian)
Currently translated at 100.0% (554 of 554 strings)

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

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/el/
2026-07-29 20:34:20 +02:00
CI/CD Bot by pstorch e372a780f0 Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 2026-07-29 17:43:19 +00:00
Dennis Guse 00be0b565c Cleanup: removed unused methods and constants. 2026-07-26 15:50:01 +02:00
Dennis Guse 8b1e1cf294 Cleanup. 2026-07-26 15:34:34 +02:00
Dennis Guse ce313ba4fd Cleanup: add missing @NonNull annotation. 2026-07-26 15:33:29 +02:00
Dennis Guse df501bde0d Cleanup: removed empty localization (ia). 2026-07-26 15:30:55 +02:00
Dennis Guse 30ed8d8ba3 Cleanup: CustomContentProvider by inlining into UrlType. 2026-07-24 20:54:16 +02:00
Dennis Guse ea2127846d Cleanup: CustomContentProvider by inlining into UrlType. 2026-07-24 19:51:19 +02:00
Dennis Guse ac0da5e9d1 Cleanup: CustomContentProvider by inlining into UrlType. 2026-07-24 19:51:19 +02:00
Dennis Guse 47fea690c9 FOREGROUND_SERVICE is mandatory.
Revert a1fa95094f
2026-07-24 19:51:01 +02:00
Dennis Guse a1fa95094f Removed unused translations. 2026-07-23 22:39:52 +02:00
Dennis Guse c4a1fb0d98 Cleanup. 2026-07-23 22:39:17 +02:00
Dennis Guse 57cd4b3780 Bugfix: gravel was not selectable.
Introduced in fb25055f3e
2026-07-23 22:34:43 +02:00
Dennis Guse 5c645712ff Fix translations. 2026-07-23 22:31:09 +02:00
Dennis Guse e724baaafa Sensors: Polar Loop (Gen 2) can be used for heartrate. 2026-07-23 22:10:06 +02:00
CI/CD Bot by pstorch f55dcd7162 Update dependency com.android.tools.build:gradle to v9.3.1 2026-07-23 14:36:34 +00:00
Dennis Guse 74169cb819 Code cleanup using static code analysis (Android Studio). 2026-07-21 07:31:25 +02:00
Dennis Guse 1821f47cf1 Code analyze: only use String.replace() instead of String.replaceAll(). 2026-07-21 07:30:19 +02:00
Dennis Guse 925a253bbb Fix tests: PreferencesUtils wasn't visible enough (anymore). 2026-07-21 07:22:51 +02:00
Dennis Guse e89ae1c2df Translations: must use <a>. 2026-07-21 06:39:06 +02:00
Dennis Guse 75058118e0 Remove unused translation. 2026-07-21 06:39:06 +02:00
CI/CD Bot by pstorch 96559230f8 Update dependency com.android.tools.build:gradle to v9.3.0 2026-07-14 14:34:39 +00:00
ghose f16f2cfc64 Translated using Weblate (Galician)
Currently translated at 100.0% (554 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/gl/
2026-07-12 06:07:51 +00:00
Outbreak2096 7f353a5d13 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (554 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/zh_Hans/
2026-07-08 18:08:05 +00:00
SomeTr 2453edaa65 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (554 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/uk/
2026-07-08 18:08:04 +00:00
Priit Jõerüüt 1b1568511a Translated using Weblate (Estonian)
Currently translated at 98.7% (547 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/et/
2026-07-08 18:08:04 +00:00
miler 29a36c3ea7 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (554 of 554 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/es_419/
2026-07-08 18:08:04 +00:00
gallegonovato 71d0c7cc92 Translated using Weblate (Spanish)
Currently translated at 8.5% (16 of 187 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/es/
2026-07-08 18:08:04 +00:00
Dennis Guse b09d640844 Release: v4.28.0 2026-07-06 18:02:19 +02:00
Dennis Guse a039f1a00c Remove unused translation. 2026-07-06 17:57:23 +02:00
jimkats 3df5c06c20 Translated using Weblate (Greek)
Currently translated at 71.6% (391 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/el/
2026-07-05 17:08:03 +00:00
hipgnose 2904c65a01 Translated using Weblate (Portuguese (Brazil))
Currently translated at 3.2% (6 of 187 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/pt_BR/
2026-07-05 17:08:02 +00:00
Dennis Guse 4977789004 Implemented basic HealthConnect exporter
Fixes #1542

Co-authored-by: Manuel <manuel@mmarca.tech>
2026-07-03 08:24:42 +02:00
Dennis Guse 95e6448cba Health Connect: minimal implementation for writing route data. 2026-07-03 08:18:44 +02:00
Manuel a66c1b9407 HealthConnect: Rename PermissionsRationaleActivity to HealthConnectPermissionsRationaleActivity 2026-07-03 08:18:44 +02:00
Manuel 9d55ac261f HealthConnect: Wire settings screen 2026-07-03 08:18:44 +02:00
Manuel dbd3e2fc5c HealthConnect: Add settings screen resources 2026-07-03 08:18:44 +02:00
Manuel 713e454e44 HealthConnect: Queue exports in the background 2026-07-03 08:18:44 +02:00
Manuel f0616fc043 HealthConnect: Add exercise session exporter 2026-07-03 08:18:44 +02:00
Manuel 75ed3120bd HealthConnect: Add permissions rationale activity 2026-07-03 08:18:44 +02:00
Peter Storch b22e41cf66 fix #2349: dashboard API does not update while recording if more than one track is selected 2026-06-28 14:56:14 +02:00
Dennis Guse d1f003c0ce Bugfix: voice_announce_lap_power_key should not have been translatable.
For some localizations, users will experience that the setting got reverted.

Fixes #2359.
2026-06-28 13:02:28 +02:00
CI/CD Bot by pstorch 6ef80ade98 Update dependency gradle to v9.6.1 2026-06-28 12:29:00 +02:00
hipgnose e49d9fbd83 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (546 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/pt_BR/
2026-06-27 08:49:58 +00:00
Dennis Guse c7738b1cb2 Release: v4.27.2 2026-06-24 06:24:33 +02:00
Dennis Guse 713f5c7708 Cleanup. 2026-06-24 06:10:12 +02:00
Dennis Guse b9a36d1b3c Bugfix: storing idle and sensor-based trackpoints might happen in the wrong order (race condition).
Fixes #2356.
2026-06-21 18:50:26 +02:00
Dennis Guse 34e89ea3f9 TimeZoneOffsets now supports minutes.
Fixes #2357.
2026-06-21 13:54:59 +02:00
CI/CD Bot by pstorch 50a0e37086 Update actions/checkout action to v7 2026-06-19 05:44:07 +02:00
CI/CD Bot by pstorch 11ac6b7279 Update dependency gradle to v9.6.0 2026-06-19 00:34:17 +00:00
CI/CD Bot by pstorch aadb79bf7d Update dependency androidx.core:core-location-altitude to v1.0.0 2026-06-17 17:34:17 +00:00
ovl-005 da6b26644e Translated using Weblate (Norwegian Bokmål)
Currently translated at 98.7% (539 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/nb_NO/
2026-06-12 21:41:16 +00:00
qwjyh b264655d75 Translated using Weblate (Japanese)
Currently translated at 52.1% (285 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/ja/
2026-06-09 14:16:51 +00:00
Outbreak2096 1ea9c0b216 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (546 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/zh_Hans/
2026-06-09 14:16:51 +00:00
SomeTr 8040edac7a Translated using Weblate (Ukrainian)
Currently translated at 100.0% (546 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/uk/
2026-06-09 14:16:51 +00:00
ghose 037c79a1c5 Translated using Weblate (Galician)
Currently translated at 100.0% (546 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/gl/
2026-06-09 14:16:51 +00:00
Priit Jõerüüt bc46f7447e Translated using Weblate (Estonian)
Currently translated at 100.0% (546 of 546 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/et/
2026-06-09 14:16:51 +00:00
Dennis Guse 6e88a448b1 Release: v4.27.1 2026-06-07 12:24:52 +02:00
jimkats 2dac8d3ea6 Translated using Weblate (Greek)
Currently translated at 71.7% (391 of 545 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/el/
2026-06-04 19:41:11 +02:00
CI/CD Bot by pstorch 0844c48d25 Update dependency androidx.core:core to v1.19.0 2026-06-03 21:25:51 +02:00
Dennis Guse 9607fda962 Android17: upgrade target sdk to 37.
Part of #2340.
2026-06-03 21:25:20 +02:00
Dennis Guse 77ed28a8e2 Revert "Remove StrictMode warnings."
This reverts commit f3e8d033cc.
2026-06-03 21:25:20 +02:00
Dennis Guse 4283e6d316 Bugfix: TrackRecordingService must handle restarts while recording.
We now store the Track.Id in a sharedPreferences.

Functionality was removed in e09b04308c

Fixes #2333
2026-05-29 16:36:29 +02:00
Dennis Guse ee030f32f5 Bugfix: resetting settings should restore data. 2026-05-29 16:36:29 +02:00
Dennis Guse fb25055f3e Activity: add gravel biking. 2026-05-27 12:23:32 +02:00
Dennis Guse 9cdf87b998 Test for announcement without speed/distance units.
Part of #2323
2026-05-27 11:29:18 +02:00
gallegonovato e9217b5327 Translated using Weblate (Spanish)
Currently translated at 7.5% (14 of 185 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/es/
2026-05-27 06:28:40 +00:00
claudio001 b4cb606499 Translated using Weblate (Italian)
Currently translated at 4.8% (9 of 184 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/it/
2026-05-24 22:06:57 +02:00
CI/CD Bot by pstorch d1975685bc Update dependency com.google.android.material:material to v1.14.0 2026-05-13 15:34:36 +00:00
CI/CD Bot by pstorch 8cd9dda1f5 Update dependency gradle to v9.5.1 2026-05-12 14:36:37 +00:00
Dennis Guse c072967dd1 PlayStore: data retention seems to be a thing now. 2026-05-10 11:32:02 +02:00
Dennis Guse 94e91d2063 PlayStore: add existing privacy policy. 2026-05-10 11:32:02 +02:00
Kilian Lackhove 796fa216f6 added bosch cadence sensor 2026-05-09 21:46:44 +02:00
Dennis Guse f143aabfbf Release: v4.27.0 2026-05-08 23:10:06 +02:00
Kilian Lackhove d58342233c fix onSharedPreferenceChanged for cyclingPowerHandler 2026-05-08 17:15:39 +02:00
CI/CD Bot by pstorch 91c254ba7f Update dependency com.android.tools.build:gradle to v9.2.1 2026-05-05 14:35:54 +00:00
CI/CD Bot by pstorch 19edb47554 Update dependency gradle to v9.5.0 2026-04-28 12:36:29 +00:00
Kilian Lackhove 1129c96562 initial support for Bosch ebike BLE power sensor 2026-04-27 21:04:33 +02:00
Dennis Guse 7224efa8a9 Make tests less flacky 2026-04-27 19:20:16 +02:00
Dennis Guse 149f8f809e Revert "Update dependency com.android.tools.build:gradle to v9.2.0"
This reverts commit a94f023a17.
2026-04-27 19:14:49 +02:00
Marcatili, Manuel c2f59c11bf removed the mockito from build.gradle 2026-04-26 09:33:03 +02:00
Marcatili, Manuel 8b7d4fa813 Added explicit assertions to tests 2026-04-26 09:33:03 +02:00
Marcatili, Manuel 81081b0ee5 reduce code duplication and creation of more test utils methods 2026-04-26 09:33:03 +02:00
Marcatili, Manuel def4f3a1b9 Cleanup unused utils 2026-04-26 09:33:03 +02:00
Marcatili, Manuel b679f00cc5 Improve clicking behaviour 2026-04-26 09:33:03 +02:00
Marcatili, Manuel 40494557d4 Implement dynamic waits, setup methods, isolate gps tests 2026-04-26 09:33:03 +02:00
CI/CD Bot by pstorch a94f023a17 Update dependency com.android.tools.build:gradle to v9.2.0 2026-04-21 14:34:38 +00:00
Kilian Lackhove ee105a1610 Fix file import/export on debug builds
Debug builds added .debug to the actual applicationId, but the settings XML
used a flavor-level applicationId resource without that suffix, causing an
"Unable to find explicit activity class" exception. Solved by generating the
applicationId resource from each resolved variant and keeping debug and
nightly branding limited to the launcher label. Also added an Android test that
verifies the settings targets resolve on-device.
2026-04-19 12:46:29 +02:00
Peter Storch 3bec73e074 fix #2317 dashboard API updates 2026-04-19 10:02:23 +02:00
Dennis Guse 1155a56e49 Merge branch 'stickflowerfightclub-main' 2026-04-13 22:25:37 +02:00
stickflowerfightclub 9b7e858d1d Sensors: Fix excessive altitude gain by enforcing SAMPLING_PERIOD on internal barometer.
On some modern devices like Pixel 8 Pro, the internal pressure sensor ignores the requested sampling period in registerListener and sends data at high frequency (~50Hz). This floods the AltitudeChangeHandler with noise, causing absurdly high altitude gain (e.g., 38km for a flat run).
This commit adds a manual throttle in BarometerInternal to strictly respect the SAMPLING_PERIOD (5s), allowing the exponential smoothing to work as intended.
2026-04-13 20:00:30 +02:00
CI/CD Bot by pstorch d1490fc343 Update dependency com.android.tools.build:gradle to v9.1.1 2026-04-13 14:34:27 +00:00
stickflowerfightclub 3f6a4975fa Fix: Allow selection of internal sensors when Bluetooth is disabled 2026-04-12 10:27:19 +02:00
Kilian Lackhove b5408eabcd Pre-populate BLE devices list with already-bonded devices 2026-04-11 21:46:20 +02:00
Kilian Lackhove e1e34452ae fix gradle 2026-04-11 16:03:11 +02:00
ghose df121b40d7 Translated using Weblate (Galician)
Currently translated at 17.3% (32 of 184 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://translate.codeberg.org/projects/opentracks/app-store-metadata-files/gl/
2026-03-28 05:57:25 +00:00
North-DaCoder 4e737616cb Translated using Weblate (German)
Currently translated at 99.8% (544 of 545 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://translate.codeberg.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/de/
2026-03-28 05:57:25 +00:00
CI/CD Bot by pstorch fb963781af Update dependency androidx.work:work-runtime to v2.11.2 2026-03-25 17:34:24 +00:00
CI/CD Bot by pstorch 96339b6a36 Update dependency gradle to v9.4.1 2026-03-19 09:34:15 +00:00
147 changed files with 3160 additions and 829 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
@@ -27,7 +27,7 @@ jobs:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
+14
View File
@@ -8,6 +8,7 @@
* Polar H10
* Polar OH1
Has updatable firmware that requires a user account at polar.com
* Polar Loop Gen 2
* Wahoo Tickr (Model: WFBTHR02)
* Mi Band 3, Amazfit Band 5, Amazfit Bip. These devices are manufactured by Huami, and other Huami devices will very likely work. Theoretically, this sensor must be enabled, but testing reveals that it works even without enabling. For details on how to enable see [Gadgetbridge wiki](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Huami-Heartrate-measurement#bluetooth-heart-rate-sensor)
* Amazfit Neo. But first you have to enable "Discoverable" and "Activity heart rate sharing" in the official Zepp app. (after pairing with OpenTracks you can disable them again)
@@ -112,3 +113,16 @@ Here is a list of the smartphones tested:
| Google | Nexus 4 | LineageOS 18.1 | Ok |
| Samsung | S4 | Android 11 | Ok |
| Sony | Xperia XZ1 Compact | LineageOS 16.1 | Failure |
## Bosch Smart System eBike
OpenTracks also supports Bosch Smart System eBikes via Bosch's custom BLE service `00000010-eaa2-11e9-81b4-2a2ae2dbcce4`.
Tested with:
* Bosch Perfomance Line CX (SW-Version 16.9.0) / Kiox 300 (SW-Version 16.4.0)
Currently supported measurements:
* Human power
* Cadence
+22 -16
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.1.0'
classpath 'com.android.tools.build:gradle:9.3.1'
}
}
@@ -18,11 +18,11 @@ allprojects {
}
def getVersionName = { ->
return 'git describe --tags'.execute().text.trim()
return 'git describe --tags'.execute(null, project.rootDir).text.trim()
}
def getVersionCodeNightly = { ->
return Integer.valueOf('git rev-list HEAD --count'.execute().text.trim())
return Integer.valueOf('git rev-list HEAD --count'.execute(null, project.rootDir).text.trim())
}
android {
@@ -33,7 +33,7 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
compileSdk = 36
compileSdk = 37
buildFeatures {
viewBinding = true
@@ -47,13 +47,13 @@ android {
defaultConfig {
applicationId 'de.dennisguse.opentracks'
versionCode 6627
versionName "v4.26.2"
versionCode 6733
versionName "v4.28.1"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
minSdk = 26
targetSdk = 36
targetSdk = 37
testInstrumentationRunner 'de.dennisguse.opentracks.TestRunner'
testInstrumentationRunnerArguments clearPackageData: 'true'
@@ -107,7 +107,6 @@ android {
nightly {
dimension 'version'
applicationId 'de.dennisguse.opentracks.nightly'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.nightly'
versionCode getVersionCodeNightly()
versionName getVersionName()
@@ -119,7 +118,6 @@ android {
// Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/
dimension 'version'
applicationId 'de.dennisguse.opentracks'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks'
versionNameSuffix 'irreproducible'
}
reproducible {
@@ -128,7 +126,6 @@ android {
// PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore
dimension 'version'
applicationId 'de.dennisguse.opentracks.playstore'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.playstore'
base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
@@ -152,20 +149,29 @@ dependencies {
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.gridlayout:gridlayout:1.1.0'
implementation 'com.google.android.material:material:1.13.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.core:core:1.18.0'
implementation 'com.google.android.material:material:1.14.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
implementation 'androidx.core:core:1.19.0'
implementation 'androidx.core:core-splashscreen:1.2.0'
implementation 'androidx.mediarouter:mediarouter:1.8.1'
implementation 'androidx.core:core-location-altitude:1.0.0-rc01'
implementation 'androidx.work:work-runtime:2.11.1'
implementation 'androidx.core:core-location-altitude:1.0.0'
implementation 'androidx.work:work-runtime:2.11.2'
implementation 'androidx.health.connect:connect-client:1.1.0'
androidTestImplementation 'androidx.test:core:1.7.0'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:rules:1.7.0'
androidTestImplementation 'androidx.test:runner:1.7.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'org.mockito:mockito-android:5.23.0'
androidTestUtil 'androidx.test:orchestrator:1.6.1'
}
androidComponents {
onVariants(selector().all()) { variant ->
variant.resValues.put(
new com.android.build.api.variant.impl.ResValueKeyImpl("string", "applicationId"),
new com.android.build.api.variant.ResValue(variant.applicationId.get(), null)
)
}
}
@@ -0,0 +1,14 @@
v4.27.0: OpenTracks
Changes:
- Sensor: power from Bosch eBike
- Sensors: include already bonded Bluetooth devices (like Bosch eBike)
- Barometer: enforce sampling frequency
Bugfix:
- Dashboard API: did not provide update
- GPS could not be enabled with Bluetooth disabled
Developer:
- Removed Mockito
- UI tests: less flacky
@@ -0,0 +1,11 @@
v4.27.1: OpenTracks
Changes:
- Support for Bosch Smart System eBikes (cadence, human power)
- Add activity "gravel biking"
Bugfix:
- While recording: TrackRecordingService would not restart correctly if killed by Android
Developer:
- TargetSDK 37
@@ -0,0 +1,5 @@
v4.27.2: OpenTracks
Bugfix:
- Not all valid timezone offsets could be set
- Fixed race condition for IDLE trackpoints
@@ -0,0 +1,10 @@
v4.28.0: OpenTracks
Changes:
- Support for exporting to Health Connect
Bugfix:
- Dashboard API: when sharing multiple tracks, the currently recorded one wasn't updated
- Configuration setting for announcement of lap power wasn't stored correctly (only some languages affected)
@@ -0,0 +1,10 @@
v4.28.1: OpenTracks
Changes:
- HealthConnect: sending total distance, speed and elevation gain
Bugfix:
- Gravel activity was not selectable
Developer:
- Cleanup ContentProvider URLs
@@ -0,0 +1,17 @@
v3.0.1: OpenTracks
Cambios:
- Compartir marcadores
- Configuración reestructurada
- Se ha eliminado el botón «Atrás» de la barra de acciones
- Exportación: se ha eliminado la opción de configuración del tamaño de las fotos
- Exportación: ahora se puede seleccionar el formato KML
Correcciones de errores:
- El directorio de exportación es ahora /OpenTracks/
- El modo horizontal ya no provoca fallos
- Se ha corregido la exportación a KML/KMZ
Desarrollador:
- Las pruebas son ejecutables (pero muchas fallan)
- Se ha habilitado la integración continua con TravisCI (sin pruebas)
@@ -0,0 +1,14 @@
v3.1.0: OpenTracks
Cambios:
- Se agregó la capacidad de mostrar rutas en la aplicación de mapas (probado con OsmAnd y Maps.ME).
- Se ha añadido la posibilidad de visualizar las estadísticas de seguimiento sin desbloquear el dispositivo durante el mismo.
- Se ha eliminado el modo horizontal de la vista de detalles de la pista (debido a un error).
- El historial de sugerencias de búsqueda ha sido eliminado.
Corrección de errores:
- Solicitar permisos de almacenamiento para todas las importaciones/todas las exportaciones
Desarrolladores:
- Actualizar a Gradle 5.6
- No minimice la versión de lanzamiento
@@ -0,0 +1,16 @@
v3.2.0: OpenTracks
Cambios:
- Se ha eliminado el widget
- Se han incorporado iconos de Material Design
- Se han eliminado el snowboard y el esquí (se necesitan nuevos iconos)
Corrección de errores:
- Se ha corregido la funcionalidad de búsqueda
Desarrollador:
- Se han corregido (algunos) casos de prueba
- Se ha eliminado la funcionalidad «parcelable» en desuso
- Limpieza y reformateo a fondo
- No se utiliza la difusión para la comunicación entre la aplicación y el widget
- El archivo stats_content.xml no incluye otros archivos
@@ -0,0 +1,14 @@
v4.27.0: OpenTracks
Cambios:
- Sensor: potencia de la bicicleta eléctrica Bosch
- Sensores: se incluyen los dispositivos Bluetooth ya emparejados (como la bicicleta eléctrica Bosch)
- Barómetro: se aplica la frecuencia de muestreo
Corrección de errores:
- API del panel de control: no se proporcionaba la actualización
- No se podía activar el GPS con el Bluetooth desactivado
Desarrollador:
- Se ha eliminado Mockito
- Pruebas de la interfaz de usuario: menos inestables
@@ -0,0 +1,5 @@
v4.26.2: OpenTracks
Arranxos:
- (aínda) non hai compatibilidade co GPS vía Bluetooth
- non se mostraba a altitude tipo (EGM2008)
@@ -0,0 +1,5 @@
v4.26.2: OpenTracks
Correzioni:
- Il GPS via Bluetooth non è (ancora) supportato
- Il tipo di altitudine (EGM2008) non viene mostrato
@@ -1,11 +1,22 @@
OpenTracks é um aplicativo de rastreamento de esportes que respeita totalmente a sua privacidade.
Um companheiro de rastreamento esportivo que respeita sua privacidade.
Características:
* Rastreamento de esportes: monitore seu esporte e atividades ao ar livre
* Fotos e marcadores: marque locais interessantes durante o rastreamento
* Ganho e perda de altitude: via sensor barométrico
* Altitude mostrada no EGM2008 (acima do nível médio do mar); exportado como WGS84
* Anúncios de voz
* Suporta sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medidor de potência (ciclismo)
* Exportar dados: exporte trilhas como KMZ (incluindo fotos), KML, ou GPX
* Sem acesso à Internet: a Internet não é usada
Esportes e atividades ao ar livre garantem felicidade, habilidades e confiança.
Valoriza sua saúde acompanhando os seus treinos.
Grava enquanto você corre ou caminha e te entrega um computador de bicicleta com uma tela maior para o ciclismo.
Marca locais interessantes durante o percurso com fotos.
Deixa estatísticas registradas em grande detalhe para analises.
Compartilha apenas os dados que você quer que outros tenham.
* Anúncios por voz.
* Suporte para sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medição de potência (ciclismo).
* Ganhos e perdas de altitude: via sensor barométrico.
* Altitudes exibidas em EGM2008 (acima do nível médio do mar); exportado como WGS84.
* Exporta os dados de rastreio como KMZ (inclusive fotos), KML ou GPX.
* Sem acesso à internet ou permissões extra.
* Tema claro e escuro, respeitando as configurações do sistema.
* Sem propagandas.
Software Livre / Código Aberto
Significa que você pode usar, estudar, mudar e compartilhar o código.
Licença Apache 2.0
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+14
View File
@@ -0,0 +1,14 @@
<html>
<body>
<h1>OpenTracks: Privacy Policy</h1>
<p>
OpenTracks does only store data on the local device that is relevant for tracking your sport exercise.
Stored data is not transmitted from the app itself to a third party.
Data is stored by OpenTracks until:
1. the user deletes the recorded track or
2. the user uninstalls OpenTracks
</p>
</body>
</html>
@@ -2,76 +2,117 @@ package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
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.withParentIndex;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.renameStoppedTrack;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
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.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoDeleteTrackTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Test
public void espressoDeleteTrackTest() {
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
// TrackRecordingActivity
onView(withId(R.id.track_recording_fab_action))
// wait; stay recording
.perform(waitFor(5000))
// stop;
.perform(longClick());
// TrackStoppedActivity
onView(withId(R.id.finish_button))
.perform(click());
// select track
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0)))
.perform(longClick());
// open menu and delete selected track
onView(
allOf(withContentDescription("More options"),
childAtPosition(
childAtPosition(
withId(androidx.appcompat.R.id.action_mode_bar),
1),
2),
isDisplayed()))
.perform(click());
onView(withText("Delete"))
.perform(click());
onView(withText("OK"))
.perform(click());
// tracklist is empty now
onView(allOf(withText("Start recording your next adventure here"), isDisplayed()));
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@Test
public void deleteTrackFromTrackStoppedActivity() {
recordTrackAndOpenTrackStoppedActivity();
// TrackStoppedActivity: discard the just-recorded track directly
onView(withId(R.id.discard_button))
.perform(performClick());
onView(withText(android.R.string.ok))
.inRoot(isDialog())
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
// the recycler is empty again after discarding the only recorded track
onView(withId(R.id.track_list))
.check(matches(withRecyclerViewItemCount(0)));
}
@Test
public void deleteTrackFromSelectedTrackInTrackList() {
String uniqueTrackName = "DeleteFromTrackList-" + System.currentTimeMillis();
recordTrackAndOpenTrackStoppedActivity();
renameStoppedTrack(uniqueTrackName, UI_TIMEOUT);
onView(withId(R.id.finish_button))
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1, UI_TIMEOUT);
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
.perform(performLongClickOnAncestor());
clickDeleteSelectedTrackAction();
onView(withText(android.R.string.ok))
.inRoot(isDialog())
.perform(performClick());
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
onView(withId(R.id.track_list))
.check(matches(withRecyclerViewItemCount(0)));
}
private void recordTrackAndOpenTrackStoppedActivity() {
startRecordingFromTrackList(UI_TIMEOUT);
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.discard_button, R.id.finish_button);
}
private void clickDeleteSelectedTrackAction() {
try {
waitUntilDisplayed(withId(R.id.list_context_menu_delete), UI_TIMEOUT);
onView(withId(R.id.list_context_menu_delete))
.perform(performClick());
} catch (AssertionError ignored) {
onView(allOf(
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
isDescendantOfA(withId(androidx.appcompat.R.id.action_mode_bar))))
.perform(performClick());
onView(withText(R.string.menu_delete))
.inRoot(isPlatformPopup())
.perform(performClickOnAncestor());
}
}
}
@@ -2,34 +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.longClick;
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.RootMatchers.isPlatformPopup;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
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.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.Locale;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoEditTrackRecordingTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
@@ -39,27 +48,27 @@ public class EspressoEditTrackRecordingTest {
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@LargeTest
@Test
public void espressoEditTrackRecordingTest() {
{
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
startRecordingFromTrackList(UI_TIMEOUT);
{
// TrackRecordingActivity
// wait; stay recording
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(4000));
// open menu
openContextualActionModeOverflowMenu();
// Click the item.
onView(allOf(
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
isDescendantOfA(withId(R.id.bottom_app_bar))))
.perform(performClick());
onView(withText(R.string.menu_edit))
.perform(click());
.inRoot(isPlatformPopup())
.perform(performClickOnAncestor());
waitUntilDisplayed(withId(R.id.track_edit_name), UI_TIMEOUT);
// change name for "New Name"
onView(withId(R.id.track_edit_name))
@@ -70,11 +79,11 @@ public class EspressoEditTrackRecordingTest {
// save edition
onView(withId(R.id.track_edit_save))
.perform(click());
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_recording_fab_action), UI_TIMEOUT);
// stop;
onView(withId(R.id.track_recording_fab_action))
.perform(longClick());
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button);
// it's on track stopped activity and there are two buttons
onView(withId(R.id.resume_button))
@@ -1,100 +1,141 @@
package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.withSelectedTab;
import static de.dennisguse.opentracks.util.EspressoUtils.withViewPagerCurrentItem;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.finishStoppedTrack;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.resumeStoppedTrack;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.ViewInteraction;
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.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.data.models.TrackPoint;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoUITest {
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
private final ActivityScenarioRule<TrackListActivity> activityRule = new ActivityScenarioRule<>(TrackListActivity.class);
private final GrantPermissionRule grantPermissionRule = TestUtil.createGrantPermissionRule();
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
public RuleChain rules = RuleChain.outerRule(grantPermissionRule)
.around(activityRule);
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@LargeTest
@Test
public void record_stop_resume_stop_finish() {
startRecordingFromTrackList(UI_TIMEOUT);
{
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{
// TrackRecordingActivity: wait to record some time and then stop
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(5000))
.perform(longClick());
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button);
resumeStoppedTrack(UI_TIMEOUT);
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button);
finishStoppedTrack(UI_TIMEOUT);
// TrackStoppedActivity: resume
onView(allOf(withId(R.id.resume_button), isClickable()))
.perform(click());
// TrackRecordingActivity: wait and then stop
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(5000))
.perform(longClick());
// TrackStoppedActivity
onView(withId(R.id.finish_button))
.perform(click());
onView(withId(R.id.track_list))
.check(matches(isDisplayed()));
}
}
@LargeTest
@Test
public void record_move_through_tabs() {
{
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
startRecordingFromTrackList(UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout), UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager), UI_TIMEOUT);
{
// TrackRecordingActivity
ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout));
tabLayout.perform(selectTabAtIndex(1));
tabLayout.perform(waitFor(1000));
waitUntilTabSelectionSettlesAt(1);
tabLayout.perform(selectTabAtIndex(2));
tabLayout.perform(waitFor(1000));
waitUntilTabSelectionSettlesAt(2);
tabLayout.perform(selectTabAtIndex(3));
tabLayout.perform(waitFor(1000));
waitUntilTabSelectionSettlesAt(3);
tabLayout.perform(selectTabAtIndex(0));
tabLayout.perform(waitFor(1000));
waitUntilTabSelectionSettlesAt(0);
// stop
onView(withId(R.id.track_recording_fab_action)).
perform(longClick());
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button);
onView(withId(R.id.resume_button))
.check(matches(isDisplayed()));
onView(withId(R.id.finish_button))
.check(matches(isDisplayed()));
}
}
@LargeTest
@Test
public void selectAndDeleteTrack() {
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 5);
String uniqueTrackName = "SelectAndDeleteTrack-" + System.currentTimeMillis();
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
trackBuilder.setName(uniqueTrackName);
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
activityRule.getScenario().recreate();
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
onView(withId(R.id.track_list)).check(matches(isDisplayed()));
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))).perform(longClick());
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
.perform(performLongClickOnAncestor());
onView(withId(androidx.appcompat.R.id.action_mode_bar))
.check(matches(isDisplayed()));
}
private void waitUntilTabSelectionSettlesAt(int index) {
waitForResumedViewCondition(
withId(R.id.track_detail_activity_tablayout),
view -> withSelectedTab(index).matches(view),
"selected tab to be " + index,
UI_TIMEOUT);
waitForResumedViewCondition(
withId(R.id.track_detail_activity_view_pager),
view -> withViewPagerCurrentItem(index).matches(view),
"ViewPager current item to be " + index,
UI_TIMEOUT);
}
}
@@ -20,22 +20,19 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.when;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.io.File;
import java.io.IOException;
@@ -78,7 +75,6 @@ import de.dennisguse.opentracks.util.FileUtils;
* @author Bartlomiej Niechwiej
* @author Youtao Liu
*/
@RunWith(MockitoJUnitRunner.class)
public class CustomContentProviderUtilsTest {
private static final String NAME_PREFIX = "test name";
@@ -90,12 +86,6 @@ public class CustomContentProviderUtilsTest {
private final Context context = ApplicationProvider.getApplicationContext();
private ContentProviderUtils contentProviderUtils;
@Mock
private Cursor cursorMock;
@Mock
private ContentResolver contentResolverMock;
@Before
public void setUp() {
contentProviderUtils = new ContentProviderUtils(context);
@@ -104,21 +94,21 @@ public class CustomContentProviderUtilsTest {
@Test
public void testLocationIterator_noPoints() {
testIterator(new Track.Id(1), 0);
assertEquals(0, testIterator(new Track.Id(1), 0).size());
}
@Test
public void testLocationIterator_noAscending() {
testIterator(new Track.Id(1), 50);
testIterator(new Track.Id(2), 50);
assertEquals(50, testIterator(new Track.Id(1), 50).size());
assertEquals(50, testIterator(new Track.Id(2), 50).size());
}
@Test
public void testLocationIterator_largeTrack() {
testIterator(new Track.Id(1), 20000 / 2);
assertEquals(20000 / 2, testIterator(new Track.Id(1), 20000 / 2).size());
}
private void testIterator(Track.Id trackId, int numPoints) {
private List<TrackPoint> testIterator(Track.Id trackId, int numPoints) {
TrackPoint.Id lastPointId = initializeTrack(trackId, numPoints);
List<TrackPoint> locations = new ArrayList<>(numPoints);
try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) {
@@ -131,6 +121,7 @@ public class CustomContentProviderUtilsTest {
}
assertEquals(numPoints, locations.size());
}
return locations;
}
private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) {
@@ -187,40 +178,62 @@ public class CustomContentProviderUtilsTest {
@Test
public void testCreateTrack() {
Track.Id trackId = new Track.Id(System.currentTimeMillis());
int columnIndex = 1;
// Id
when(cursorMock.getColumnIndexOrThrow(TracksColumns._ID)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getLong(columnIndex)).thenReturn(trackId.id());
//Uuid
columnIndex++;
when(cursorMock.getColumnIndexOrThrow(TracksColumns.UUID)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getBlob(columnIndex)).thenReturn(UUIDUtils.toBytes(UUID.randomUUID()));
// Name
columnIndex++;
String name = NAME_PREFIX + trackId.id();
when(cursorMock.getColumnIndexOrThrow(TracksColumns.NAME)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getString(columnIndex)).thenReturn(name);
Track track = ContentProviderUtils.createTrack(cursorMock);
MatrixCursor cursor = new MatrixCursor(new String[]{
TracksColumns._ID,
TracksColumns.UUID,
TracksColumns.NAME,
TracksColumns.DESCRIPTION,
TracksColumns.ACTIVITY_TYPE,
TracksColumns.ACTIVITY_TYPE_LOCALIZED,
TracksColumns.STARTTIME,
TracksColumns.STARTTIME_OFFSET,
TracksColumns.STOPTIME,
TracksColumns.TOTALDISTANCE,
TracksColumns.TOTALTIME,
TracksColumns.MOVINGTIME,
TracksColumns.MAXSPEED,
TracksColumns.MIN_ALTITUDE,
TracksColumns.MAX_ALTITUDE,
TracksColumns.ALTITUDE_GAIN,
TracksColumns.ALTITUDE_LOSS,
});
cursor.addRow(new Object[]{
trackId.id(),
UUIDUtils.toBytes(UUID.randomUUID()),
name,
"",
ActivityType.UNKNOWN.getId(),
"",
1_000L,
ZoneOffset.UTC.getTotalSeconds(),
2_000L,
123.45f,
1_000L,
500L,
12.34f,
null,
null,
null,
null,
});
assertTrue(cursor.moveToFirst());
Track track = ContentProviderUtils.createTrack(cursor);
assertEquals(trackId, track.id());
assertEquals(name, track.name());
}
private void assertCount(int trackCount, int trackPointCount, int markerCount) {
ContentResolver contentResolver = context.getContentResolver();
try (Cursor tracksCursor = contentResolver.query(TracksColumns.CONTENT_URI, null, null, null, TracksColumns._ID)) {
try (Cursor tracksCursor = contentResolver.query(CustomContentProvider.UrlType.TRACKS.getUri(), null, null, null, TracksColumns._ID)) {
assertEquals(trackCount, tracksCursor.getCount());
}
try (Cursor tracksPointsCursor = contentResolver.query(TrackPointsColumns.CONTENT_URI_BY_ID, null, null, null, TrackPointsColumns._ID)) {
try (Cursor tracksPointsCursor = contentResolver.query(CustomContentProvider.UrlType.TRACKPOINTS_BY_ID.getUri(), null, null, null, TrackPointsColumns._ID)) {
assertEquals(trackPointCount, tracksPointsCursor.getCount());
}
try (Cursor markerCursor = contentResolver.query(MarkerColumns.CONTENT_URI, null, null, null, MarkerColumns._ID)) {
try (Cursor markerCursor = contentResolver.query(CustomContentProvider.UrlType.MARKERS.getUri(), null, null, null, MarkerColumns._ID)) {
assertEquals(markerCount, markerCursor.getCount());
}
}
@@ -449,8 +462,6 @@ public class CustomContentProviderUtilsTest {
marker.setDescription(TEST_DESC);
contentProviderUtils.insertMarker(marker.getMarker());
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(contentResolverMock);
Marker.Id markerId = new Marker.Id(System.currentTimeMillis());
marker.setId(markerId);
ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId);
@@ -1184,7 +1195,7 @@ public class CustomContentProviderUtilsTest {
assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0f);
}
private void testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) {
private Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) {
// given
Instant start = Instant.now();
TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil();
@@ -1207,22 +1218,27 @@ public class CustomContentProviderUtilsTest {
SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId);
TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats();
// then
assertEquals(sensorStatistics.avgHeartRate().getBPM(), stats.avgHr, 0.01f);
assertEquals(sensorStatistics.maxHeartRate().getBPM(), stats.maxHr, 0.01f);
assertEquals(sensorStatistics.avgCadence().getRPM(), stats.avgCadence, 0.01f);
assertEquals(sensorStatistics.maxCadence().getRPM(), stats.maxCadence, 0.01f);
assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0.01f);
return Pair.create(sensorStatistics, stats);
}
@Test
public void testGetSensorStats_veryLongActivity12h() {
testGetSensorStats_randomData(43200 / 6, false);
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(43200 / 6, false);
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
}
@Test
public void testGetSensorStats_withSeveralRandomStartSegments() {
testGetSensorStats_randomData(5000, true);
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(5000, true);
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
}
private TrackPoint getLastValidTrackPoint(Track.Id trackId) {
@@ -0,0 +1,137 @@
package de.dennisguse.opentracks.sensors.driver;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import org.junit.Test;
public class BoschEbikeParserTest {
@Test
public void parse_multipleFrames_returnsCombinedMeasurements() {
byte[] raw = new byte[] {
// Bosch cadence frame
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76,
// Bosch human power frame
0x30, 0x05, (byte) 0x98, 0x5B, 0x08, (byte) 0x83, 0x01,
// Bosch speed frame
0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0x94, 0x0B, 0x10, 0x01
};
BoschEbikeParser.Data data = BoschEbikeParser.parse(raw);
assertNotNull(data);
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
assertEquals(131.0f, data.humanPower().getW(), 0.01f);
assertEquals(14.28 / 3.6, data.speed().toMPS(), 0.0001);
}
@Test
public void parse_multiByteVarint_returnsScaledSpeed() {
// Bosch speed frame using a multi-byte varint
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0xE1, 0x0E, 0x10, 0x01
});
assertNotNull(data);
assertEquals(18.89 / 3.6, data.speed().toMPS(), 0.0001);
}
@Test
public void parse_zeroValueFrame_returnsZeroCadence() {
// Bosch zero-value cadence frame
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x02, (byte) 0x98, 0x5A
});
assertNotNull(data);
assertEquals(0.0f, data.cadence().getRPM(), 0.01f);
assertNull(data.speed());
assertNull(data.humanPower());
}
@Test
public void parse_unsupportedMeasurementsOnly_returnsNull() {
// Bosch frame for message 0x9865, which the parser does not map to a measurement.
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x02, (byte) 0x98, 0x65
});
assertNull(data);
}
@Test
public void parse_unimplementedMeasurementsOnly_returnsNull() {
// Bosch motor power frame: message 0x985D with value 0x42, currently ignored.
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x04, (byte) 0x98, 0x5D, 0x08, 0x42
});
assertNull(data);
}
@Test
public void parse_malformedVarint_returnsNull() {
// Truncated Bosch speed frame: starts like a real 0x982D frame but the varint is incomplete.
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94
});
assertNull(data);
}
@Test
public void parse_varintLargerThan32Bit_returnsNull() {
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
0x30, 0x08, (byte) 0x98, 0x2D, 0x08,
(byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, 0x01
});
assertNull(data);
}
@Test
public void parse_validMeasurementBeforeMalformedFrame_returnsValidMeasurement() {
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
// Bosch cadence frame: message 0x985A with value 0x76
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76,
// Truncated Bosch speed frame: malformed trailing frame should not discard the valid cadence above.
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94
});
assertNotNull(data);
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
assertNull(data.speed());
}
@Test
public void parse_malformedVarintDoesNotConsumeNextFrameStartByte() {
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
// Truncated Bosch speed frame: the varint continuation bit is set, but the frame ends
// before the next varint byte. The following frame start byte must not be consumed.
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94,
// Bosch cadence frame: message 0x985A with value 0x76
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76
});
assertNotNull(data);
assertNull(data.speed());
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
}
@Test
public void parse_leadingNoise_ignored() {
byte[] raw = new byte[] {
// Noise bytes to verify the parser resynchronizes on the Bosch frame start byte 0x30.
0x01, 0x02, 0x03,
// Bosch cadence frame: message 0x985A with value 0x76
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76
};
BoschEbikeParser.Data data = BoschEbikeParser.parse(raw);
assertNotNull(data);
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
}
}
@@ -1,6 +1,7 @@
package de.dennisguse.opentracks.sensors.driver;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import android.bluetooth.BluetoothGattCharacteristic;
@@ -35,4 +36,27 @@ public class CyclingPowerBluetoothTest {
assertEquals(17125, powerCadence.crank().crankRevolutionsTime());
}
}
@Test
public void parsePayload_boschPower() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
// Bosch human power frame: message 0x985B with value 0x7B => 123 W.
characteristic.setValue(new byte[] { 0x30, 0x04, (byte) 0x98, 0x5B, 0x08, 0x7B });
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
assertEquals(123, power.power().getW(), 0.01);
assertNull(power.crank());
}
@Test
public void parsePayload_boschCadenceOnly_returnsNull() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
// Bosch cadence-only frame: message 0x985A with value 0x01B8 => 220 / 2 = 110 rpm, so power parsing should ignore it.
characteristic.setValue(new byte[] { 0x30, 0x05, (byte) 0x98, 0x5A, 0x08, (byte) 0xB8, 0x01 });
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
assertNull(power);
}
}
@@ -96,6 +96,25 @@ public class SensorDataCyclingTest {
assertNull(current.getAggregatedValue(Instant.MIN));
}
@Test
public void compute_cadence_directCadence_withoutPrevious() {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
}
@Test
public void compute_cadence_directCadence_winsOverCrankData() {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1L, 1024));
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
}
@Test
public void compute_speed() {
AggregatorCyclingDistanceSpeed current = new AggregatorCyclingDistanceSpeed("", "");
@@ -1,24 +1,24 @@
package de.dennisguse.opentracks.services;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.junit.Assert.assertEquals;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
@@ -29,32 +29,24 @@ import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.settings.UnitSystem;
@RunWith(MockitoJUnitRunner.class)
public class TrackRecordingServiceNotificationManagerTest {
private static final String CHANNEL_ID = "TrackRecordingServiceNotificationManagerTest";
private final Context context = ApplicationProvider.getApplicationContext();
private final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
@Mock
private NotificationCompat.Builder notificationCompatBuilder;
@Mock
private NotificationManager notificationManager;
@After
public void tearDown() {
notificationManager.cancelAll();
}
@Test
public void updateLocation_triggersAlertOnlyOnFirstInaccurateLocation() {
TrackPoint trackPointMock = new TrackPoint(TrackPoint.Type.TRACKPOINT,
new Position(
null,
null, null, Distance.of(999f),
Altitude.WGS84.of(10), null,
null,
Speed.ZERO)
);
notificationManager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, CHANNEL_ID, NotificationManager.IMPORTANCE_DEFAULT));
when(notificationCompatBuilder.setOnlyAlertOnce(anyBoolean()))
.thenReturn(notificationCompatBuilder);
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationCompatBuilder);
TrackingNotificationBuilder notificationBuilder = new TrackingNotificationBuilder(context, CHANNEL_ID);
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationBuilder);
subject.setUnitSystem(UnitSystem.METRIC);
Track track = new Track(
@@ -68,16 +60,43 @@ public class TrackRecordingServiceNotificationManagerTest {
Statistics.DEFAULT
);
RecordingData data = new RecordingData(track, trackPointMock, null, null);
// when
subject.updateTrackPoint(context, data, Distance.of(100));
subject.updateTrackPoint(context, data, Distance.of(100));
subject.updateTrackPoint(context, data, Distance.of(1000));
subject.updateTrackPoint(context, data, Distance.of(100));
// then
verify(notificationCompatBuilder, times(6)).setOnlyAlertOnce(true);
verify(notificationCompatBuilder, times(2)).setOnlyAlertOnce(false);
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(1000));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
assertEquals(java.util.Arrays.asList(false, true, true, true, true, true, false, true), notificationBuilder.onlyAlertOnceValues);
}
}
private RecordingData createRecordingData(Track track, Distance horizontalAccuracy) {
TrackPoint trackPoint = new TrackPoint(
TrackPoint.Type.TRACKPOINT,
new Position(
null,
null,
null,
horizontalAccuracy,
Altitude.WGS84.of(10),
null,
null,
Speed.ZERO
)
);
return new RecordingData(track, trackPoint, null, null);
}
private static final class TrackingNotificationBuilder extends NotificationCompat.Builder {
private final List<Boolean> onlyAlertOnceValues = new ArrayList<>();
private TrackingNotificationBuilder(Context context, String channelId) {
super(context, channelId);
setSmallIcon(R.drawable.ic_logo_color_24dp);
}
@NonNull
@Override
public NotificationCompat.Builder setOnlyAlertOnce(boolean onlyAlertOnce) {
onlyAlertOnceValues.add(onlyAlertOnce);
return super.setOnlyAlertOnce(onlyAlertOnce);
}
}
}
@@ -2,6 +2,7 @@ package de.dennisguse.opentracks.services;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
@@ -93,6 +94,41 @@ public class TrackRecordingServiceRecordingTest {
service = startService();
}
@MediumTest
@Test
public void reCreate_not_recording() {
// given
TrackIdStorage.unset(context);
assertFalse(service.isRecording());
// when
service.onStartCommand(null, 0, 1);
// then
assertFalse(service.isRecording());
}
@MediumTest
@Test
public void reCreate_recording() {
// given
assertFalse(service.isRecording());
Track.Id trackId = service.startNewTrack();
assertTrue(service.isRecording());
// when (crash and restart)
service.onDestroy();
service.onCreate();
service.onStartCommand(null, 0, 1);
// then
assertTrue(service.isRecording());
assertEquals(trackId, service.getRecordingStatusObservable().getValue().trackId());
service.endCurrentTrack();
}
@MediumTest
@Test
public void recording_startStop() {
@@ -15,6 +15,7 @@
*/
package de.dennisguse.opentracks.services;
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@@ -41,6 +42,7 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -56,6 +58,8 @@ import de.dennisguse.opentracks.sensors.GpsStatusValue;
@RunWith(AndroidJUnit4.class)
public class TrackRecordingServiceStateMachineTest {
private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(2);
@Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
@@ -123,6 +127,7 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.tryStartSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
@@ -131,6 +136,7 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.stopSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
@@ -146,6 +152,9 @@ public class TrackRecordingServiceStateMachineTest {
// when
Track.Id trackId = service.startNewTrack();
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilRecordingDataIsNotDefault();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -154,9 +163,13 @@ public class TrackRecordingServiceStateMachineTest {
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// when
service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -167,6 +180,8 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then
assertFalse(service.isRecording());
@@ -177,6 +192,8 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -254,4 +271,25 @@ public class TrackRecordingServiceStateMachineTest {
assertNotNull(trackId);
assertNull(newTrackId);
}
private void waitUntilRecordingDataIsNotDefault() {
waitForValue(service.getRecordingDataObservable(),
recordingData -> !RecordingData.NOT_RECORDING.equals(recordingData),
WAIT_TIMEOUT,
"recording data to leave NOT_RECORDING");
}
private void waitUntilGpsStatusIs(GpsStatusValue expected) {
waitForValue(service.getGpsStatusObservable(),
expected::equals,
WAIT_TIMEOUT,
"gps status to become " + expected);
}
private void waitUntilRecordingStatusIs(RecordingStatus expected) {
waitForValue(service.getRecordingStatusObservable(),
expected::equals,
WAIT_TIMEOUT,
"recording status to become " + expected);
}
}
@@ -2,12 +2,12 @@ package de.dennisguse.opentracks.services;
import static org.junit.Assert.assertEquals;
import static de.dennisguse.opentracks.services.TrackRecordingService.RECORDING_DATA_UPDATE_INTERVAL;
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import androidx.core.location.LocationManagerCompat;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.GrantPermissionRule;
@@ -30,6 +30,7 @@ import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.io.file.importer.ExportImportTest;
import de.dennisguse.opentracks.io.file.importer.TrackPointAssert;
import de.dennisguse.opentracks.sensors.GpsHandler;
import de.dennisguse.opentracks.sensors.driver.GpsInternal;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
@@ -51,7 +52,7 @@ public class TrackRecordingServiceUITest {
}
@Test
public void getDataForUI_gps() throws TimeoutException, InterruptedException {
public void getDataForUI_gps() throws TimeoutException {
TrackRecordingService service = ((TrackRecordingService.Binder) mServiceRule.bindService(new Intent(context, TrackRecordingService.class)))
.getService();
RecordingData recordingData;
@@ -63,34 +64,23 @@ public class TrackRecordingServiceUITest {
// when
service.startNewTrack();
//We do not want the real GPS to interfere.
GpsInternal gpsInternal = (GpsInternal)trackPointCreator.getSensorManager().getGpsHandler().driver;
LocationManagerCompat.removeUpdates(gpsInternal.locationManager, gpsInternal.locationListenerCompat);
Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis());
disconnectRealGps(trackPointCreator.getSensorManager().getGpsHandler());
trackPointCreator.getSensorManager().reset();
RecordingData initialRecordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:02Z"), false);
// then
assertEquals(new TrackPoint(
null,
TrackPoint.Type.TRACKPOINT,
Position.of( Instant.parse("2020-02-02T02:02:02Z")),
null,
null,
null,
null,
null,
null
),
service.getRecordingDataObservable().getValue().latestTrackPoint());
TrackPoint initialTrackPoint = initialRecordingData.latestTrackPoint();
assertEquals(TrackPoint.Type.TRACKPOINT, initialTrackPoint.type());
assertEquals(Instant.parse("2020-02-02T02:02:02Z"), initialTrackPoint.getTime());
// when
// In this step, the data is saved (and Aggregator.resetAggregated called), but data should still be shown in the UI.
trackPointCreator.setClock("2020-02-02T02:02:03Z");
ExportImportTest.sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
recordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:03Z"), true);
// then
Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis());
recordingData = service.getRecordingDataObservable().getValue();
new TrackPointAssert().assertEquals(new TrackPoint(
null,
TrackPoint.Type.TRACKPOINT,
@@ -109,4 +99,23 @@ public class TrackRecordingServiceUITest {
),
recordingData.latestTrackPoint());
}
}
private void disconnectRealGps(GpsHandler gpsHandler) {
if (gpsHandler.driver != null) {
gpsHandler.driver.disconnect();
}
gpsHandler.onConnected(null, GpsInternal.LOCATION_PROVIDER);
}
private RecordingData waitUntilLatestTrackPointMatches(TrackRecordingService service, Instant expectedTime, boolean requiresLocation) {
return waitForValue(service.getRecordingDataObservable(),
recordingData -> {
TrackPoint latestTrackPoint = recordingData != null ? recordingData.latestTrackPoint() : null;
return latestTrackPoint != null
&& expectedTime.equals(latestTrackPoint.getTime())
&& (!requiresLocation || latestTrackPoint.position().hasLocation());
},
RECORDING_DATA_UPDATE_INTERVAL.multipliedBy(3),
"latest track point to reach " + expectedTime + (requiresLocation ? " with location" : ""));
}
}
@@ -52,6 +52,7 @@ public class VoiceAnnouncementUtilsTest {
@Before
public void setUp() {
PreferencesUtils.setVoiceAnnounceUnit(true);
PreferencesUtils.setVoiceAnnounceHeartRateCurrent(false);
PreferencesUtils.setVoiceAnnounceLapHeartRate(false);
PreferencesUtils.setVoiceAnnounceAverageHeartRate(false);
@@ -221,6 +222,48 @@ public class VoiceAnnouncementUtilsTest {
assertEquals("12:00 AM. Total distance 19.9 kilometers. 1 hour. Average moving speed 19.9 kilometers per hour.", announcement);
}
@Test
public void getAnnouncement_metric_distance_without_unit() {
PreferencesUtils.setVoiceAnnounceUnit(false);
Track track = new Track(
null,
null,
"",
"",
"",
ActivityType.AIRPLANE,
ZoneOffset.UTC,
new Statistics(
Instant.EPOCH,
Instant.EPOCH,
Duration.ofHours(2).plusMinutes(5).plusSeconds(10),
Duration.ofHours(1),
Distance.of(19900),
Speed.of(100),
null,
new AltitudeGainLoss(6000, 6000),
null,
null
));
SensorDataSet dataSet = new SensorDataSet(
null,
null,
null,
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
);
// when
String announcement = VoiceAnnouncementUtils.createStatistics(context, track, dataSet, UnitSystem.METRIC, true, null, null).toString();
// then
assertEquals("12:00 AM. Total distance 19.9. 1 hour. Average moving speed 19.9.", announcement);
}
@Test
public void getAnnouncement_withInterval_metric_speed() {
// given
@@ -556,6 +599,7 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapHeartRate(true);
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -573,7 +617,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then
assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement);
}
@Test
@@ -589,6 +633,7 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapSpeedPace(false);
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -606,7 +651,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement);
}
@Test
@@ -0,0 +1,51 @@
package de.dennisguse.opentracks.settings;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.R;
@RunWith(AndroidJUnit4.class)
public class ImportExportSettingsIntentTest {
private final Context context = ApplicationProvider.getApplicationContext();
@Test
public void applicationIdResource_matchesInstalledPackage() {
assertEquals(BuildConfig.APPLICATION_ID, context.getString(R.string.applicationId));
assertEquals(context.getPackageName(), context.getString(R.string.applicationId));
}
@Test
public void importExportPreferenceTargets_areResolvableInCurrentPackage() throws PackageManager.NameNotFoundException {
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity");
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserActivity");
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserOneFileActivity");
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$DefaultTrackExportDirectoryChooserActivity");
}
private void assertResolvable(String activityClassName) throws PackageManager.NameNotFoundException {
String applicationId = context.getString(R.string.applicationId);
ComponentName componentName = new ComponentName(applicationId, activityClassName);
ActivityInfo activityInfo = context.getPackageManager().getActivityInfo(componentName, 0);
assertEquals(applicationId, activityInfo.packageName);
ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(new Intent().setComponent(componentName), 0);
assertNotNull(resolveInfo);
}
}
@@ -1,36 +1,41 @@
package de.dennisguse.opentracks.ui.aggregatedStatistics;
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.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
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 de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.stopTrackRecordingService;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.util.Pair;
import android.view.View;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.matcher.ViewMatchers;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.core.IsInstanceOf;
import org.hamcrest.Matcher;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TestUtil;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
@@ -41,53 +46,69 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
@RunWith(AndroidJUnit4.class)
public class EspressoAggregatedFilterTest {
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
private ActivityScenario<AggregatedStatisticsActivity> scenario;
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(androidx.test.core.app.ApplicationProvider.getApplicationContext());
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20);
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED);
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
TestDataUtil.insertTrackWithLocations(contentProviderUtils, trackBuilder.getTrack(), pair.second);
scenario = ActivityScenario.launch(AggregatedStatisticsActivity.class);
}
@After
public void tearDown() {
stopTrackRecordingService();
if (scenario != null) {
scenario.close();
}
}
@Test
public void espressoAggregatedFilterTest() {
// open AggregatedStatisticsActivity through toolbar's menu item
onView(ViewMatchers.withId(R.id.bottom_app_bar))
.check(matches(isDisplayed()));
onView(withId(R.id.bottom_app_bar))
.check(matches(hasDescendant(withId(R.id.aggregated_stats_button))));
onView(withId(R.id.aggregated_stats_button))
.perform(click());
waitUntilDisplayed(withId(R.id.bottom_app_bar), UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.aggregated_statistics_filter), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1, UI_TIMEOUT);
waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED)), UI_TIMEOUT);
// open FilterDialogFragment through toolbar's menu item
// open FilterDialogFragment through the current bottom app bar action item
onView(withId(R.id.bottom_app_bar))
.check(matches(isDisplayed()));
onView(withId(R.id.bottom_app_bar))
.check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter))));
onView(withId(R.id.aggregated_statistics_filter))
.perform(click());
.perform(performClick());
// check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text
onView(allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED),
Matcher<View> activityTypeFilter = allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED),
withParent(allOf(withId(R.id.filter_items),
withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))),
isDisplayed()))
.perform(waitFor(2000));
isDisplayed());
onView(activityTypeFilter)
.inRoot(isDialog())
.check(matches(isDisplayed()));
// check there's an edit text for "from date"
onView(withId(R.id.filter_date_edit_text_from))
.inRoot(isDialog())
.check(matches(isDisplayed()));
// check there's an edit text for "to date"
onView(withId(R.id.filter_date_edit_text_to))
.inRoot(isDialog())
.check(matches(isDisplayed()));
}
}
@@ -63,8 +63,8 @@ public class RecordingLayoutTest extends TestCase {
RecordingLayout recordingLayout = RecordingLayoutIO.fromCsvLine(csv1, resources);
// then layout and data fields are built correctly
assertEquals(recordingLayout.getName(), "Default Layout");
assertEquals(recordingLayout.getColumnsPerRow(), 2);
assertEquals("Default Layout", recordingLayout.getName());
assertEquals(2, recordingLayout.getColumnsPerRow());
assertEquals(PreferencesUtils.getDefaultLayout().getFields(), recordingLayout.getFields());
}
@@ -78,8 +78,8 @@ public class RecordingLayoutTest extends TestCase {
List<DataField> dataFieldList = recordingLayout.getFields();
// then layout and data fields are built correctly
assertEquals(recordingLayout.getName(), "Default Layout");
assertEquals(recordingLayout.getColumnsPerRow(), 2);
assertEquals("Default Layout", recordingLayout.getName());
assertEquals(2, recordingLayout.getColumnsPerRow());
assertEquals(PreferencesUtils.getDefaultLayout().getFields(), recordingLayout.getFields());
}
@@ -144,6 +144,6 @@ public class RecordingLayoutTest extends TestCase {
String csv = recordingLayout.toCsv();
// then csv is well built
assertEquals(csv, "Test Layout;2;key1,0,0,0;key2,0,1,0;key3,1,0,0;key4,1,1,0;key5,1,1,1;");
assertEquals("Test Layout;2;key1,0,0,0;key2,0,1,0;key3,1,0,0;key4,1,1,0;key5,1,1,1;", csv);
}
}
@@ -35,7 +35,10 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
whenAndThen(50, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(50, distanceInterval);
assertIntervalStatisticsComputation(computation, 50, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
/**
@@ -49,7 +52,10 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
whenAndThen(200, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(200, distanceInterval);
assertIntervalStatisticsComputation(computation, 200, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
/**
@@ -63,7 +69,10 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f;
// when and then
whenAndThen(3000, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(3000, distanceInterval);
assertIntervalStatisticsComputation(computation, 3000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
/**
@@ -77,7 +86,10 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f;
// when and then
whenAndThen(1000, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(1000, distanceInterval);
assertIntervalStatisticsComputation(computation, 1000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
/**
@@ -91,7 +103,10 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
whenAndThen(10000, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(10000, distanceInterval);
assertIntervalStatisticsComputation(computation, 10000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
@Test
@@ -122,15 +137,18 @@ public class IntervalStatisticsUpdaterTest {
Pair<Track, List<TrackPoint>> trackWithStats = new Pair<>(dummyTrack, trackPoints);
// when and then
whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
IntervalStatisticsComputation computation = computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval);
assertIntervalStatisticsComputation(computation, numberOfPoints, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
}
private void whenAndThen(int numberOfPoints, float distanceInterval) {
private IntervalStatisticsComputation computeIntervalStatistics(int numberOfPoints, float distanceInterval) {
Pair<Track, List<TrackPoint>> trackWithStats = buildTrackWithTrackPoints(numberOfPoints);
whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
return computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval);
}
private void whenAndThen(Track track, List<TrackPoint> trackPoints, int numberOfPoints, float distanceInterval) {
private IntervalStatisticsComputation computeIntervalStatistics(Track track, List<TrackPoint> trackPoints, float distanceInterval) {
IntervalStatisticsUpdater intervalStatistics = new IntervalStatisticsUpdater(Distance.of(distanceInterval));
intervalStatistics.addTrackPoints(trackPoints.iterator());
@@ -157,23 +175,27 @@ public class IntervalStatisticsUpdaterTest {
}
}
// then
assertEquals(track.statistics().totalDuration().toSeconds(), totalTime, 0.01);
assertEquals(track.statistics().totalDistance().toM(), totalDistance.toM(), 0.01);
assertEquals(intervalList.size(), (int) Math.ceil(track.statistics().totalDistance().toM() / distanceInterval));
if (totalGain != null && totalLoss != null) {
assertEquals(totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
assertEquals(totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
return new IntervalStatisticsComputation(track, intervalList, totalDistance, totalTime, totalGain, totalLoss);
}
private void assertIntervalStatisticsComputation(IntervalStatisticsComputation computation, int numberOfPoints, float distanceInterval) {
assertEquals(computation.track.statistics().totalDuration().toSeconds(), computation.totalTime, 0.01);
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
assertEquals(computation.intervalList.size(), (int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval));
if (computation.totalGain != null && computation.totalLoss != null) {
assertEquals(computation.totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
assertEquals(computation.totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
} else {
assertTrue(intervalStatistics.getIntervalList().stream().noneMatch(i -> i.altitudeGainLoss() != null));
assertTrue(computation.intervalList.stream().noneMatch(i -> i.altitudeGainLoss() != null));
}
for (int i = 0; i < intervalList.size() - 1; i++) {
assertEquals(intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
totalDistance = totalDistance.minus(intervalList.get(i).totalDistance());
Distance remainingDistance = computation.totalDistance;
for (int i = 0; i < computation.intervalList.size() - 1; i++) {
assertEquals(computation.intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
remainingDistance = remainingDistance.minus(computation.intervalList.get(i).totalDistance());
}
assertEquals(intervalList.get(intervalList.size() - 1).totalDistance().toM(), totalDistance.toM(), 0.01);
assertEquals(computation.intervalList.get(computation.intervalList.size() - 1).totalDistance().toM(), remainingDistance.toM(), 0.01);
}
private static Pair<Track, List<TrackPoint>> buildTrackWithTrackPoints(int numberOfPoints) {
@@ -195,4 +217,27 @@ public class IntervalStatisticsUpdaterTest {
return new Pair<>(track, trackPoints);
}
}
private static final class IntervalStatisticsComputation {
private final Track track;
private final List<Statistics> intervalList;
private final Distance totalDistance;
private final float totalTime;
private final Float totalGain;
private final Float totalLoss;
private IntervalStatisticsComputation(Track track,
List<Statistics> intervalList,
Distance totalDistance,
float totalTime,
Float totalGain,
Float totalLoss) {
this.track = track;
this.intervalList = intervalList;
this.totalDistance = totalDistance;
this.totalTime = totalTime;
this.totalGain = totalGain;
this.totalLoss = totalLoss;
}
}
}
@@ -0,0 +1,107 @@
package de.dennisguse.opentracks.util;
import static org.junit.Assert.fail;
import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public final class ActivityTestUtils {
private ActivityTestUtils() {
}
public static <T extends Activity> T waitForResumedActivity(Class<T> activityClass, Duration timeout) {
T resumedActivity = getResumedActivity(activityClass);
if (resumedActivity != null) {
return resumedActivity;
}
Application application = ApplicationProvider.getApplicationContext();
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<T> resumedActivityRef = new AtomicReference<>();
Application.ActivityLifecycleCallbacks callbacks = new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityResumed(@NonNull Activity activity) {
if (activityClass.isInstance(activity)) {
resumedActivityRef.set(activityClass.cast(activity));
latch.countDown();
}
}
@Override
public void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
}
};
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
application.registerActivityLifecycleCallbacks(callbacks);
T currentResumedActivity = findResumedActivity(activityClass);
if (currentResumedActivity != null) {
resumedActivityRef.set(currentResumedActivity);
latch.countDown();
}
});
try {
if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
fail("Timed out waiting for resumed activity " + activityClass.getSimpleName());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail("Interrupted while waiting for resumed activity " + activityClass.getSimpleName());
} finally {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> application.unregisterActivityLifecycleCallbacks(callbacks));
}
return resumedActivityRef.get();
}
private static <T extends Activity> T getResumedActivity(Class<T> activityClass) {
AtomicReference<T> resumedActivity = new AtomicReference<>();
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> resumedActivity.set(findResumedActivity(activityClass)));
return resumedActivity.get();
}
private static <T extends Activity> T findResumedActivity(Class<T> activityClass) {
for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) {
if (activityClass.isInstance(activity)) {
return activityClass.cast(activity);
}
}
return null;
}
}
@@ -1,14 +1,25 @@
package de.dennisguse.opentracks.util;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static org.hamcrest.Matchers.allOf;
import android.app.Activity;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ListView;
import android.widget.AdapterView;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.espresso.util.TreeIterables;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
@@ -16,61 +27,112 @@ import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
public class EspressoUtils {
public static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
public static Matcher<View> withRecyclerViewItemCount(final int count) {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(View view) {
if (!(view instanceof RecyclerView recyclerView)) {
return false;
}
RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
return adapter != null && adapter.getItemCount() == count;
}
@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));
description.appendText("RecyclerView should have " + count + " items");
}
};
}
public static Matcher<View> withListSize (final int size) {
public static Matcher<View> withSelectedTab(final int index) {
return new TypeSafeMatcher<>() {
@Override
public boolean matchesSafely(final View view) {
return ((ListView) view).getCount() == size;
protected boolean matchesSafely(View view) {
return view instanceof TabLayout tabLayout
&& tabLayout.getSelectedTabPosition() == index;
}
@Override
public void describeTo(final Description description) {
description.appendText("ListView should have " + size + " items");
public void describeTo(Description description) {
description.appendText("TabLayout should have selected tab " + index);
}
};
}
public static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
public static Matcher<View> withViewPagerCurrentItem(final int index) {
return new TypeSafeMatcher<>() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
protected boolean matchesSafely(View view) {
return view instanceof ViewPager2 viewPager2
&& viewPager2.getCurrentItem() == index
&& viewPager2.getScrollState() == ViewPager2.SCROLL_STATE_IDLE;
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
public void describeTo(Description description) {
description.appendText("ViewPager2 should be idle on item " + index);
}
};
}
public static void waitForResumedView(final Matcher<View> viewMatcher, final Duration timeout) {
waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout);
}
public static void waitUntilDisplayed(final Matcher<View> viewMatcher, final Duration timeout) {
waitForResumedView(allOf(viewMatcher, isDisplayed()), timeout);
}
public static void waitUntilRecyclerViewItemCount(final Matcher<View> viewMatcher, final int expectedCount, final Duration timeout) {
waitForResumedViewCondition(
allOf(viewMatcher, isDisplayed()),
view -> withRecyclerViewItemCount(expectedCount).matches(view),
"RecyclerView item count to be " + expectedCount,
timeout);
}
public static void waitForResumedViewCondition(final Matcher<View> viewMatcher,
final Predicate<View> condition,
final String conditionDescription,
final Duration timeout) {
long deadline = SystemClock.uptimeMillis() + timeout.toMillis();
do {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
AtomicBoolean matched = new AtomicBoolean(false);
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) {
View decorView = activity.getWindow() != null ? activity.getWindow().getDecorView() : null;
if (decorView == null) {
continue;
}
for (View child : TreeIterables.breadthFirstViewTraversal(decorView)) {
if (viewMatcher.matches(child) && condition.test(child)) {
matched.set(true);
return;
}
}
}
});
if (matched.get()) {
return;
}
} while (SystemClock.uptimeMillis() < deadline);
throw new AssertionError("Timed out waiting for " + conditionDescription);
}
public static ViewAction selectTabAtIndex(final int index) {
return new ViewAction() {
@Override
@@ -91,4 +153,135 @@ public class EspressoUtils {
};
}
public static ViewAction performClick() {
return new ViewAction() {
@Override
public String getDescription() {
return "invoke View.performClick()";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, View view) {
if (!view.performClick()) {
throw new PerformException.Builder()
.withActionDescription(getDescription())
.withViewDescription(HumanReadables.describe(view))
.build();
}
uiController.loopMainThreadUntilIdle();
}
};
}
public static ViewAction performClickOnAncestor() {
return new ViewAction() {
@Override
public String getDescription() {
return "invoke performClick() on the matched view or its ancestor";
}
@Override
public Matcher<View> getConstraints() {
return isAssignableFrom(View.class);
}
@Override
public void perform(UiController uiController, View view) {
View current = view;
while (current != null) {
if (current.performClick()) {
uiController.loopMainThreadUntilIdle();
return;
}
ViewParent parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
current = view;
while (current != null) {
ViewParent parent = current.getParent();
if (parent instanceof AdapterView<?> adapterView) {
int position = adapterView.getPositionForView(current);
if (position != AdapterView.INVALID_POSITION
&& adapterView.performItemClick(current, position, adapterView.getAdapter().getItemId(position))) {
uiController.loopMainThreadUntilIdle();
return;
}
}
current = parent instanceof View ? (View) parent : null;
}
throw new PerformException.Builder()
.withActionDescription(getDescription())
.withViewDescription(HumanReadables.describe(view))
.build();
}
};
}
public static ViewAction performLongClick() {
return new ViewAction() {
@Override
public String getDescription() {
return "invoke View.performLongClick()";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, View view) {
if (!view.performLongClick()) {
throw new PerformException.Builder()
.withActionDescription(getDescription())
.withViewDescription(HumanReadables.describe(view))
.build();
}
uiController.loopMainThreadUntilIdle();
}
};
}
public static ViewAction performLongClickOnAncestor() {
return new ViewAction() {
@Override
public String getDescription() {
return "invoke performLongClick() on the matched view or its ancestor";
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
}
@Override
public void perform(UiController uiController, View view) {
View current = view;
while (current != null) {
if (current.performLongClick()) {
uiController.loopMainThreadUntilIdle();
return;
}
ViewParent parent = current.getParent();
current = parent instanceof View ? (View) parent : null;
}
throw new PerformException.Builder()
.withActionDescription(getDescription())
.withViewDescription(HumanReadables.describe(view))
.build();
}
};
}
}
@@ -0,0 +1,57 @@
package de.dennisguse.opentracks.util;
import static org.junit.Assert.fail;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.test.platform.app.InstrumentationRegistry;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
public final class LiveDataTestUtils {
private LiveDataTestUtils() {
}
public static <T> T waitForValue(LiveData<T> liveData,
Predicate<T> predicate,
Duration timeout,
String description) {
AtomicReference<T> lastValue = new AtomicReference<>(liveData.getValue());
CountDownLatch latch = new CountDownLatch(1);
Observer<T> observer = value -> {
lastValue.set(value);
if (predicate.test(value)) {
latch.countDown();
}
};
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
liveData.observeForever(observer);
T currentValue = liveData.getValue();
lastValue.set(currentValue);
if (predicate.test(currentValue)) {
latch.countDown();
}
});
try {
if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
fail("Timed out waiting for " + description + ". Last value: " + lastValue.get());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail("Interrupted while waiting for " + description);
} finally {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> liveData.removeObserver(observer));
}
return lastValue.get();
}
}
@@ -0,0 +1,30 @@
package de.dennisguse.opentracks.util;
import android.content.Context;
import android.content.Intent;
import androidx.test.core.app.ApplicationProvider;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.services.TrackRecordingService;
public final class TestEnvironmentUtils {
private TestEnvironmentUtils() {
}
public static void stopTrackRecordingService() {
Context context = ApplicationProvider.getApplicationContext();
context.stopService(new Intent(context, TrackRecordingService.class));
}
public static void deleteAllTracks() {
Context context = ApplicationProvider.getApplicationContext();
new ContentProviderUtils(context).deleteAllTracks(context);
}
public static void resetTrackRecordingServiceAndDeleteTracks() {
stopTrackRecordingService();
deleteAllTracks();
}
}
@@ -0,0 +1,50 @@
package de.dennisguse.opentracks.util;
import static org.junit.Assert.fail;
import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
public final class TrackRecordingServiceTestUtils {
private TrackRecordingServiceTestUtils() {
}
public static void waitForRecordingState(boolean isRecording, Duration timeout) {
Context context = ApplicationProvider.getApplicationContext();
CountDownLatch connectedLatch = new CountDownLatch(1);
AtomicReference<TrackRecordingService> serviceRef = new AtomicReference<>();
TrackRecordingServiceConnection connection = new TrackRecordingServiceConnection((service, self) -> {
serviceRef.set(service);
connectedLatch.countDown();
});
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.bind(context));
try {
if (!connectedLatch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
fail("Timed out waiting to bind TrackRecordingService");
}
LiveDataTestUtils.waitForValue(serviceRef.get().getRecordingStatusObservable(),
status -> status != null && status.isRecording() == isRecording,
timeout,
"recording state to become " + isRecording);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail("Interrupted while waiting for recording state " + isRecording);
} finally {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.unbind(context));
}
}
}
@@ -0,0 +1,70 @@
package de.dennisguse.opentracks.util;
import static androidx.test.espresso.Espresso.onView;
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.matcher.ViewMatchers.isClickable;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState;
import static org.hamcrest.Matchers.allOf;
import androidx.annotation.IdRes;
import java.time.Duration;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.TrackRecordingActivity;
import de.dennisguse.opentracks.TrackStoppedActivity;
public final class TrackUiTestUtils {
private TrackUiTestUtils() {
}
public static void startRecordingFromTrackList(Duration timeout) {
onView(withId(R.id.track_list_fab_action))
.perform(performClick());
waitForResumedActivity(TrackRecordingActivity.class, timeout);
waitForRecordingState(true, timeout);
waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout);
}
public static void stopRecordingToTrackStopped(Duration timeout, @IdRes int... expectedVisibleViewIds) {
onView(withId(R.id.track_recording_fab_action))
.perform(performLongClick());
waitForResumedActivity(TrackStoppedActivity.class, timeout);
waitUntilDisplayed(withId(R.id.track_edit_name), timeout);
for (int viewId : expectedVisibleViewIds) {
waitUntilDisplayed(withId(viewId), timeout);
}
onView(withId(R.id.track_edit_name))
.perform(closeSoftKeyboard());
}
public static void resumeStoppedTrack(Duration timeout) {
onView(allOf(withId(R.id.resume_button), isClickable()))
.perform(performClick());
waitForResumedActivity(TrackRecordingActivity.class, timeout);
waitForRecordingState(true, timeout);
waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout);
}
public static void finishStoppedTrack(Duration timeout) {
onView(withId(R.id.finish_button))
.perform(performClick());
waitForResumedActivity(TrackListActivity.class, timeout);
waitUntilDisplayed(withId(R.id.track_list), timeout);
}
public static void renameStoppedTrack(String name, Duration timeout) {
waitUntilDisplayed(withId(R.id.track_edit_name), timeout);
onView(withId(R.id.track_edit_name))
.perform(scrollTo(), replaceText(name), closeSoftKeyboard());
}
}
+1 -1
View File
@@ -14,5 +14,5 @@ See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="app_name" translatable="false">OpenTracks (Debug)</string>
<string name="launcher_name" translatable="false">OpenTracks (Debug)</string>
</resources>
+33 -3
View File
@@ -69,6 +69,12 @@ limitations under the License.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"
android:minSdkVersion="33" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
<uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED" />
<uses-permission android:name="android.permission.health.WRITE_SPEED" />
<application
android:name=".Startup"
android:allowBackup="true"
@@ -78,7 +84,7 @@ limitations under the License.
android:hardwareAccelerated="true"
android:hasFragileUserData="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:label="@string/launcher_name"
android:supportsRtl="true"
android:theme="@style/DayNightDynamicTheme"> <!-- Default for AppCompatActivities -->
@@ -126,9 +132,32 @@ limitations under the License.
<activity android:name=".AboutActivity" />
<!-- Permissions -->
<!-- until Android 13 -->
<activity
android:name=".HelpActivity"
android:label="@string/menu_help" />
android:name=".HealthConnectPermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- for Android 14 -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".HealthConnectPermissionsRationaleActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<activity android:name=".settings.HealthConnect$Settings" />
<activity android:name=".settings.HealthConnect$ConfigurePermissions" />
<activity android:name=".settings.HealthConnect$ExportAll" />
<activity android:name=".HelpActivity" />
<activity android:name=".io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity" />
@@ -410,5 +439,6 @@ limitations under the License.
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -0,0 +1,28 @@
package de.dennisguse.opentracks;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
setSupportActionBar(viewBinding.healthConnectActivityToolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setContentView(viewBinding.getRoot());
}
@Override
public boolean onSupportNavigateUp() {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
}
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.os.Build;
import android.os.StrictMode;
import android.util.Log;
import java.lang.reflect.Method;
@@ -27,6 +28,11 @@ public class Startup extends Application {
// Include version information into stack traces.
Log.i(TAG, BuildConfig.APPLICATION_ID + "; BuildType: " + BuildConfig.BUILD_TYPE + "; VersionName: " + BuildConfig.VERSION_NAME + "/" + BuildConfig.VERSION_NAME_FULL + " VersionCode: " + BuildConfig.VERSION_CODE);
// In debug builds: show thread and VM warnings.
if (BuildConfig.DEBUG) {
Log.d(TAG, "Enabling strict mode");
StrictMode.enableDefaults();
}
PreferencesUtils.initPreferences(this);
// Set default values of preferences on first start.
@@ -367,7 +367,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
if (itemId == R.id.list_context_menu_show_on_map) {
DataProvider.showTrackOnMap(this, false, trackIds);
DataProvider.showTrackOnMap(this, recordingStatus.isRecording(), trackIds);
return true;
}
@@ -18,6 +18,7 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.databinding.TrackStoppedBinding;
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
@@ -93,6 +94,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
viewBinding.finishButton.setOnClickListener(v -> {
storeTrackMetaData(contentProviderUtils, track);
ExportUtils.postWorkoutExport(this, trackId);
HealthConnectUtils.postExportTrack(this, trackId);
finish();
});
@@ -22,6 +22,7 @@ import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import android.text.TextUtils;
import androidx.annotation.NonNull;
@@ -131,7 +132,7 @@ public class ContentProviderUtils {
@VisibleForTesting
public void deleteAllTracks(Context context) {
// Delete tracks last since it triggers a database vacuum call
contentResolver.delete(TracksColumns.CONTENT_URI, null, null);
contentResolver.delete(CustomContentProvider.UrlType.TRACKS.getUri(), null, null);
File dir = FileUtils.getPhotoDir(context);
FileUtils.deleteDirectoryRecurse(dir);
@@ -144,13 +145,13 @@ public class ContentProviderUtils {
}
String whereClause = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?")));
contentResolver.delete(TracksColumns.CONTENT_URI, whereClause, trackIds.stream().map(trackId -> Long.toString(trackId.id())).toArray(String[]::new));
contentResolver.delete(CustomContentProvider.UrlType.TRACKS.getUri(), whereClause, trackIds.stream().map(trackId -> Long.toString(trackId.id())).toArray(String[]::new));
}
public void deleteTrack(Context context, @NonNull Track.Id trackId) {
// Delete track folder resources.
FileUtils.deleteDirectoryRecurse(FileUtils.getPhotoDir(context, trackId));
contentResolver.delete(TracksColumns.CONTENT_URI, TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
contentResolver.delete(CustomContentProvider.UrlType.TRACKS.getUri(), TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
}
//TODO Only use for tests; also move to tests.
@@ -194,7 +195,7 @@ public class ContentProviderUtils {
selectionArgs = new String[]{"%" + searchQuery + "%", "%" + searchQuery + "%", "%" + searchQuery + "%"};
}
return new TrackListIterator(contentResolver.query(TracksColumns.CONTENT_URI, PROJECTION, selection, selectionArgs, sortOrder));
return new TrackListIterator(contentResolver.query(CustomContentProvider.UrlType.TRACKS.getUri(), PROJECTION, selection, selectionArgs, sortOrder));
}
public Track getTrack(@NonNull Track.Id trackId) {
@@ -225,7 +226,7 @@ public class ContentProviderUtils {
* @param sortOrder the sort order. Can be null
*/
Cursor getTrackCursor(String selection, String[] selectionArgs, String sortOrder) {
return contentResolver.query(TracksColumns.CONTENT_URI, null, selection, selectionArgs, sortOrder);
return contentResolver.query(CustomContentProvider.UrlType.TRACKS.getUri(), null, selection, selectionArgs, sortOrder);
}
/**
@@ -237,7 +238,7 @@ public class ContentProviderUtils {
*/
public Track.Id insertTrack(Track track) {
//TODO Unset trackId
Uri uri = contentResolver.insert(TracksColumns.CONTENT_URI, createContentValues(track));
Uri uri = contentResolver.insert(CustomContentProvider.UrlType.TRACKS.getUri(), createContentValues(track));
return new Track.Id(ContentUris.parseId(uri));
}
@@ -248,7 +249,7 @@ public class ContentProviderUtils {
* @param track the track
*/
public void updateTrack(Track track) {
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(track), TracksColumns._ID + "=?", new String[]{Long.toString(track.id().id())});
contentResolver.update(CustomContentProvider.UrlType.TRACKS.getUri(), createContentValues(track), TracksColumns._ID + "=?", new String[]{Long.toString(track.id().id())});
}
private ContentValues createContentValues(Track track) {
@@ -270,7 +271,7 @@ public class ContentProviderUtils {
}
public void updateTrackStatistics(@NonNull Track.Id trackId, @NonNull Statistics trackStatistics) {
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(trackStatistics), TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
contentResolver.update(CustomContentProvider.UrlType.TRACKS.getUri(), createContentValues(trackStatistics), TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
}
private ContentValues createContentValues(Statistics trackStatistics) {
@@ -362,7 +363,7 @@ public class ContentProviderUtils {
public void deleteMarker(Context context, Marker.Id markerId) {
final Marker marker = getMarker(markerId);
deleteMarkerPhoto(context, marker);
contentResolver.delete(MarkerColumns.CONTENT_URI, MarkerColumns._ID + "=?", new String[]{Long.toString(markerId.id())});
contentResolver.delete(CustomContentProvider.UrlType.MARKERS.getUri(), MarkerColumns._ID + "=?", new String[]{Long.toString(markerId.id())});
}
/**
@@ -422,7 +423,7 @@ public class ContentProviderUtils {
if (updateMarker.photoUrl() == null) {
deleteMarkerPhoto(context, savedMarker);
}
int rows = contentResolver.update(MarkerColumns.CONTENT_URI, createContentValues(updateMarker, updateMarker.trackId()), MarkerColumns._ID + "=?", new String[]{Long.toString(updateMarker.id().id())});
int rows = contentResolver.update(CustomContentProvider.UrlType.MARKERS.getUri(), createContentValues(updateMarker, updateMarker.trackId()), MarkerColumns._ID + "=?", new String[]{Long.toString(updateMarker.id().id())});
return rows == 1;
}
@@ -430,7 +431,7 @@ public class ContentProviderUtils {
assert marker.id() == null;
assert marker.trackId() != null;
Uri uri = contentResolver.insert(MarkerColumns.CONTENT_URI, createContentValues(marker, marker.trackId()));
Uri uri = contentResolver.insert(CustomContentProvider.UrlType.MARKERS.getUri(), createContentValues(marker, marker.trackId()));
return new Marker.Id(ContentUris.parseId(uri));
}
@@ -491,7 +492,7 @@ public class ContentProviderUtils {
if (maxCount >= 0) {
sortOrder += " LIMIT " + maxCount;
}
return contentResolver.query(MarkerColumns.CONTENT_URI, projection, selection, selectionArgs, sortOrder);
return contentResolver.query(CustomContentProvider.UrlType.MARKERS.getUri(), projection, selection, selectionArgs, sortOrder);
}
public MarkerIterator searchMarkers(Track.Id trackId, String query) {
@@ -509,7 +510,7 @@ public class ContentProviderUtils {
MarkerColumns.DESCRIPTION + " LIKE ? OR " +
MarkerColumns.TYPE_LOCALIZED + " LIKE ?";
selectionArgs = new String[]{"%" + query + "%", "%" + query + "%", "%" + query + "%"};
sortOrder = MarkerColumns.DEFAULT_SORT_ORDER + " DESC";
sortOrder = BaseColumns._ID + " DESC";
}
return new MarkerIterator(this, getMarkerCursor(null, selection, selectionArgs, sortOrder, -1));
@@ -567,7 +568,7 @@ public class ContentProviderUtils {
for (int i = 0; i < trackPoints.size(); i++) {
values[i] = createContentValues(trackPoints.get(i), trackId);
}
return contentResolver.bulkInsert(TrackPointsColumns.CONTENT_URI_BY_ID, values);
return contentResolver.bulkInsert(CustomContentProvider.UrlType.TRACKPOINTS_BY_ID.getUri(), values);
}
//TODO Set trackId in this method.
@@ -576,7 +577,7 @@ public class ContentProviderUtils {
for (int i = 0; i < markers.size(); i++) {
values[i] = createContentValues(markers.get(i), trackId);
}
return contentResolver.bulkInsert(MarkerColumns.CONTENT_URI, values);
return contentResolver.bulkInsert(CustomContentProvider.UrlType.MARKERS.getUri(), values);
}
/**
@@ -597,7 +598,7 @@ public class ContentProviderUtils {
selectionArgs = new String[]{Long.toString(trackId.id())};
}
return getTrackPointCursor(null, selection, selectionArgs, TrackPointsColumns.DEFAULT_SORT_ORDER);
return getTrackPointCursor(null, selection, selectionArgs, BaseColumns._ID);
}
/**
@@ -608,7 +609,7 @@ public class ContentProviderUtils {
* @return the content provider URI of the inserted trackPoint
*/
public Uri insertTrackPoint(TrackPoint trackPoint, Track.Id trackId) {
return contentResolver.insert(TrackPointsColumns.CONTENT_URI_BY_ID, createContentValues(trackPoint, trackId));
return contentResolver.insert(CustomContentProvider.UrlType.TRACKPOINTS_BY_ID.getUri(), createContentValues(trackPoint, trackId));
}
/**
@@ -683,7 +684,7 @@ public class ContentProviderUtils {
* @param sortOrder the sort order
*/
private Cursor getTrackPointCursor(String[] projection, String selection, String[] selectionArgs, String sortOrder) {
return contentResolver.query(TrackPointsColumns.CONTENT_URI_BY_ID, projection, selection, selectionArgs, sortOrder);
return contentResolver.query(CustomContentProvider.UrlType.TRACKPOINTS_BY_ID.getUri(), projection, selection, selectionArgs, sortOrder);
}
public static String formatIdListForUri(Track.Id... trackIds) {
@@ -718,7 +719,7 @@ public class ContentProviderUtils {
public SensorStatistics getSensorStats(@NonNull Track.Id trackId) {
SensorStatistics sensorStatistics = null;
try (Cursor cursor = contentResolver.query(ContentUris.withAppendedId(TracksColumns.CONTENT_URI_SENSOR_STATS, trackId.id()), null, null, null, null)) {
try (Cursor cursor = contentResolver.query(ContentUris.withAppendedId(CustomContentProvider.UrlType.TRACKS_SENSOR_STATS.getUri(), trackId.id()), null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int MAX_HR_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_HR);
final int AVG_HR_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_HR);
@@ -743,7 +744,7 @@ public class ContentProviderUtils {
@NonNull
public List<AggregatedStatistic> getAggregatedStatisticsForTracks(TrackSelection selection) {
SelectionData data = selection.buildSelection();
try (Cursor cursor = contentResolver.query(TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS, null, data.selection(), data.getSelectionArgs(), null)) {
try (Cursor cursor = contentResolver.query(CustomContentProvider.UrlType.TRACKS_AGGREGATED_STATISTICS.getUri(), null, data.selection(), data.getSelectionArgs(), null)) {
ArrayList<AggregatedStatistic> aggregatedStatistics = new ArrayList<>(cursor.getCount());
while (cursor.moveToNext()) {
aggregatedStatistics.add(new AggregatedStatistic(
@@ -27,6 +27,7 @@ import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Build;
import android.provider.BaseColumns;
import android.text.TextUtils;
import android.util.Log;
@@ -73,51 +74,39 @@ public class CustomContentProvider extends ContentProvider {
*/
private final String SENSOR_STATS_QUERY =
"WITH time_select AS " +
"(SELECT t1." + TrackPointsColumns.TIME + " * (t1." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")) time_value " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t1 " +
"WHERE t1." + TrackPointsColumns._ID + " > t." + TrackPointsColumns._ID + " AND t1." + TrackPointsColumns.TRACKID + " = ? ORDER BY _id LIMIT 1) " +
"(SELECT t1." + TrackPointsColumns.TIME + " * (t1." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")) time_value " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t1 " +
"WHERE t1." + TrackPointsColumns._ID + " > t." + TrackPointsColumns._ID + " AND t1." + TrackPointsColumns.TRACKID + " = ? ORDER BY _id LIMIT 1) " +
"SELECT " +
"SUM(t." + TrackPointsColumns.SENSOR_HEARTRATE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_HR + ", " +
"SELECT " +
"SUM(t." + TrackPointsColumns.SENSOR_HEARTRATE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_HR + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_HEARTRATE + ") " + TrackPointsColumns.ALIAS_MAX_HR + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_HEARTRATE + ") " + TrackPointsColumns.ALIAS_MAX_HR + ", " +
"SUM(t." + TrackPointsColumns.SENSOR_CADENCE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_CADENCE + ", " +
"SUM(t." + TrackPointsColumns.SENSOR_CADENCE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_CADENCE + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_CADENCE + ") " + TrackPointsColumns.ALIAS_MAX_CADENCE + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_CADENCE + ") " + TrackPointsColumns.ALIAS_MAX_CADENCE + ", " +
"SUM(t." + TrackPointsColumns.SENSOR_POWER + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_POWER + ", " +
"SUM(t." + TrackPointsColumns.SENSOR_POWER + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_POWER + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_POWER + ") " + TrackPointsColumns.ALIAS_MAX_POWER + " " +
"MAX(t." + TrackPointsColumns.SENSOR_POWER + ") " + TrackPointsColumns.ALIAS_MAX_POWER + " " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t " +
"WHERE t." + TrackPointsColumns.TRACKID + " = ? " +
"AND t." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")";
"FROM " + TrackPointsColumns.TABLE_NAME + " t " +
"WHERE t." + TrackPointsColumns.TRACKID + " = ? " +
"AND t." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")";
public CustomContentProvider() {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath(), UrlType.TRACKPOINTS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath() + "/#", UrlType.TRACKPOINTS_BY_ID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_TRACKID.getPath() + "/*", UrlType.TRACKPOINTS_BY_TRACKID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath(), UrlType.TRACKS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS.getPath(), UrlType.TRACKS_AGGREGATED_STATISTICS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath() + "/#", UrlType.MARKERS_BY_ID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI_BY_TRACKID.getPath() + "/*", UrlType.MARKERS_BY_TRACKID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, UrlType.DASHBOARD_TRACKS_BY_IDS.getPath() + "*", UrlType.DASHBOARD_TRACKS_BY_IDS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, UrlType.DASHBOARD_TRACKPOINTS_BY_TRACKIDS.getPath() + "*", UrlType.DASHBOARD_TRACKPOINTS_BY_TRACKIDS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.getPath() + "*", UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.ordinal());
UrlType[] urlTypes = UrlType.values();
for (int i = 0; i < urlTypes.length; i++) {
uriMatcher.addURI(AUTHORITY_PACKAGE, urlTypes[i].getPathWithFilter(), i);
}
}
@Override
@@ -153,7 +142,7 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
getContext().getContentResolver().notifyChange(url, null, false);
notifyChanges(url);
int totalChanges = getTotalChanges() - totalChangesBefore;
Log.i(TAG, "Deleted " + totalChanges + " total rows from database");
@@ -180,23 +169,7 @@ public class CustomContentProvider extends ContentProvider {
@Override
public String getType(@NonNull Uri url) {
String contentType = getUrlType(url).contentType;
if (contentType != null) {
return contentType;
}
//TODO replace with getUrlType(url).getContentType()
return switch (getUrlType(url)) {
case TRACKPOINTS -> TrackPointsColumns.CONTENT_TYPE;
case TRACKPOINTS_BY_TRACKID -> TrackPointsColumns.CONTENT_ITEMTYPE;
case TRACKS -> TracksColumns.CONTENT_TYPE;
case MARKERS -> MarkerColumns.CONTENT_TYPE;
case MARKERS_BY_TRACKID -> MarkerColumns.CONTENT_ITEMTYPE;
case DASHBOARD_TRACKS_BY_IDS -> UrlType.DASHBOARD_TRACKS_BY_IDS.contentType;
case DASHBOARD_TRACKPOINTS_BY_TRACKIDS -> UrlType.DASHBOARD_TRACKPOINTS_BY_TRACKIDS.contentType;
case DASHBOARD_MARKERS_BY_TRACKIDS -> UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.contentType;
default -> throw new IllegalArgumentException("Unknown URL " + url);
};
return getUrlType(url).contentType;
}
@Override
@@ -212,7 +185,8 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
getContext().getContentResolver().notifyChange(url, null, false);
notifyChanges(url);
return result;
}
@@ -235,7 +209,8 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
getContext().getContentResolver().notifyChange(url, null, false);
notifyChanges(url);
return numInserted;
}
@@ -249,7 +224,7 @@ public class CustomContentProvider extends ContentProvider {
switch (getUrlType(url)) {
case TRACKPOINTS -> {
queryBuilder.setTables(TrackPointsColumns.TABLE_NAME);
sortOrder = sort != null ? sort : TrackPointsColumns.DEFAULT_SORT_ORDER;
sortOrder = sort != null ? sort : BaseColumns._ID;
}
case TRACKPOINTS_BY_ID -> {
queryBuilder.setTables(TrackPointsColumns.TABLE_NAME);
@@ -270,13 +245,13 @@ public class CustomContentProvider extends ContentProvider {
} else {
queryBuilder.setTables(TracksColumns.TABLE_NAME);
}
sortOrder = sort != null ? sort : TracksColumns.DEFAULT_SORT_ORDER;
sortOrder = sort != null ? sort : BaseColumns._ID;
}
case TRACKS_BY_ID -> {
queryBuilder.setTables(TracksColumns.TABLE_NAME);
queryBuilder.appendWhere(TracksColumns._ID + " IN (" + TextUtils.join(SQL_LIST_DELIMITER, ContentProviderUtils.parseTrackIdsFromUri(url)) + ")");
}
case DASHBOARD_TRACKS_BY_IDS -> {
case DASHBOARD_TRACKS_BY_IDS -> {
queryBuilder.setTables(TracksColumns.TABLE_NAME);
queryBuilder.appendWhere(TracksColumns._ID + " IN (" + TextUtils.join(SQL_LIST_DELIMITER, ContentProviderUtils.parseTrackIdsFromUri(url)) + ")");
queryBuilder.setProjectionMap(DataProvider.DATA_PROJECTIONMAP_TRACKS);
@@ -301,7 +276,7 @@ public class CustomContentProvider extends ContentProvider {
}
case MARKERS -> {
queryBuilder.setTables(MarkerColumns.TABLE_NAME);
sortOrder = sort != null ? sort : MarkerColumns.DEFAULT_SORT_ORDER;
sortOrder = sort != null ? sort : BaseColumns._ID;
}
case MARKERS_BY_ID -> {
queryBuilder.setTables(MarkerColumns.TABLE_NAME);
@@ -348,10 +323,36 @@ public class CustomContentProvider extends ContentProvider {
}
}
getContext().getContentResolver().notifyChange(url, null, false);
notifyChanges(url);
return count;
}
private void notifyChanges(@NonNull Uri uri) {
getContext().getContentResolver().notifyChange(uri, null, false);
// Also notify dashboard URIs for compatibility
Uri dashboardUri = getDashboardUri(uri);
if (dashboardUri != null) {
getContext().getContentResolver().notifyChange(dashboardUri, null, false);
}
}
private Uri getDashboardUri(Uri uri) {
String path = uri.getPath();
if (path == null) return null;
String dashboardPath;
if (path.startsWith("/tracks")) {
dashboardPath = path.replaceFirst("/tracks", "/dashboard/tracks");
} else if (path.startsWith("/markers")) {
dashboardPath = path.replaceFirst("/markers", "/dashboard/markers");
} else if (path.startsWith("/trackpoints")) {
dashboardPath = path.replaceFirst("/trackpoints", "/dashboard/trackpoints");
} else {
return null;
}
return Uri.parse(CONTENT_BASE_URI + dashboardPath);
}
@NonNull
private UrlType getUrlType(Uri url) {
UrlType[] urlTypes = UrlType.values();
@@ -386,7 +387,7 @@ public class CustomContentProvider extends ContentProvider {
}
long rowId = db.insert(TrackPointsColumns.TABLE_NAME, TrackPointsColumns._ID, values);
if (rowId >= 0) {
return ContentUris.appendId(TrackPointsColumns.CONTENT_URI_BY_ID.buildUpon(), rowId).build();
return ContentUris.appendId(UrlType.TRACKPOINTS_BY_ID.getUri().buildUpon(), rowId).build();
}
throw new SQLiteException("Failed to insert a track point " + url);
}
@@ -394,7 +395,7 @@ public class CustomContentProvider extends ContentProvider {
private Uri insertTrack(Uri url, ContentValues contentValues) {
long rowId = db.insert(TracksColumns.TABLE_NAME, TracksColumns._ID, contentValues);
if (rowId >= 0) {
return ContentUris.appendId(TracksColumns.CONTENT_URI.buildUpon(), rowId).build();
return ContentUris.appendId(UrlType.TRACKS.getUri().buildUpon(), rowId).build();
}
throw new SQLException("Failed to insert a track " + url);
}
@@ -402,53 +403,49 @@ public class CustomContentProvider extends ContentProvider {
private Uri insertMarker(Uri url, ContentValues contentValues) {
long rowId = db.insert(MarkerColumns.TABLE_NAME, MarkerColumns._ID, contentValues);
if (rowId >= 0) {
return ContentUris.appendId(MarkerColumns.CONTENT_URI.buildUpon(), rowId).build();
return ContentUris.appendId(UrlType.MARKERS.getUri().buildUpon(), rowId).build();
}
throw new SQLException("Failed to insert a marker " + url);
}
//TODO Add ContentType as attribute
public enum UrlType {
TRACKPOINTS,
TRACKPOINTS_BY_ID,
TRACKPOINTS("/trackpoints", "", "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint"),
TRACKPOINTS_BY_ID("/trackpoints", "#", "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint"),
//TODO Check if still used
TRACKPOINTS_BY_TRACKID,
TRACKS,
TRACKS_BY_ID,
TRACKS_SENSOR_STATS,
TRACKS_AGGREGATED_STATISTICS,
MARKERS,
MARKERS_BY_ID,
TRACKPOINTS_BY_TRACKID("/trackpoints/trackid", "*", "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint"),
TRACKS("/tracks", "", "vnd.android.cursor.dir/vnd.de.dennisguse.track"),
TRACKS_BY_ID("/tracks", "#", "vnd.android.cursor.dir/vnd.de.dennisguse.track"),
TRACKS_SENSOR_STATS("/tracks/sensorstats", "#", "vnd.android.cursor.dir/vnd.de.dennisguse.sensorstats"),
TRACKS_AGGREGATED_STATISTICS("/tracks/aggregated/", "", "vnd.android.cursor.dir/vnd.de.dennisguse.aggregatedstatistics"),
MARKERS("/markers", "", "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint"),
MARKERS_BY_ID("/markers", "#", "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint"),
@Deprecated //TODO Should not be used anymore
MARKERS_BY_TRACKID,
MARKERS_BY_TRACKID("/markers/trackid", "*", "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint"),
DASHBOARD_TRACKS_BY_IDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/tracks/"), TracksColumns.CONTENT_TYPE),
DASHBOARD_TRACKPOINTS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/trackpoints/"), TrackPointsColumns.CONTENT_TYPE),
DASHBOARD_MARKERS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/markers/"), MarkerColumns.CONTENT_TYPE);
DASHBOARD_TRACKS_BY_IDS("/dashboard/tracks", "*", "vnd.android.cursor.dir/vnd.de.dennisguse.track"),
DASHBOARD_TRACKPOINTS_BY_TRACKIDS("/dashboard/trackpoints", "*", "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint"),
DASHBOARD_MARKERS_BY_TRACKIDS("/dashboard/markers", "*", "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint");
private final Uri uri;
private final String path;
private final String filter;
private final String contentType;
//TODO Add Uri as attribute:
//TODO Add ContentType as attribute:
@Deprecated()
UrlType() {
this.uri = null;
this.contentType = null;
}
UrlType(Uri uri, String contentType) {
this.uri = uri;
UrlType(@NonNull String path, String filter, @NonNull String contentType) {
this.path = path;
this.filter = filter;
this.contentType = contentType;
}
public Uri getUri() {
return uri;
return Uri.parse(CONTENT_BASE_URI + path);
}
String getPath() {
return uri.getPath();
private String getPathWithFilter() {
if (TextUtils.isEmpty(filter))
return path;
return path + "/" + filter;
}
}
}
@@ -88,7 +88,7 @@ public class ShareContentProvider extends CustomContentProvider {
}
trackIdBuilder.deleteCharAt(trackIdBuilder.lastIndexOf(TRACKID_DELIMITER));
Uri uri = Uri.parse(TracksColumns.CONTENT_URI + "/" + trackFileFormat.getPreferenceId() + "/" + trackIdBuilder + "/" + Uri.encode(filename));
Uri uri = Uri.parse(UrlType.TRACKS.getUri() + "/" + trackFileFormat.getPreferenceId() + "/" + trackIdBuilder + "/" + Uri.encode(filename));
Log.d(TAG, "Created uri " + uri + " with MIME " + trackFileFormat.getMimeType());
@@ -192,7 +192,7 @@ public class ShareContentProvider extends CustomContentProvider {
String[] trackIdsString = trackIds.stream().map(id -> String.valueOf(id.id())).toArray(String[]::new);
String whereClause = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?")));
try (Cursor cursor = super.query(TracksColumns.CONTENT_URI, null, whereClause, trackIdsString, TracksColumns._ID)) {
try (Cursor cursor = super.query(UrlType.TRACKS.getUri(), null, whereClause, trackIdsString, TracksColumns._ID)) {
while (cursor.moveToNext()) {
tracks.add(ContentProviderUtils.createTrack(cursor));
}
@@ -34,8 +34,6 @@ import de.dennisguse.opentracks.data.models.Statistics;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.statistics.TrackStatisticsUpdater;
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.AltitudeCorrectionManager;
@@ -120,7 +118,7 @@ public class TrackDataHub {
notifyTracksTableUpdate(listeners);
}
};
contentResolver.registerContentObserver(TracksColumns.CONTENT_URI, false, tracksTableObserver);
contentResolver.registerContentObserver(CustomContentProvider.UrlType.TRACKS.getUri(), false, tracksTableObserver);
trackPointsTableObserver = new ContentObserver(handler) {
@Override
@@ -128,7 +126,7 @@ public class TrackDataHub {
notifyTrackPointsTableUpdate(true, listeners);
}
};
contentResolver.registerContentObserver(TrackPointsColumns.CONTENT_URI_BY_ID, false, trackPointsTableObserver);
contentResolver.registerContentObserver(CustomContentProvider.UrlType.TRACKPOINTS_BY_ID.getUri(), false, trackPointsTableObserver);
}
public void stop() {
@@ -30,6 +30,7 @@ public enum ActivityType {
FERRY("ferry", R.string.activity_type_ferry, R.drawable.ic_activity_boat_24dp, true),
FRISBEE("frisbee", R.string.activity_type_frisbee, ActivityIcon.ICON_UNKNOWN, true),
GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true),
GRAVEL_BIKING("gravel biking", R.string.activity_type_gravel_biking, R.drawable.ic_activity_bike_24dp, true),
HANG_GLIDING("hang gliding", R.string.activity_type_hang_gliding, ActivityIcon.ICON_UNKNOWN, true),
HELICOPTER("helicopter", R.string.activity_type_helicopter, ActivityIcon.ICON_UNKNOWN, true),
HIKING("hiking", R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false),
@@ -50,15 +50,6 @@ public class ExtremityMonitor {
return min;
}
/**
* Sets the minimum value.
*
* @param min the minimum value
*/
public void setMin(double min) {
this.min = min;
}
/**
* Gets the maximum value seen.
*/
@@ -66,15 +57,6 @@ public class ExtremityMonitor {
return max;
}
/**
* Sets the maximum value.
*
* @param max the maximum value
*/
public void setMax(double max) {
this.max = max;
}
/**
* Updates the min and the max with a new value.
*
@@ -17,12 +17,10 @@
package de.dennisguse.opentracks.data.tables;
import android.content.Context;
import android.net.Uri;
import android.provider.BaseColumns;
import android.widget.ArrayAdapter;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.CustomContentProvider;
/**
* Constants for markers table.
@@ -32,11 +30,6 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface MarkerColumns extends BaseColumns {
String TABLE_NAME = "markers";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.waypoint";
String DEFAULT_SORT_ORDER = _ID;
// Columns
String NAME = "name";
@@ -16,11 +16,8 @@
package de.dennisguse.opentracks.data.tables;
import android.net.Uri;
import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider;
/**
* Constants for the track points table.
*
@@ -29,11 +26,6 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TrackPointsColumns extends BaseColumns {
String TABLE_NAME = "trackpoints";
Uri CONTENT_URI_BY_ID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.trackpoint";
String DEFAULT_SORT_ORDER = _ID;
// Columns
String TRACKID = "trackid";
@@ -16,11 +16,8 @@
package de.dennisguse.opentracks.data.tables;
import android.net.Uri;
import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider;
/**
* Constants for the tracks table.
*
@@ -29,12 +26,6 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TracksColumns extends BaseColumns {
String TABLE_NAME = "tracks";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_SENSOR_STATS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats");
Uri CONTENT_URI_AGGREGATED_STATISTICS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track";
String DEFAULT_SORT_ORDER = _ID;
// Columns
String UUID = "uuid"; // identifier to make tracks globally unique (prevent re-import)
@@ -56,7 +56,7 @@ public class ChooseActivityTypeDialogFragment extends DialogFragment implements
ActivityType.WORKOUT
);
private static int getPosition(Context context, ActivityType activityType) {
private static int getPosition(ActivityType activityType) {
if (activityType == null) {
return -1;
}
@@ -92,7 +92,7 @@ public class ChooseActivityTypeDialogFragment extends DialogFragment implements
}
final ChooseActivityTypeImageAdapter imageAdapter = new ChooseActivityTypeImageAdapter(iconDrawableIds);
int position = getPosition(getContext(), preselectedActivityType);
int position = getPosition(preselectedActivityType);
if (position != -1) {
imageAdapter.setSelected(position);
}
@@ -36,7 +36,7 @@ public class ExportUtils {
private static final String TAG = ExportUtils.class.getSimpleName();
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
if (PreferencesUtils.shouldInstantExportToFile()) {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
@@ -384,10 +384,7 @@ public class GPXTrackExporter implements TrackExporter {
printWriter.println("</trkpt>");
if (cumulativeDistance != null) {
return cumulativeDistance;
}
return Distance.ZERO;
return Objects.requireNonNullElse(cumulativeDistance, Distance.ZERO);
}
private Double cumulateSensorData(TrackPoint trackPoint, List<TrackPoint> sensorPoints, Function<TrackPoint, Double> map) {
@@ -0,0 +1,99 @@
package de.dennisguse.opentracks.io.healthconnect.exporter;
import android.content.Context;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.PermissionController;
import androidx.health.connect.client.permission.HealthPermission;
import androidx.health.connect.client.records.DistanceRecord;
import androidx.health.connect.client.records.ElevationGainedRecord;
import androidx.health.connect.client.records.ExerciseSessionRecord;
import androidx.health.connect.client.records.SpeedRecord;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;
import java.util.Set;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import kotlin.coroutines.Continuation;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.jvm.functions.Function2;
import kotlinx.coroutines.BuildersKt;
/**
* Exports a single track as an ExerciseSession to Health Connect.
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
*/
public class HealthConnectUtils {
public static final Set<String> PERMISSIONS = Set.of(
HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class)),
HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(DistanceRecord.class)),
HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ElevationGainedRecord.class)),
HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(SpeedRecord.class)),
HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE
);
private HealthConnectUtils() {
}
public static boolean isAvailable(Context context) {
return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE;
}
public static Set<String> getGrantedPermissions(Context context) {
if (!isAvailable(context))
return Set.of();
try {
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
PermissionController controller = client.getPermissionController();
return (Set<String>) BuildersKt.runBlocking(
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
(Function2) (scope, cont) -> controller.getGrantedPermissions((Continuation) cont));
} catch (InterruptedException e) {
return Set.of();
}
}
static boolean hasRequiredPermissions(Context context) {
return getGrantedPermissions(context)
.containsAll(PERMISSIONS);
}
public static void postExportTrack(Context context, Track.Id trackId) {
if (!PreferencesUtils.shouldInstantExportToHealthConnect()) {
return;
}
WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
.addTag(HealthConnectWorker.WORK_TAG)
.setInputData(new Data.Builder()
.putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id())
.build())
.build();
WorkManager.getInstance(context).enqueue(request);
}
public static void postExportAll(Context context) {
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
.addTag(HealthConnectWorker.WORK_TAG)
.build();
WorkManager.getInstance(context).enqueueUniqueWork(
HealthConnectWorker.WORK_TAG,
ExistingWorkPolicy.REPLACE,
request
);
}
public static void cancelHealthConnectExport(Context context) {
WorkManager workManager = WorkManager.getInstance(context);
workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG);
}
}
@@ -0,0 +1,252 @@
package de.dennisguse.opentracks.io.healthconnect.exporter;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.records.DistanceRecord;
import androidx.health.connect.client.records.ElevationGainedRecord;
import androidx.health.connect.client.records.ExerciseRoute;
import androidx.health.connect.client.records.ExerciseSessionRecord;
import androidx.health.connect.client.records.Record;
import androidx.health.connect.client.records.SpeedRecord;
import androidx.health.connect.client.records.metadata.Device;
import androidx.health.connect.client.records.metadata.Metadata;
import androidx.health.connect.client.units.Length;
import androidx.health.connect.client.units.Velocity;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import kotlin.coroutines.Continuation;
import kotlin.jvm.functions.Function2;
import kotlinx.coroutines.BuildersKt;
/**
* Exports a single track as an ExerciseSession to Health Connect.
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
*/
//TODO add reasons for failures (if shown in the UI: must be localized)
//TODO Export sensor data (permissions needed)
//TODO Show progress: like ExportActivity? Or use a notification (aka handle it as a background process)
public class HealthConnectWorker extends Worker {
private static final String TAG = HealthConnectWorker.class.getSimpleName();
static final String WORK_TAG = "HealthConnectSync";
static final String TRACK_ID_KEY = "trackId";
public HealthConnectWorker(Context context, WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
if (!HealthConnectUtils.isAvailable(getApplicationContext())) {
Log.w(TAG, "Health Connect SDK not available");
return Result.failure();
}
//TODO If not all permissions are granted, export the data that was granted.
if (!HealthConnectUtils.hasRequiredPermissions(getApplicationContext())) {
Log.w(TAG, "Health Connect required permissions not granted");
return Result.failure();
}
Track.Id trackId = new Track.Id(getInputData().getLong(TRACK_ID_KEY, -1L));
if (trackId.id() != -1L) {
return exportTrack(trackId);
}
return exportAll();
}
private Result exportTrack(Track.Id trackId) {
try {
export(getApplicationContext(), trackId);
return Result.success();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.retry();
} catch (Exception e) {
Log.e(TAG, "Health Connect export failed for track " + trackId, e);
return Result.failure();
}
}
private Result exportAll() {
List<Track> allTracks = new ContentProviderUtils(getApplicationContext())
.getTracks();
for (Track track : allTracks) {
if (isStopped()) {
Log.i(TAG, "Initial Health Connect sync stopped by WorkManager");
return Result.success();
}
try {
export(getApplicationContext(), track.id());
} catch (InterruptedException e) {
return Result.retry();
} catch (Exception e) {
Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e);
return Result.failure();
}
}
return Result.success();
}
private static ExportResult export(Context context, Track.Id trackId) throws InterruptedException {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
throw new RuntimeException("Track not found: " + trackId);
}
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
Metadata metadata = Metadata.activelyRecorded(
new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL),
track.uuid().toString()
);
List<Record> records = new ArrayList<>();
ExerciseData exerciseData = createExerciseData(contentProviderUtils, trackId);
//Avoid bug in HealthConnect: data must be within ExerciseSessionRecord.startTime and ExerciseSessionRecord.stopTime
List<ExerciseRoute.Location> locations = exerciseData.locations.stream()
.filter(i -> track.startTime().toInstant().isBefore(i.getTime()) && track.stopTime().toInstant().isAfter(i.getTime()))
.toList();
records.add(new ExerciseSessionRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
metadata,
toExerciseType(track.activityType()),
track.name(),
track.description(),
Collections.emptyList(),
Collections.emptyList(),
new ExerciseRoute(locations)));
records.add(new DistanceRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
Length.meters(track.statistics().totalDistance().toM()),
metadata
));
if (track.statistics().altitudeGainLoss() != null) {
records.add(new ElevationGainedRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
Length.meters(track.statistics().altitudeGainLoss().gain_m()),
metadata
));
}
if (!exerciseData.speeds.isEmpty()) {
records.add(new SpeedRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
exerciseData.speeds,
metadata
));
}
Object ignored = BuildersKt.runBlocking(
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
(Function2) (scope, cont) -> client.insertRecords(records, (Continuation) cont));
Log.i(TAG, "Exported track to Health Connect: " + track.name());
return new ExportResult(track.name());
}
private static ExerciseData createExerciseData(ContentProviderUtils contentProviderUtils, Track.Id trackId) {
List<ExerciseRoute.Location> locations = new LinkedList<>();
List<SpeedRecord.Sample> speeds = new LinkedList<>();
try (TrackPointIterator iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) {
while (iterator.hasNext()) { //TODO iterator.iterator.forEachRemaining()
TrackPoint trackPoint = iterator.next();
if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue; //TODO Include SEGMENT_START_AUTOMATIC?
Position position = trackPoint.position();
if (position.hasLocation()) {
//ExcerciseRoute does not allow duplicated timestamps.
if (locations.stream().noneMatch(location -> location.getTime().equals(position.time()))) {
locations.add(new ExerciseRoute.Location(
trackPoint.getTime(),
position.latitude(),
position.longitude(),
position.horizontalAccuracy() != null ? Length.meters(position.horizontalAccuracy().toM()) : null,
position.verticalAccuracy() != null ? Length.meters(position.verticalAccuracy().toM()) : null,
position.altitude() != null ? Length.meters(position.altitude().toM()) : null)
);
}
}
if (position.hasSpeed()) {
speeds.add(new SpeedRecord.Sample(
trackPoint.getTime(),
Velocity.metersPerSecond(position.speed().toMPS())
));
}
//TODO Cadence
//TODO Power
//TODO Heartrate
}
}
return new ExerciseData(locations, speeds);
}
private record ExerciseData(
List<ExerciseRoute.Location> locations,
List<SpeedRecord.Sample> speeds
) {}
private static int toExerciseType(ActivityType activityType) {
return switch (activityType) {
case RUNNING, STREET_RUNNING, TRAIL_RUNNING, TRACK_RUNNING -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING;
case BIKING, CYCLING, ROAD_BIKING, MOUNTAIN_BIKING, TRACK_CYCLING -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING;
case WALKING, SPEED_WALKING -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING;
case HIKING, OFF_TRAIL_HIKING, TRAIL_HIKING -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING;
case CROSS_COUNTRY_SKIING, SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING;
case SNOW_BOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING;
case KAYAKING -> ExerciseSessionRecord.EXERCISE_TYPE_PADDLING;
case ROWING -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE;
case SWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL;
case SWIMMING_OPEN -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER;
default -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT;
};
}
public record ExportResult(String trackName) {
}
}
@@ -9,7 +9,7 @@ import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
public class CyclingCadenceHandler extends SensorHandler<CyclingCadenceBluetooth.CrankData, Cadence> {
public class CyclingCadenceHandler extends SensorHandler<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
protected CyclingCadenceHandler(SensorManager sensorManager) {
super(sensorManager);
@@ -20,8 +20,6 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
*/
class GpsStatusManager {
private static final String TAG = GpsStatusManager.class.getSimpleName();
// The duration that GpsStatus waits from minimal interval to consider GPS lost.
@VisibleForTesting
public static final Duration SIGNAL_LOST_THRESHOLD = Duration.ofSeconds(30);
@@ -164,7 +164,7 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
cyclingDistanceSpeedHandler.connect();
}
if (PreferencesUtils.isKey(cyclingCadenceHandler.getSensorPreferenceKey(), key)) {
if (PreferencesUtils.isKey(cyclingPowerHandler.getSensorPreferenceKey(), key)) {
cyclingPowerHandler.connect();
}
@@ -9,6 +9,8 @@ import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
import de.dennisguse.opentracks.data.models.AtmosphericPressure;
@@ -17,12 +19,14 @@ public class BarometerInternal implements Driver {
private static final String TAG = BarometerInternal.class.getSimpleName();
private static final int SAMPLING_PERIOD = (int) TimeUnit.SECONDS.toMicros(5);
private static final Duration SAMPLING_PERIOD = Duration.ofSeconds(5);
private final DriverObserver<AtmosphericPressure> observer;
private android.hardware.SensorManager sensorService;
private Instant lastEventTime = Instant.EPOCH;
private final SensorEventListener sensorEventListener = new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
@@ -31,6 +35,13 @@ public class BarometerInternal implements Driver {
return;
}
Instant currentTime = Instant.now();
if (lastEventTime.plus(SAMPLING_PERIOD).isAfter(currentTime)) {
return;
}
lastEventTime = currentTime;
observer.onDataReceived(AtmosphericPressure.ofHPA(event.values[0]));
}
@@ -48,8 +59,9 @@ public class BarometerInternal implements Driver {
public void connect(Context context, Handler handler, String addressIgnored) {
sensorService = (android.hardware.SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
Sensor pressureSensor = sensorService.getDefaultSensor(Sensor.TYPE_PRESSURE);
int samplingPeriodUs = (int) TimeUnit.MICROSECONDS.convert(SAMPLING_PERIOD);
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, SAMPLING_PERIOD, handler)) {
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, samplingPeriodUs, handler)) {
observer.onConnected(null, null);
return;
}
@@ -0,0 +1,218 @@
package de.dennisguse.opentracks.sensors.driver;
import android.util.Log;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
// Implementation of metrics from a Bosch Smart System eBike via BLE.
// as described and implemented here: https://github.com/RobbyPee/Bosch-Smart-System-Ebike-Garmin-Android
//
// The bike exposes a single custom BLE service with one notification characteristic onto
// which all measurements are multiplexed as concatenated frames.
//
// ```
// 30 <length> <id-hi> <id-lo> [08 <varint...>]
// ```
//
// Frame format
//
// - `30` : start byte
// - `<length>` - number of payload bytes following (min 2, which means value = 0)
// - two-byte big-endian message ID
// - `08` : varint type tag (absent when value is 0)
// - varint : strip MSB from each byte, 7-bit groups, little-endian concatenation
//
// Multiple frames may be packed into a single characteristic notification.
//
public final class BoschEbikeParser {
private static final String TAG = BoschEbikeParser.class.getSimpleName();
private static final int MESSAGE_ID_CADENCE = 0x985A;
private static final int MESSAGE_ID_HUMAN_POWER = 0x985B;
private static final int MESSAGE_ID_SPEED = 0x982D;
private static final int MESSAGE_ID_MOTOR_POWER = 0x985D;
private static final int MESSAGE_ID_BATTERY = 0x8088;
private static final int MESSAGE_ID_ASSIST_MODE = 0x9809;
private static final int START_BYTE = 0x30;
private static final int VARINT_TAG = 0x08;
private static final int MAX_VARINT_32_BIT_BYTES = 5;
public static final ServiceMeasurementUUID BOSCH_EBIKE = new ServiceMeasurementUUID(
UUID.fromString("00000010-eaa2-11e9-81b4-2a2ae2dbcce4"),
UUID.fromString("00000011-eaa2-11e9-81b4-2a2ae2dbcce4"));
private BoschEbikeParser() {
}
/**
* Decodes a single varint from {@code data}. Uses little-endian 7-bit groups; MSB=1 means another byte follows.
*/
private static Integer decodeVarint(byte[] data) {
if (data == null || data.length == 0) {
Log.d(TAG, "Malformed varint: empty payload");
return null;
}
int result = 0;
for (int i = 0; i < data.length; i++) {
if (i >= MAX_VARINT_32_BIT_BYTES) {
Log.d(TAG, "Malformed varint: exceeds 32-bit limit after " + i + " bytes");
return null;
}
int byteValue = data[i] & 0x7F;
result |= byteValue << (7 * i);
if ((data[i] & 0x80) == 0) {
return result;
}
}
Log.d(TAG, "Malformed varint: truncated after " + data.length + " bytes");
return null;
}
/**
* Parses one Bosch notification payload, which may contain multiple concatenated frames, and returns the measurements recognized in that payload.
*/
static Data parse(byte[] raw) {
if (raw == null || raw.length == 0) {
return null;
}
Data data = splitFrames(raw).stream()
.filter(BoschEbikeParser::isValidFrame)
.filter(BoschEbikeParser::isSupportedFrame)
.map(BoschEbikeParser::decodeFrame)
.filter(Objects::nonNull)
.reduce(null,
BoschEbikeParser::processMessage,
(left, right) -> right);
Log.d(TAG, "Parsed Bosch data: " + data);
return data;
}
private static List<byte[]> splitFrames(byte[] raw) {
List<byte[]> frames = new ArrayList<>();
int i = 0;
while (i < raw.length) {
if (raw[i] != START_BYTE) {
i++;
continue;
}
if (i + 1 >= raw.length) {
Log.d(TAG, "Malformed frame at offset " + i + ": missing length byte (raw length=" + raw.length + ")");
i++;
continue;
}
int length = raw[i + 1] & 0xFF;
int frameEnd = i + length + 2;
frames.add(Arrays.copyOfRange(raw, i, Math.min(frameEnd, raw.length)));
i = frameEnd;
}
return frames;
}
private static boolean isValidFrame(byte[] frame) {
int length = getFrameLength(frame);
if (length < 2) {
Log.d(TAG, "Malformed frame: payload length " + length + " is shorter than minimum 2 bytes");
return false;
}
if (frame.length != length + 2) {
Log.d(TAG, "Malformed frame: expected " + (length + 2) + " bytes, got " + frame.length);
return false;
}
return true;
}
private static boolean isSupportedFrame(byte[] frame) {
boolean supported = getFrameLength(frame) == 2 || (frame[4] & 0xFF) == VARINT_TAG;
if (!supported) {
Log.d(TAG, "Unsupported frame: tag 0x" + String.format("%02X", frame[4] & 0xFF));
}
return supported;
}
@Nullable
private static DecodedFrame decodeFrame(byte[] frame) {
if (getFrameLength(frame) == 2) {
return new DecodedFrame(getMessageId(frame), 0);
}
Integer varintResult = decodeVarint(Arrays.copyOfRange(frame, 5, frame.length));
if (varintResult == null) {
return null;
}
return new DecodedFrame(getMessageId(frame), varintResult);
}
private static int getFrameLength(byte[] frame) {
return frame[1] & 0xFF;
}
private static int getMessageId(byte[] frame) {
return (frame[2] & 0xFF) << 8 | (frame[3] & 0xFF);
}
/**
* Processes a message ID and value, updating the data with any measurements.
*/
@Nullable
private static Data processMessage(@Nullable Data data, DecodedFrame decodedFrame) {
switch (decodedFrame.messageId()) {
case MESSAGE_ID_CADENCE:
return new Data(null, Cadence.of(decodedFrame.value() / 2.0f), null, data);
case MESSAGE_ID_HUMAN_POWER:
return new Data(null, null, Power.of((float) decodedFrame.value()), data);
case MESSAGE_ID_SPEED:
double speed_kmh = decodedFrame.value() / 100.0;
return new Data(Speed.ofKMH(speed_kmh), null, null, data);
case MESSAGE_ID_MOTOR_POWER:
case MESSAGE_ID_BATTERY:
case MESSAGE_ID_ASSIST_MODE:
Log.d(TAG, "Ignoring unimplemented message: raw=" + decodedFrame.value());
return data;
}
Log.d(TAG, "Ignoring unknown message: raw=" + decodedFrame.value());
return data;
}
public record Data(
@Nullable Speed speed,
@Nullable Cadence cadence,
@Nullable Power humanPower) {
public Data(@Nullable Speed speed, @Nullable Cadence cadence, @Nullable Power humanPower, @Nullable Data oldData) {
this(
speed != null ? speed : oldData != null ? oldData.speed() : null,
cadence != null ? cadence : oldData != null ? oldData.cadence() : null,
humanPower != null ? humanPower : oldData != null ? oldData.humanPower() : null);
}
}
private record DecodedFrame(int messageId, int value) {
}
}
@@ -6,23 +6,25 @@ import android.util.Pair;
import java.util.List;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<CyclingCadenceBluetooth.CrankData> {
public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<CyclingCadenceBluetooth.CyclingCadenceMeasurement> {
private static final String TAG = CyclingCadenceBluetooth.class.getSimpleName();
public static final List<ServiceMeasurementUUID> CYCLING_CADENCE = List.of(
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
CyclingPowerBluetooth.CYCLING_POWER,
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE,
BoschEbikeParser.BOSCH_EBIKE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return CYCLING_CADENCE;
return SUPPORTED_SERVICES;
}
@Override
public CrankData parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
public CyclingCadenceMeasurement parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
if (serviceMeasurementUUID.equals(CyclingPowerBluetooth.CYCLING_POWER)) {
CyclingPowerBluetooth.Data data = CyclingPowerBluetooth.parseCyclingPower(characteristic);
if (data != null && data.crank() != null) {
@@ -38,12 +40,28 @@ public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<
}
}
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
if (boschData != null && boschData.cadence() != null) {
return new DirectCadenceData(boschData.cadence());
}
}
Log.e(TAG, "Don't know how to decode this payload.");
return null;
}
public sealed interface CyclingCadenceMeasurement permits CrankData, DirectCadenceData {
}
public record CrankData(
long crankRevolutionsCount, // UINT32
int crankRevolutionsTime // UINT16; 1/1024s
) {}
) implements CyclingCadenceMeasurement {
}
public record DirectCadenceData(
Cadence cadence
) implements CyclingCadenceMeasurement {
}
}
@@ -18,14 +18,28 @@ public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser<Cy
new UUID(0x2A6300001000L, 0x800000805f9b34fbL)
);
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
CYCLING_POWER,
BoschEbikeParser.BOSCH_EBIKE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return List.of(CYCLING_POWER);
return SUPPORTED_SERVICES;
}
@Override
public CyclingPowerBluetooth.Data parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
return parseCyclingPower(characteristic);
if (serviceMeasurementUUID.equals(CYCLING_POWER)) {
return parseCyclingPower(characteristic);
}
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
if (boschData != null && boschData.humanPower() != null) {
return new Data(boschData.humanPower(), null);
}
}
return null;
}
@@ -63,12 +63,13 @@ public abstract class Aggregator<InputType, OutputType> {
*/
protected void resetOutdated() {
output = null;
};
}
/**
* Reset long-term (i.e., aggregated) values (more than derived from previous SensorData) like overall distance.
*/
public void resetAggregated() {};
public void resetAggregated() {
}
/**
* Is the data recent considering the current time.
@@ -10,7 +10,7 @@ import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.UintUtils;
import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CrankData, Cadence> {
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
private final String TAG = AggregatorCyclingCadence.class.getSimpleName();
@@ -19,12 +19,20 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
}
@Override
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CrankData> current) {
if (previous == null) {
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CyclingCadenceMeasurement> current) {
if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) {
output = directCadenceData.cadence();
return;
}
float timeDiff_ms = UintUtils.diff(current.value().crankRevolutionsTime(), previous.value().crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value());
}
private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw) {
CyclingCadenceBluetooth.CrankData previousRaw = getPreviousRaw();
if (previousRaw == null) return;
float timeDiff_ms = UintUtils.diff(currentRaw.crankRevolutionsTime(), previousRaw.crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
if (timeDiff.isZero()) {
@@ -37,13 +45,21 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
}
// TODO We have to treat with overflow according to the documentation: read https://codeberg.org/OpenTracksApp/OpenTracks/pulls/953#issuecomment-6466930
if (current.value().crankRevolutionsCount() < previous.value().crankRevolutionsCount()) {
if (currentRaw.crankRevolutionsCount() < previousRaw.crankRevolutionsCount()) {
Log.e(TAG, "Crank revolutions count difference is invalid: cannot compute cadence.");
output = null;
return;
}
long crankDiff = UintUtils.diff(current.value().crankRevolutionsCount(), previous.value().crankRevolutionsCount(), UintUtils.UINT32_MAX);
long crankDiff = UintUtils.diff(currentRaw.crankRevolutionsCount(), previousRaw.crankRevolutionsCount(), UintUtils.UINT32_MAX);
output = Cadence.of(crankDiff, timeDiff);
}
private CyclingCadenceBluetooth.CrankData getPreviousRaw() {
if (previous == null) {
return null;
}
return previous.value() instanceof CyclingCadenceBluetooth.CrankData raw ? raw : null;
}
}
@@ -0,0 +1,56 @@
package de.dennisguse.opentracks.services;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import de.dennisguse.opentracks.data.models.Track;
/**
* For handling restarts of TrackRecordingService: while recording, stores the {@link de.dennisguse.opentracks.data.models.Track.Id}.
*/
class TrackIdStorage {
private final static String SHARED_PREFERENCE_NAME = "TrackRecordingService_pref";
private final static String KEY = "trackId_key";
private static SharedPreferences sharedPreferences;
private static void init(Context context) {
if (sharedPreferences == null) {
sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE);
}
}
@Nullable
public static Track.Id get(Context context) {
TrackIdStorage.init(context);
long value = sharedPreferences.getLong(KEY, -1);
if (value < 0) {
return null;
}
return new Track.Id(value);
}
public static void set(Context context, @NonNull Track.Id trackId) {
TrackIdStorage.init(context);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putLong(KEY, trackId.id());
editor.commit();
}
public static void unset(Context context) {
TrackIdStorage.init(context);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.remove(KEY);
editor.commit();
}
}
@@ -67,7 +67,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
contentProviderUtils = new ContentProviderUtils(context);
}
Track.Id startNewTrack() {
synchronized Track.Id startNewTrack() {
TrackPoint segmentStartTrackPoint = trackPointCreator.createSegmentStartManual();
ZoneOffset zoneOffset = ZoneOffset.systemDefault().getRules().getOffset(segmentStartTrackPoint.getTime());
@@ -103,7 +103,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
/**
* @return if the recording could be started.
*/
boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
synchronized boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
trackId = resumeTrackId;
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
@@ -119,7 +119,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
return true;
}
void endCurrentTrack() {
synchronized void endCurrentTrack() {
TrackPoint segmentEnd = trackPointCreator.createSegmentEnd();
insertTrackPoint(segmentEnd, true);
@@ -149,7 +149,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public void onIdle() {
public synchronized void onIdle() {
Log.d(TAG, "Becoming idle");
onNewTrackPoint(trackPointCreator.createIdle());
@@ -86,7 +86,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
private MutableLiveData<GpsStatusValue> gpsStatusObservable;
private MutableLiveData<RecordingData> recordingDataObservable;
// The following variables are set when recording:
private Handler handler;
private TrackPointCreator trackPointCreator;
@@ -119,9 +118,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
@Override
public void onDestroy() {
Log.d(TAG, "Destroying");
if (isRecording()) {
endCurrentTrack();
}
if (trackPointCreator != null && trackPointCreator.isStarted()) {
stopSensors();
}
@@ -150,6 +146,16 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
Log.i(TAG, "Service got killed and is restarting");
//Best guess: do we need to resume a recording?
Track.Id trackId = TrackIdStorage.get(this);
if (trackId != null) {
Log.i(TAG, "After restart: resuming recording for " + trackId);
resumeTrack(trackId);
}
}
return START_STICKY;
}
@@ -167,9 +173,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
// Set recording status
Track.Id trackId = trackRecordingManager.startNewTrack();
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
startRecording(trackId);
return trackId;
}
@@ -180,12 +184,13 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
}
Log.i(TAG, "resumeTrack");
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
startRecording(trackId);
}
private void startRecording() {
private void startRecording(Track.Id trackId) {
updateRecordingStatus(RecordingStatus.record(trackId));
TrackIdStorage.set(this, trackId);
startSensors();
voiceAnnouncementManager.start(trackRecordingManager.getTrackStatistics());
@@ -226,6 +231,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
updateRecordingStatus(STATUS_DEFAULT);
trackRecordingManager.endCurrentTrack();
TrackIdStorage.unset(this);
stopUpdateRecordingData();
@@ -0,0 +1,54 @@
package de.dennisguse.opentracks.settings;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.PermissionController;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
public class HealthConnect {
public static class Settings extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startActivity(new Intent(HealthConnectClient.getHealthConnectSettingsAction()));
finish();
}
}
public static class ConfigurePermissions extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!HealthConnectUtils.isAvailable(this)) {
finish();
}
registerForActivityResult(
PermissionController.createRequestPermissionResultContract(),
grantedPermissions -> {
// TODO User feedback via Toast.makeText()?
finish();
}
).launch(HealthConnectUtils.PERMISSIONS);
}
}
public static class ExportAll extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
HealthConnectUtils.postExportAll(this);
finish();
}
}
}
@@ -14,11 +14,11 @@ import java.util.Map;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.util.IntentUtils;
public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
private static final String TAG = ImportExportSettingsFragment.class.getSimpleName();
public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
@@ -40,9 +40,20 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
super.onResume();
setExportDirectorySummary();
findPreference(getString(R.string.post_workout_export_enabled_key)).setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
setHealthConnectPreference();
}
private void setHealthConnectPreference() {
boolean healthConnectAvailable = HealthConnectUtils.isAvailable(requireContext());
findPreference("healthConnectCategory")
.setEnabled(healthConnectAvailable);
if (!healthConnectAvailable) {
findPreference(getString(R.string.post_workout_export_health_connect_enabled_key))
.setSummary(R.string.health_connect_not_available);
}
}
private void setExportTrackFileFormatOptions() {
@@ -71,6 +71,7 @@ public class PreferencesUtils {
private static SharedPreferences sharedPreferences;
private static Resources resources;
private static final long LONG_UNSET = Long.MIN_VALUE;
/**
* Must be called during application startup.
@@ -168,23 +169,38 @@ public class PreferencesUtils {
}
}
private static long getLong(int keyId, long defaultValue) {
try {
return sharedPreferences.getLong(getKey(keyId), defaultValue);
} catch (ClassCastException e) {
// Ignore.
}
try {
String stringValue = sharedPreferences.getString(getKey(keyId), null);
return Long.parseLong(stringValue);
} catch (NumberFormatException e) {
return defaultValue;
}
}
public static String getString(int keyId, String defaultValue) {
return sharedPreferences.getString(getKey(keyId), defaultValue);
}
@VisibleForTesting
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static void setString(int keyId, String value) {
Editor editor = sharedPreferences.edit();
editor.putString(getKey(keyId), value);
editor.apply();
}
@VisibleForTesting
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static void setString(int keyId, int valueId) {
setString(keyId, resources.getString(valueId));
}
@VisibleForTesting
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static void setBoolean(int keyId, Boolean value) {
Editor editor = sharedPreferences.edit();
editor.putBoolean(getKey(keyId), value);
@@ -197,6 +213,12 @@ public class PreferencesUtils {
editor.apply();
}
private static void setLong(int keyId, long value) {
Editor editor = sharedPreferences.edit();
editor.putLong(getKey(keyId), value);
editor.apply();
}
public static boolean isPublicAPIenabled() {
return getBoolean(R.string.publicapi_enabled_key, resources.getBoolean(R.bool.publicapi_enabled_default));
}
@@ -328,6 +350,11 @@ public class PreferencesUtils {
return getBoolean(R.string.voice_announce_unit_key, true);
}
@VisibleForTesting
public static void setVoiceAnnounceUnit(boolean value) {
setBoolean(R.string.voice_announce_unit_key, value);
}
public static Duration getVoiceAnnouncementFrequency() {
final int DEFAULT = Integer.parseInt(resources.getString(R.string.voice_announcement_frequency_default));
int value = getInt(R.string.voice_announcement_frequency_key, DEFAULT);
@@ -422,7 +449,7 @@ public class PreferencesUtils {
}
public static boolean shouldVoiceAnnounceLapPower() {
return getBoolean(R.string.voice_announce_lap_power_key, false);
return getBoolean(R.string.voice_announce_lap_power_key, getBoolean(R.bool.voice_announce_lap_power_default, false));
}
public static boolean shouldVoiceAnnounceHeartRateCurrent() {
@@ -655,12 +682,16 @@ public class PreferencesUtils {
return entries;
}
public static boolean shouldInstantExportAfterWorkout() {
public static boolean shouldInstantExportToFile() {
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri();
}
public static boolean shouldInstantExportToHealthConnect() {
final boolean DEFAULT = resources.getBoolean(R.bool.post_workout_export_health_connect_enabled_default);
return getBoolean(R.string.post_workout_export_health_connect_enabled_key, DEFAULT);
}
public static TrackFilenameGenerator getTrackFileformatGenerator() {
String DEFAULT = resources.getString(R.string.export_filename_format_default);
TrackFilenameGenerator generator = new TrackFilenameGenerator(getString(R.string.export_filename_format_key, DEFAULT));
@@ -748,11 +779,13 @@ public class PreferencesUtils {
sharedPreferences.edit().clear().commit();
}
// Each setting file must be mentioned here.
// Each setting file must be mentioned here: see xml/settings.xml
PreferenceManager.setDefaultValues(context, R.xml.settings, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_defaults, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_user_interface, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_recording, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_sensors, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_announcements, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_import_export, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_public_api, readAgain);
}
@@ -96,6 +96,7 @@ public class BluetoothLeAdapter extends BaseAdapter {
this.name = name;
}
@NonNull
public String getAddress() {
return address;
}
@@ -27,6 +27,6 @@ public class BluetoothLeCyclingCadencePreference extends BluetoothLeSensorPrefer
@Override
public PreferenceDialogFragmentCompat createInstance() {
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.CYCLING_CADENCE);
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.SUPPORTED_SERVICES);
}
}
@@ -4,9 +4,6 @@ import android.content.Context;
import android.util.AttributeSet;
import androidx.preference.PreferenceDialogFragmentCompat;
import java.util.List;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreference {
@@ -29,6 +26,8 @@ public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreferen
@Override
public PreferenceDialogFragmentCompat createInstance() {
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(getKey(), List.of(CyclingPowerBluetooth.CYCLING_POWER));
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(
getKey(),
CyclingPowerBluetooth.SUPPORTED_SERVICES);
}
}
}
@@ -191,26 +191,6 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
List<ParcelUuid> serviceUUIDs = getArguments().getParcelableArrayList(ARG_BLE_SERVICE_UUIDS);
boolean includeInternalSensor = getArguments().getBoolean(ARG_INCLUDE_INTERNAL);
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled.");
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
dismiss();
return;
}
if (bluetoothAdapter.isDiscovering()) {
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
bluetoothAdapter.cancelDiscovery();
}
scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner == null) {
Log.e(TAG, "BluetoothLeScanner is null.");
dismiss();
return;
}
listAdapter.add(SensorType.NONE.getPreferenceValue(), getContext().getString(DEVICE_NONE_RESOURCEID));
selectedEntryIndex = 0;
@@ -228,8 +208,37 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
selectedEntryIndex = !includeInternalSensor ? 1 : 2;
}
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled. Skipping BLE scan.");
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
if (!includeInternalSensor) {
dismiss();
}
return;
}
if (bluetoothAdapter.isDiscovering()) {
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
bluetoothAdapter.cancelDiscovery();
}
scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner == null) {
Log.e(TAG, "BluetoothLeScanner is null.");
dismiss();
return;
}
// Pre-populate with already-bonded BLE devices so that devices that are paired but not actively advertising (e.g. Bosch eBike) are still shown. In case we dont have any cached UUIDs for a device, we just add it to the list and let the user decide
boolean filterEnabled = PreferencesUtils.getBluetoothFilterEnabled();
bluetoothAdapter.getBondedDevices().stream()
.filter(bonded -> bonded.getType() == BluetoothDevice.DEVICE_TYPE_LE || bonded.getType() == BluetoothDevice.DEVICE_TYPE_DUAL)
.filter(bonded -> !filterEnabled || bonded.getUuids() == null || java.util.Arrays.stream(bonded.getUuids()).anyMatch(serviceUUIDs::contains))
.forEach(bonded -> listAdapter.add(bonded.getAddress(), bonded.getName()));
List<ScanFilter> scanFilter = null;
if (PreferencesUtils.getBluetoothFilterEnabled()) {
if (filterEnabled) {
scanFilter = serviceUUIDs.stream()
.map(it -> new ScanFilter.Builder().setServiceUuid(it).build())
.toList();
@@ -7,13 +7,51 @@ import android.widget.ArrayAdapter;
import androidx.annotation.NonNull;
import java.time.ZoneOffset;
import java.util.stream.IntStream;
/**
* <a href="Data source for UTC Offsets">https://en.wikipedia.org/wiki/List_of_UTC_offsets</a>
*/
public class ZoneOffsetAdapter extends ArrayAdapter<ZoneOffset> {
public ZoneOffsetAdapter(@NonNull Context context, int resource) {
super(context, resource, IntStream.range(-12, +12)
.boxed()
.map(ZoneOffset::ofHours)
.toList());
super(context, resource);
addAll(
ZoneOffset.ofHoursMinutes(-12, 0),
ZoneOffset.ofHoursMinutes(-11, 0),
ZoneOffset.ofHoursMinutes(-10, 0),
ZoneOffset.ofHoursMinutes(-9, -30),
ZoneOffset.ofHoursMinutes(-8, 0),
ZoneOffset.ofHoursMinutes(-7, 0),
ZoneOffset.ofHoursMinutes(-6, 0),
ZoneOffset.ofHoursMinutes(-5, 0),
ZoneOffset.ofHoursMinutes(-4, 0),
ZoneOffset.ofHoursMinutes(-3, -30),
ZoneOffset.ofHoursMinutes(-3, 0),
ZoneOffset.ofHoursMinutes(-2, 0),
ZoneOffset.ofHoursMinutes(-1, 0),
ZoneOffset.ofHoursMinutes(0, 0),
ZoneOffset.ofHoursMinutes(1, 0),
ZoneOffset.ofHoursMinutes(2, 0),
ZoneOffset.ofHoursMinutes(3, 0),
ZoneOffset.ofHoursMinutes(3, 30),
ZoneOffset.ofHoursMinutes(4, 0),
ZoneOffset.ofHoursMinutes(4, 30),
ZoneOffset.ofHoursMinutes(5, 30),
ZoneOffset.ofHoursMinutes(5, 45),
ZoneOffset.ofHoursMinutes(6, 0),
ZoneOffset.ofHoursMinutes(6, 30),
ZoneOffset.ofHoursMinutes(7, 0),
ZoneOffset.ofHoursMinutes(8, 0),
ZoneOffset.ofHoursMinutes(8, 45),
ZoneOffset.ofHoursMinutes(9, 0),
ZoneOffset.ofHoursMinutes(9, 30),
ZoneOffset.ofHoursMinutes(10, 0),
ZoneOffset.ofHoursMinutes(10, 30),
ZoneOffset.ofHoursMinutes(11, 0),
ZoneOffset.ofHoursMinutes(12, 0),
ZoneOffset.ofHoursMinutes(12, 45),
ZoneOffset.ofHoursMinutes(13, 0),
ZoneOffset.ofHoursMinutes(14, 0)
);
}
}
@@ -136,12 +136,6 @@ public class FilterDialogFragment extends DialogFragment {
public final String value;
public boolean isChecked;
public FilterItem(String id, String value) {
this.id = id;
this.value = value;
this.isChecked = true;
}
public FilterItem(String id, String value, boolean isChecked) {
this.id = id;
this.value = value;
@@ -1,16 +0,0 @@
package de.dennisguse.opentracks.ui.customRecordingLayout;
public enum CustomLayoutFieldType {
GENERIC(1),
CLOCK(2);
private final int value;
CustomLayoutFieldType(int value) {
this.value = value;
}
public int value() {
return this.value;
}
}
@@ -17,12 +17,12 @@ import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Statistics;
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.UnitSystem;
/**
@@ -85,7 +85,7 @@ public class IntervalStatisticsModel extends AndroidViewModel {
loadIntervalStatistics(trackId);
}
};
contentResolver.registerContentObserver(TrackPointsColumns.CONTENT_URI_BY_TRACKID, false, trackPointsTableObserver);
contentResolver.registerContentObserver(CustomContentProvider.UrlType.TRACKPOINTS_BY_TRACKID.getUri(), false, trackPointsTableObserver);
return intervalsLiveData;
}
@@ -3,7 +3,6 @@ package de.dennisguse.opentracks.util;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.util.Iterator;
import java.util.NoSuchElementException;
@@ -54,7 +53,6 @@ public abstract class AutoCloseableIterator<T> implements Iterator<T>, AutoClose
return get();
}
@VisibleForTesting
public final int getCount() {
return cursor.getCount();
}
@@ -27,7 +27,6 @@ import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.List;
@@ -105,10 +104,6 @@ public class FileUtils {
return fileName.substring(index + 1);
}
public static String getExtension(DocumentFile file) {
return getExtension(file.getName());
}
public static String getExtension(Uri uri) {
return getExtension(uri.toString());
}
@@ -36,8 +36,6 @@ public class IntentUtils {
private static final String TAG = IntentUtils.class.getSimpleName();
private static final String JPEG_EXTENSION = "jpeg";
private IntentUtils() {
}
@@ -191,7 +191,7 @@ public class StringUtils {
* @param text the given text
*/
public static String formatCData(String text) {
return "<![CDATA[" + text.replaceAll("]]>", "]]]]><![CDATA[>") + "]]>";
return "<![CDATA[" + text.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
/**
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/health_connect_activity_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/app_name" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="20dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
style="?attr/textAppearanceHeadline6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/health_connect_permissions_rationale_heading" />
<TextView
style="?attr/textAppearanceBody1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/health_connect_permissions_rationale_summary" />
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
+1 -5
View File
@@ -125,7 +125,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">في انتظار إشارة أفضل ‏‫لنظام تحديد المواقع العالمي (GPS)‬</string>
<string name="gps_wait_for_signal">في انتظار إشارة نظام تحديد المواقع العالمي (GPS)‬</string>
<!-- Image -->
<string name="image_arrow">سهم</string>
<string name="image_marker_count">عدد العلامات</string>
<string name="image_photo_marker">علامة الصورة</string>
<string name="image_record">تسجيل</string>
@@ -386,7 +385,6 @@ limitations under the License.
<item quantity="many">اكتمل مع %1$d خطأ</item>
<item quantity="other">اكتمل مع %1$d خطأ</item>
</plurals>
<string name="voice_announce_lap_power_key">الإعلان الصوتي عن قوة اللفة voiceAnnounceLapPower</string>
<string name="settings_defaults_title">الوحدات الافتراضية والنشاط</string>
<string name="settings_defaults_summary">الوحدات المفضلة، النشاط الافتراضي، اسم المسار الافتراضي</string>
<string name="settings_announcements_summary">الوقت، المسافة، سرعة الصوت</string>
@@ -457,7 +455,7 @@ limitations under the License.
<string name="settings_recording_title">التسجيل</string>
<string name="settings_public_api_enabled_title">واجهة برمجة التطبيقات العامة</string>
<string name="settings_public_api_disabled_toast">واجهة برمجة التطبيقات العامة لـ المسارات المفتوحة معطلة: يمكن تفعيلها في الإعدادات.</string>
<string name="settings_export_section">التصدير</string>
<string name="settings_export_as_file_section">التصدير</string>
<string name="instant_export_enabled_summary">تصدير المسار إلى التخزين بعد انتهاء التسجيل</string>
<string name="create_marker_error">غير قادر على إنشاء علامة. حاول مرة أخرى.</string>
<string name="sensor_state_heart_rate_avg">متوسط معدل ضربات القلب</string>
@@ -503,7 +501,6 @@ limitations under the License.
<string name="help_feature_content">* إنشاء مسارات GPS \n* استخدام مستشعر معدل ضربات القلب (Bluetooth LE فقط) \n* إعلانات صوتية (حسب الوقت أو المسافة) \n* إنشاء علامات (بما في ذلك الصور) \n* يعمل بدون اتصال بالإنترنت \n* عرض المسارات على خريطة (انظر أدناه)</string>
<string name="settings_recording_fullscreen_on_while_recording_summary">أثناء التسجيل، اعرضه في وضع ملء الشاشة.</string>
<string name="settings_gps_summary">الفاصل الزمني، فاصل المسافة، أقصى مسافة، الدقة</string>
<string name="open_track_as_trackfileformat">فتح كـ %1$s</string>
<string name="sensor_state_power_unit">واط</string>
<string name="about_url">صفحة المشروع: <a href="%1$s">%1$s</a></string>
<string name="help_data_google_play_services_title">هل يتطلب المسارات المفتوحة استخدام خدمات Google Play؟</string>
@@ -548,7 +545,6 @@ limitations under the License.
<string name="report_bug">الإبلاغ عن خطأ</string>
<string name="show_on_dashboard">واجهة برمجة تطبيقات لوحة تحكم المسارات المفتوحة</string>
<string name="show_on_dashboard_not_installed">لا يوجد تطبيق يدعم واجهة برمجة تطبيقات لوحة تحكم المسارات المفتوحة مثبت.</string>
<string name="always">دائمًا</string>
<string name="settings_announcements_lap_power">طاقة الدورة</string>
<string name="power_x_watt">الطاقة {x, plural, =1 {1 واط} other {# واط} }</string>
<string name="power">الطاقة</string>
-1
View File
@@ -103,7 +103,6 @@
<string name="gps_starting">Starting GPS</string>
<string name="gps_wait_for_better_signal">Waiting for a better GPS signal</string>
<string name="gps_wait_for_signal">Waiting for GPS signal</string>
<string name="image_arrow">Arrow</string>
<string name="image_marker_count">Marker count</string>
<string name="image_photo_marker">Photo marker</string>
<string name="image_record">Record</string>
+101 -9
View File
@@ -103,7 +103,6 @@
<string name="gps_starting">Iniciando GPS</string>
<string name="gps_wait_for_better_signal">Esperando una mejor señal de GPS</string>
<string name="gps_wait_for_signal">Esperando señal de GPS</string>
<string name="image_arrow">Flecha</string>
<string name="image_marker_count">Recuento de marcadores</string>
<string name="image_photo_marker">Marcador de foto</string>
<string name="image_record">Grabar</string>
@@ -262,8 +261,8 @@
<string name="share_image_subject">Me gustaría compartir una imagen contigo</string>
<string name="share_image_body">Creo que esta imagen podría interesarte.</string>
<string name="about_preference_title">Acerca de OpenTracks</string>
<string name="about_description"><i> OpenTracks </i> es una aplicación de seguimiento deportivo que respeta completamente su privacidad.</string>
<string name="about_url">Página web del proyecto: <a href="%1$s">%1$s</a></string>
<string name="about_description"><i> OpenTracks </i> es una aplicación de seguimiento deportivo que respeta completamente tu privacidad.</string>
<string name="about_url">Sitio web del proyecto: <a href="%1$s">%1$s</a></string>
<string name="about_version_name">Nombre de la versión: %1$s</string>
<string name="bluetooth_disabled">Por favor habilite Bluetooth.</string>
<string name="settings_default_trackfileformat">Formato para exportar/compartir</string>
@@ -320,10 +319,7 @@
<string name="track_distance_notification">Distancia: %1$s</string>
<string name="aggregated_stats_empty_message">Grabe su primer recorrido para ver estadísticas agregadas</string>
<string name="settings_default_export_uri_title">Directorio de exportación de recorrido</string>
<string name="help_track_map_content"> Utilice el pequeño icono de mapa.
\na) (actualizaciones en vivo) Instale <a href="https://codeberg.org/OpenTracksApp/OSMDashboard"> OSMDashboard </a>. Proporciona mapas en línea o fuera de línea a través de <a href="https://www.openstreetmap.org"> OpenStreetMap </a>.
\nEn realidad, OSMDashboard es una aplicación separada que solo puede acceder (temporalmente) a los datos de los recorridos que se mostrarán.
\nb) A través de una aplicación de mapas normal (p. ej., <a href="http://osmand.net/"> OsmAnd </a> y <a href="https://maps.me/"> MAPS.ME </a>). Estas aplicaciones deben admitir el formato de archivo KMZ (solo ubicaciones y marcas de tiempo). De hecho, una aplicación debe admitir <i> ACTION_VIEW </i> con MIME <i> application / vnd.google-earth.kmz</i>. </string>
<string name="help_track_map_content">Utilice el pequeño icono de mapa. \na) (actualizaciones en vivo) Instale <a href="https://codeberg.org/OpenTracksApp/OSMDashboard"> OSMDashboard </a>. Proporciona mapas con o sin conexión a través de <a href="https://www.openstreetmap.org"> OpenStreetMap </a>. \nEn realidad, OSMDashboard es una aplicación separada que solo puede acceder (temporalmente) a los datos de los recorridos que se mostrarán. \nb) A través de una aplicación de mapas normal (p. ej., <a href="http://osmand.net/"> OsmAnd </a> y <a href="https://maps.me/"> MAPS.ME </a>). Estas aplicaciones deben admitir el formato de archivo KMZ (solo ubicaciones y marcas de tiempo). De hecho, una aplicación debe admitir <i> ACTION_VIEW </i> con MIME <i>application/vnd.google-earth.kmz</i>.</string>
<string name="about_third_party_data">Datos de terceros</string>
<string name="about_libraries">Bibliotecas</string>
<string name="help_feature_title">OpenTracks tiene las siguientes características:</string>
@@ -344,7 +340,6 @@
<string name="help_data_storage_title">¿Cómo almacena OpenTracks los datos de los sensores\?</string>
<string name="help_gps_troubleshooting_title">¿OpenTracks no registra ubicaciones\?</string>
<string name="help_support_title">¿Cómo puedo ayudar a OpenTracks\?</string>
<string name="open_track_as_trackfileformat">Abrir como %1$s</string>
<string name="activity_type_escooter">moto</string>
<string name="description_speed_ms">Velocidad (m/s)</string>
<string name="description_default_speed_or_pace">Por tipo de actividad</string>
@@ -430,7 +425,7 @@
<string name="settings_gps_summary">Intervalo de tiempo, Intervalo de distancia, Distancia máxima, Precisión</string>
<string name="settings_sensors_title">Sensores</string>
<string name="settings_sensors_summary">Sensores de ciclismo, Circunferencia de la rueda, Sensores de carrera</string>
<string name="settings_export_section">Exportar</string>
<string name="settings_export_as_file_section">Exportar</string>
<string name="activity_type_swimming">Natación</string>
<string name="activity_type_swimming_open">Natación en aguas abiertas</string>
<string name="activity_type_workout">Entrenamiento</string>
@@ -483,4 +478,101 @@
<string name="image_discard">Descartar</string>
<string name="menu_export_all_one_file">Exportar todo (un archivo para todas las pistas)</string>
<string name="permission_recording_failed">OpenTracks requiere permisos adicionales para acceder a tu ubicación y a tus dispositivos cercanos.</string>
<string name="activity_type_gravel_biking">Ciclismo en grava</string>
<string name="description_sensor_temperature">Temperatura (Celsius)</string>
<string name="create_marker_error">No se pudo crear un marcador. Inténtalo nuevamente.</string>
<string name="marker_detail_title">%1$d de %2$d: %3$s</string>
<string name="menu_export_all_to_health_connect">Exportar todo</string>
<string name="sensor_state_cadence_unit">rpm</string>
<string name="sensor_state_heart_rate_unit">ppm</string>
<string name="sensor_state_temperature">Termómetro</string>
<string name="sensor_state_pressure">Barómetro</string>
<string name="sensor_state_power_max">Potencia máxima</string>
<string name="sensor_state_power_unit">W</string>
<string name="bluetooth_sensor_summary">%1$s (%2$s)</string>
<string name="settings_announcements_idle_category_title">Anuncios de espera</string>
<string name="settings_announcements_idle_title">Espera</string>
<string name="settings_charts_title">Gráficas</string>
<string name="settings_chart_display_elevation_title">Mostrar elevación</string>
<string name="settings_chart_display_pace_or_speed_title">Mostrar ritmo o velocidad</string>
<string name="settings_chart_display_heart_rate_title">Mostrar frecuencia cardiaca</string>
<string name="settings_recording_title">Registro</string>
<string name="settings_theme_switch_restart">Los cambios en el tema pueden requerir un reinicio manual.</string>
<string name="settings_ui_dynamic_colors_title">Colores dinámicos</string>
<string name="settings_ui_dynamic_colors_summary">Usar colores dinámicos definidos por el usuario (solo para Android 12+; requiere reiniciar manualmente)</string>
<string name="settings_recording_show_gps_disabled_warning_title">Alerta de GPS desactivado</string>
<string name="settings_recording_show_gps_disabled_warning_summary">Durante el registro, mostrar una advertencia si el GPS está desactivado.</string>
<string name="settings_recording_min_required_accuracy_summary">La ubicación es ignorada si su precisión es mayor que %1$s</string>
<string name="settings_recording_idle_timeout_title">Umbral de inactividad</string>
<string name="settings_locale_title">Idioma</string>
<string name="settings_locale_system_default">Predeterminado del sistema</string>
<string name="settings_night_mode_option_system_dynamic">Sistema (DynamicColor)</string>
<string name="settings_night_mode_option_day_dynamic">Día (DynamicColor)</string>
<string name="settings_night_mode_option_night_dynamic">Noche (DynamicColor)</string>
<string name="settings_night_mode_option_night_oled">Optimizado para OLED</string>
<string name="settings_night_mode_option_night_oled_dynamic">Optimizado para OLED (DynamicColor)</string>
<string name="settings_announcements_average_heart_rate">Frecuencia cardiaca promedio</string>
<string name="settings_announcements_lap_heart_rate">Frecuencia cardiaca por vuelta</string>
<string name="settings_announcements_average_speed_pace">Ritmo/velocidad promedio</string>
<string name="settings_announcements_segment_average_speed_pace">Ritmo/velocidad promedio (segmento)</string>
<string name="settings_announcements_lap_speed_pace">Ritmo/velocidad por vuelta</string>
<string name="settings_announcements_unit">Unidad de distancia/velocidad</string>
<string name="settings_sensor_bluetooth_service_filter_title">Filtrar dispositivos bluetooth</string>
<string name="settings_sensor_bluetooth_service_filter_off">Mostrar todos los dispositivos bluetooth detectables</string>
<string name="settings_sensor_heart_rate_max">Zonas: frecuencia cardiaca máxima (ppm)</string>
<string name="settings_stats_units_imperial_meter">Imperial (mi, m)</string>
<string name="settings_stats_units_nautical">Náutica (NM, ft)</string>
<string name="settings_api_summary">Control remoto y API de estadísticas para otras aplicaciones</string>
<string name="settings_public_api_package_title">Nombre de paquete para la API pública</string>
<string name="settings_export_to_health_connect">Exportar a Health Connect</string>
<string name="stats_average_segment_pace">Ritmo promedio de segmento</string>
<string name="stats_elapsed_segment_time">Tiempo transcurrido del segmento</string>
<string name="stats_gain_loss">Ganancia y pérdida</string>
<string name="stats_sensors_temperature">Temperatura</string>
<string name="track_delete_number_of_tracks">%1$d pistas</string>
<string name="track_discarding">Pista descartada</string>
<string name="track_edit_time_offset">Diferencia horaria</string>
<string name="unit_nautical_mile">Milla Náutica</string>
<string name="unit_knots">nudo</string>
<string name="unit_minute_per_nautical_mile">minutos/nudo</string>
<string name="value_integer_knots">%1$d nudos</string>
<string name="value_float_knots">%1$.1f nudos</string>
<string name="value_float_knots_recommended">%1$.1f nudos (recomendado)</string>
<string name="value_internal_sensor">Sensor interno</string>
<string name="value_int_seconds">%1$d segundos (recomendado)</string>
<string name="voiceIdle">Pasando a inactivo.</string>
<string name="voiceHoursPlural">{n, plural, =1 {1 hora} otro {{n,número} horas} }</string>
<string name="voiceMinutesPlural">{n, plural, =1 {1 minuto} otro {{n,número} minutos} }</string>
<string name="voiceSecondsPlural">{n, plural, =1 {1 segundo} otro {{n,número} segundos}}</string>
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 kilómetro por hora} otro {{n,número,#.0} kilómetros por hora}}</string>
<string name="voiceSpeedMilesPerHourPlural">{n, plural, =1 {1 milla por hora} otro {{n,número,#.0} millas por hora}}</string>
<string name="voiceSpeedMKnotsPlural">{n, plural, =1 {1 nudo} otro {{n,número} nudos}}</string>
<string name="voiceSpeed">{n, plural, =1 {1} otro {{n,número,#.0}} }</string>
<string name="voice_on_device_speaker_title">Usar el altavoz del dispositivo</string>
<string name="voice_per_nautical_mile">por milla náutica</string>
<string name="voiceDistanceKilometersPlural">{n, plural, =1 {1 kilómetro} otro {{n,número,#.0} kilómetros} }</string>
<string name="voiceDistanceMilesPlural">{n, plural, =1 {1 milla} otro {{n,número,#.0} millas} }</string>
<string name="voiceDistanceNauticalMilesPlural">{n, plural, =1 {1 milla náutica} otro {{n,número,#.0} millas náuticas} }</string>
<string name="voiceDistance">{n, plural, =1 {1} otro {{n,número#.0}} }</string>
<string name="lap_heart_rate">Frecuencia cardiaca por vuelta</string>
<string name="health_connect_export_enabled_summary">Exportar actividad a Health Connect después de finalizar el registro</string>
<string name="health_connect_not_available">Health Connect no está disponible en este dispositivo</string>
<string name="health_connect_open_settings_title">Ajustes de Health Connect</string>
<string name="health_connect_open_settings_summary">Abrir los ajustes de Health Connect</string>
<string name="health_connect_permissions_rationale_title">Permisos de Health Connect</string>
<string name="health_connect_permissions_rationale_heading">Como OpenTracks usa Health Connect</string>
<string name="health_connect_permissions_rationale_summary">Si está activado en la configuración, OpenTracks registrará la actividad en Health Connect. OpenTracks no obtiene ningún dato de Health Connect.</string>
<string name="no_compatible_camera_installed">No hay alguna aplicación de cámara compatible instalada</string>
<string name="no_compatible_gallery_installed">No hay alguna aplicación compatible de galería de imágenes instalada</string>
<string name="error_crash_title">%1$s se cerró inesperadamente</string>
<string name="share_crash_report">Compartir informe de errores</string>
<string name="report_bug">Reportar fallo</string>
<string name="show_on_dashboard">API del panel de control de OpenTracks</string>
<string name="show_on_dashboard_not_installed">No se encontró instalada alguna aplicación que soporte la API del panel de control de OpenTracks.</string>
<string name="introduction_osm_dashboard">OpenTracks no provee mapas por sí mismo. Instala OSMDashboard para ver tus registros en un mapa.</string>
<string name="settings_recording_min_sampling_interval_title">Intervalo de tiempo de muestreo</string>
<string name="settings_recording_location_distance_summary">%1$s entre ubicaciones registradas</string>
<string name="settings_announcements_lap_power">Potencia por vuelta</string>
<string name="power_x_watt">Potencia {x, plural, =1 {1 vatio} other {# vatios} }</string>
<string name="power">Potencia</string>
</resources>
-2
View File
@@ -125,7 +125,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Изчаква се по-добър сигнал за GPS</string>
<string name="gps_wait_for_signal">Изчаква се сигнал за GPS</string>
<!-- Image -->
<string name="image_arrow">Стрелка</string>
<string name="image_marker_count">Брой маркери</string>
<string name="image_photo_marker">Маркер за снимки</string>
<string name="image_record">Запис</string>
@@ -344,7 +343,6 @@ limitations under the License.
\n б) Чрез нормално приложение за карти (напр. чрез <a href="http://osmand.net/">OsmAnd</a> и <a href="https://maps.me/">MAPS.ME</a>). Тези приложения трябва да поддържат файловия формат KMZ (само местоположения и времеви марки). Всъщност едно приложение трябва да поддържа <i>ACTION_VIEW</i> с MIME <i>application/vnd.google-earth.kmz</i>. </string>
<string name="help_data_storage_title">Как OpenTracks съхранява данните от сензорите\?</string>
<string name="help_data_storage_content"> Данните от сензора се съхраняват за всяко записано място. По отношение на сензора за сърдечен ритъм това означава, че само <i>едно</i> измерване на сърдечния ритъм се съхранява за всяко местоположение. Ако стоите неподвижно или се намирате на закрито, няма да бъдат записани данни от сензора (но те ще бъдат </string>
<string name="open_track_as_trackfileformat">Отворете като %1$s</string>
<string name="description_altitude_loss">Загуба на височина: %1$d m (%2$d ft)</string>
<string name="help_recording_content">OpenTracks използва GPS сензора на устройството, за да записва непрекъснато местоположението. Ако GPS устройството съобщи неточни данни (напр. местоположение, скорост, надморска височина), OpenTracks не може да направи нищо по въпроса.</string>
<string name="track_distance_notification">Отстояние: %1$s</string>
-1
View File
@@ -125,7 +125,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Esperant un millor senyal GPS</string>
<string name="gps_wait_for_signal">Esperant el senyal GPS</string>
<!-- Image -->
<string name="image_arrow">Fletxa</string>
<string name="image_marker_count">Recompte de marcadors</string>
<string name="image_photo_marker">Marcador de fotos</string>
<string name="image_record">Enregistra</string>
+1 -5
View File
@@ -13,7 +13,6 @@
<string name="help_recording_title">Cumu OpenTracks arregistreghja i dati ?</string>
<string name="help_gps_troubleshooting_title">OpenTracks ùn arregistreghja micca e pusizioni ?</string>
<string name="help_support_title">Cumu possu sustene OpenTracks ?</string>
<string name="open_track_as_trackfileformat">Apre cum’è %1$s</string>
<string name="activity_type_airplane">aviò</string>
<string name="activity_type_atv">fora di strada</string>
<string name="activity_type_biking">bicicletta</string>
@@ -123,7 +122,6 @@
<string name="gps_disabled_msg">GPS disattivatu</string>
<string name="gps_fixed_and_ready">GPS fissu è prontu</string>
<string name="gps_recording_status">%1$s : %2$s</string>
<string name="image_arrow">Freccia</string>
<string name="image_marker_count">Numeru di stantelle</string>
<string name="image_stop">Piantà</string>
<string name="image_discard">Scartà</string>
@@ -456,7 +454,7 @@
<string name="settings_stats_units_imperial_meter">Imperiale (mi, m)</string>
<string name="settings_stats_units_metric">Metrica (km, m)</string>
<string name="settings_import_section">Impurtà</string>
<string name="settings_export_section">Espurtà</string>
<string name="settings_export_as_file_section">Espurtà</string>
<string name="settings_public_api_package_title">Nome di u pacchettu per l’API publica</string>
<string name="settings_public_api_enabled_summary_off">Solu OpenTracks pò lancià è piantà l’arregistramenti.</string>
<string name="settings_public_api_enabled_summary_on">D’altre appiecazioni installate ponu lancià o piantà l’arregistramenti.</string>
@@ -524,7 +522,6 @@
<string name="field_set_secondary">Secundariu</string>
<string name="field_remove_from_layout">Caccià</string>
<string name="no_compatible_gallery_installed">Nisuna appiecazione cumpatibile di galleria di fiure ùn hè installata</string>
<string name="always">Sempre</string>
<string name="share_crash_report">Sparte u raportu d’accidente</string>
<string name="settings_announcements_lap_power">Putenza à u giru</string>
<string name="introduction_osm_dashboard">OpenTracks ùn pruvede micca carta da per ellu. Ci vole à installà OSMDashboard per vede i vostri arregistramenti nant’à una carta.</string>
@@ -534,7 +531,6 @@
<string name="settings_locale_title">Lingua</string>
<string name="settings_locale_system_default">Predefinizione di u sistema</string>
<string name="stats_gain_loss">Guadagnu è perdita</string>
<string name="voice_announce_lap_power_key">AnnunziamentuVucalePutenzaÀUGiru</string>
<string name="lap_heart_rate">Frequenza cardiaca à u giru</string>
<string name="settings_charts_title">Grafici</string>
<string name="settings_chart_display_elevation_title">Affissà l’altitudine</string>
+1 -5
View File
@@ -158,7 +158,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Čekání na lepší signál GPS</string>
<string name="gps_wait_for_signal">Čekání na signál GPS</string>
<!-- Image -->
<string name="image_arrow">Šipka</string>
<string name="image_marker_count">Počet značek</string>
<string name="image_photo_marker">Fotografická značka</string>
<string name="image_record">Zaznamenat</string>
@@ -353,7 +352,6 @@ limitations under the License.
<!-- Share image -->
<string name="share_image_subject">Chtěl bych s vámi sdílet obrázek</string>
<string name="share_image_body">Myslím, že by vás mohl tento obrázek zajímat.</string>
<string name="open_track_as_trackfileformat">Otevřít jako %1$s</string>
<string name="export_all_do_it_for_all">provést pro všechny konflikty</string>
<string name="export_track_errors">Soubory nebyly exportovány</string>
<string name="generic_separator_done_total">/</string>
@@ -444,7 +442,7 @@ limitations under the License.
<string name="settings_cycling_sensor">Cyklistika</string>
<string name="settings_running_sensor">Běh</string>
<string name="settings_import_section">Importovat</string>
<string name="settings_export_section">Exportovat</string>
<string name="settings_export_as_file_section">Exportovat</string>
<string name="stats_sensors_power">Napájení</string>
<string name="permission_bluetooth_failed_rejected">V nastavení systému je třeba ručně povolit aplikaci OpenTracks přístup k blízkým zařízením.</string>
<string name="value_float_mile_hour">%1$.1f mi/h</string>
@@ -525,7 +523,6 @@ limitations under the License.
<string name="report_bug">Nahlásit chybu</string>
<string name="settings_announcements_lap_heart_rate">Srdeční tep kola</string>
<string name="introduction_osm_dashboard">OpenTracks sám o sobě mapu neposkytuje. Pro zobrazení záznamů na mapě si nainstalujte OSMDashboard.</string>
<string name="always">Vždy</string>
<string name="help_data_google_play_services_content">Ne</string>
<string name="description_speed_nautical">Rychlost (uzly)</string>
<string name="settings_export_filename_title">Formát názvu souboru</string>
@@ -580,7 +577,6 @@ limitations under the License.
<string name="settings_announcements_unit">Jednotka vzdálenosti/rychlosti</string>
<string name="voiceSpeed">{n, plural, =1 {1} few {{n,number}} other {{n,number,#.0}} }</string>
<string name="voiceDistance">{n, plural, =1 {1 } few {{n,number}} other {{n,number,#.0}} }</string>
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
<string name="power">Výkon</string>
<string name="settings_announcements_lap_power">Výkon kola</string>
<string name="power_x_watt">Výkon {x, plural, =1 {1 watt} few {# watty} other {# wattů} }</string>
+1 -5
View File
@@ -174,7 +174,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Venter på et bedre GPS-signal</string>
<string name="gps_wait_for_signal">Venter på GPS-signal</string>
<!-- Image -->
<string name="image_arrow">Pil</string>
<string name="image_marker_count">Antal markører</string>
<string name="image_photo_marker">Billedmarkør</string>
<string name="image_record">Registrer</string>
@@ -369,7 +368,6 @@ limitations under the License.
<!-- Share image -->
<string name="share_image_subject">Jeg vil gerne dele et billede med dig</string>
<string name="share_image_body">Jeg tror, du måske er interesseret i dette billede.</string>
<string name="open_track_as_trackfileformat">Åbn som %1$s</string>
<string name="activity_type_escooter">elløbehjulskørsel</string>
<string name="generic_click_twice_cancel">Stryg tilbage igen for at annullere</string>
<string name="import_unable_to_import_file">Kunne ikke importere filen:%1$s</string>
@@ -471,7 +469,7 @@ limitations under the License.
<string name="custom_layout_select_layout">Vælg layout</string>
<string name="settings_running_sensor">Kører</string>
<string name="settings_import_section">Importere</string>
<string name="settings_export_section">Eksport</string>
<string name="settings_export_as_file_section">Eksport</string>
<string name="stats_clock">Ur</string>
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (anbefalet)</string>
<string name="value_float_mile_hour">%1$.1f mi/h</string>
@@ -561,7 +559,6 @@ limitations under the License.
<string name="settings_recording_min_sampling_interval_title">Tidsinterval mellem samplinger</string>
<string name="settings_night_mode_option_day_dynamic">Dag (DynamicColor)</string>
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 kilometer i timen} other {{n,number,#.0} kilometre i timen} }</string>
<string name="empty">tom</string>
<string name="settings_chart_display_elevation_title">Vis højde</string>
<string name="settings_chart_display_pace_or_speed_title">Vis tempo eller hastighed</string>
<string name="settings_chart_display_heart_rate_title">Vis puls</string>
@@ -597,7 +594,6 @@ limitations under the License.
<string name="error_crash_title">%1$s gik ned</string>
<string name="share_crash_report">Del fejlrapport</string>
<string name="report_bug">Rapporter fejl</string>
<string name="always">Altid</string>
<string name="introduction_osm_dashboard">OpenTracks indeholder ikke selv et kort. Installer venligst OSMDashboard for at se registreringer på et kort.</string>
<string name="settings_recording_location_distance_summary">%1$s mellem registrerede lokationer</string>
<string name="settings_announcements_lap_power">Omgangseffekt</string>
+6 -5
View File
@@ -61,7 +61,6 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
\ne) es im <a href="https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore">Google Play Store</a> kaufen,
\nf) spenden, und
\ng) das tun, was OpenTracks Deiner Meinung nach hilft. \u0020</string>
<string name="open_track_as_trackfileformat">Als %1$s öffnen</string>
<!-- Activity Type -->
<string name="activity_type_airplane">Flugzeug</string>
<string name="activity_type_atv">Quad</string>
@@ -176,7 +175,6 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="gps_wait_for_better_signal">Warte auf besseres GPS-Signal</string>
<string name="gps_wait_for_signal">Warte auf GPS-Signal</string>
<!-- Image -->
<string name="image_arrow">Pfeil</string>
<string name="image_marker_count">Anzahl der Markierungen</string>
<string name="image_photo_marker">Fotomarkierung</string>
<string name="image_record">Aufzeichnen</string>
@@ -511,7 +509,7 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="settings_sensor_bluetooth_service_filter_on">Zeige nur Bluetooth-Geräte, die benötigte Dienste bekanntmachen</string>
<string name="settings_public_api_enabled_summary_on">Andere installierte Anwendungen können Aufzeichnungen starten oder stoppen.</string>
<string name="settings_public_api_disabled_toast">OpenTracks öffentliche API ist deaktiviert: es kann in den Einstellungen aktiviert werden.</string>
<string name="settings_export_section">Export</string>
<string name="settings_export_as_file_section">Export</string>
<string name="settings_public_api_enabled_summary_off">Nur OpenTracks kann Aufzeichnungen starten und stoppen.</string>
<string name="settings_import_section">Import</string>
<string name="settings_api_title">Öffentliche API</string>
@@ -566,7 +564,6 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="field_remove_from_layout">Entfernen</string>
<string name="error_crash_title">%1$s ist abgestürzt</string>
<string name="share_crash_report">Absturzbericht teilen</string>
<string name="always">Immer</string>
<string name="settings_layout_reset_confirm_message">Alle Layout-Einstellungen werden auf die Standardwerte zurückgesetzt. Aufzeichnungen werden nicht vom Gerät gelöscht.</string>
<string name="report_bug">Fehler melden</string>
<string name="settings_ui_summary">Erscheinungsbild, Aufzeichnungslayout, Theme</string>
@@ -635,7 +632,6 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
<string name="settings_recording_min_sampling_interval_title">Abtastzeitintervall</string>
<string name="voiceIdle">Untätig werden.</string>
<string name="voice_announce_lap_power_key">StimmeAnkündigungLapPower</string>
<string name="settings_announcements_lap_power">Rundenleistung</string>
<string name="create_marker_error">Die Markierung konnte nicht erstellt werden. Versuchen Sie es erneut.</string>
<string name="settings_locale_title">Sprache</string>
@@ -654,4 +650,9 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
<string name="settings_night_mode_option_day_dynamic">Tag (DynamicColor)</string>
<string name="settings_night_mode_option_night_dynamic">Nacht (DynamicColor)</string>
<string name="settings_night_mode_option_night_oled_dynamic">OLED-freundlich (DynamicColor)</string>
<string name="stats_sensors_temperature">Temperatur</string>
<string name="sensor_state_temperature">Thermometer</string>
<string name="settings_announcements_segment_average_speed_pace">Durchschnittsgeschwindigkeit/pace (Segment)</string>
<string name="stats_average_segment_pace">Durchschnittliche Segment Geschwindigkeit</string>
<string name="stats_elapsed_segment_time">Verstrichene Segment Zeit</string>
</resources>
+122 -10
View File
@@ -94,7 +94,7 @@ limitations under the License.
<string name="description_max_altitude">Μέγιστη ανύψωση: %1$d μέτρα (%2$d πόδια)</string>
<string name="description_max_speed">Μέγιστη ταχύτητα: %1$.2f χλμ/ώρα (%2$.1f μίλια/ώρα)</string>
<string name="description_min_altitude">Ελάχιστη ανύψωση: %1$d μέτρα (%2$d πόδια)</string>
<string name="description_moving_time">Χρόνος μεταφοράς: %1$s</string>
<string name="description_moving_time">Χρόνος κίνησης: %1$s</string>
<string name="description_pace_imperial">Ρυθμός (λεπτά/μίλι)</string>
<string name="description_pace_metric">Ρυθμός (λεπτά/χλμ)</string>
<string name="description_recorded_time">Εγγραφή: %1$s</string>
@@ -125,7 +125,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Αναμονή για καλύτερο σήμα GPS</string>
<string name="gps_wait_for_signal">Αναμονή για σήμα GPS</string>
<!-- Image -->
<string name="image_arrow">Βέλος</string>
<string name="image_marker_count">Αριθμός δείκτη</string>
<string name="image_photo_marker">Δείκτης φωτογραφίας</string>
<string name="image_record">Εγγραφή</string>
@@ -195,7 +194,7 @@ limitations under the License.
<string name="settings_stats_units_metric">Μετρικό (χλμ, μέτρα)</string>
<string name="settings_stats_units_title">Προτιμώμενες μονάδες</string>
<!-- Share Track -->
<string name="share_track_share_file_body">Νομίζω ότι μπορεί να σε ενδιαφέρει αυτό το ίχνος.\n\nΓια να δεις αυτό το ίχνος, χρησιμοποίησε το Google Earth ή άλλο λογισμικό χαρτογράφησης.\n\n%1$s</string>
<string name="share_track_share_file_body">Νομίζω ότι μπορεί να σε ενδιαφέρει αυτό το ίχνος.\n\n%1$s</string>
<string name="share_track_subject">Θα ήθελα να μοιραστώ μία διαδρομή μαζί σου</string>
<!-- Stats -->
<string name="stats_average_moving_pace">Μέσος ρυθμός κίνησης</string>
@@ -230,22 +229,22 @@ limitations under the License.
<!-- Tracks -->
<!-- Unit -->
<string name="unit_feet">πόδια</string>
<string name="unit_kilometer">χλμ.</string>
<string name="unit_kilometer">χλμ</string>
<string name="unit_kilometer_per_hour">χλμ/ώρα</string>
<string name="unit_meter">μέτρα</string>
<string name="unit_mile">μίλι</string>
<string name="unit_mile_per_hour">μίλια/ώρα</string>
<string name="unit_minute_per_kilometer">λεπτά / χιλ.</string>
<string name="unit_minute_per_kilometer">λεπτά/χλμ</string>
<string name="unit_minute_per_mile">λεπτά/μίλι</string>
<!-- Value -->
<string name="value_integer_feet_excellent_gps">%1$d πόδια (τέλειο σήμα GPS)</string>
<string name="value_integer_feet_recommended">%1$d ft (συνιστάται)</string>
<string name="value_integer_feet_recommended">%1$d πόδια (συνιστάται)</string>
<string name="value_integer_kilometer_hour">%1$d χλμ/ώρα</string>
<string name="value_integer_meter_excellent_gps">%1$d μέτρα (τέλειο σήμα GPS)</string>
<string name="value_integer_meter_poor_gps">%1$d μέτρα (χαμηλό σήμα GPS)</string>
<string name="value_integer_meter_recommended">%1$d m (συνιστάται)</string>
<string name="value_integer_mile_hour">%1$d μίλια/ώρα</string>
<string name="value_integer_minute">%1$d λεπ.</string>
<string name="value_integer_minute">%1$d λεπ</string>
<string name="value_integer_second">%1$d δευτ</string>
<string name="value_none">Καμία</string>
<string name="value_off">Απενεργοποιημένο</string>
@@ -303,7 +302,7 @@ limitations under the License.
<string name="waypoint_type_water_fountain">συντριβάνι</string>
<string name="waypoint_type_waypoint">σημείο διαδρομής</string>
<string name="about_preference_title">Σχετικά</string>
<string name="about_url">Ιστότοπους εγχειρήματος: <a href="%1$s">%1$s</a></string>
<string name="about_url">Ιστότοπος εγχειρήματος: <a href="%1$s">%1$s</a></string>
<string name="about_version_name">Όνομα έκδοσης: %1$s</string>
<string name="about_version_code">Κωδικός έκδοσης: %1$d</string>
<string name="about_third_party_data">Δεδομένα από τρίτους</string>
@@ -315,14 +314,127 @@ limitations under the License.
<string name="help_track_map_title">Πώς να εμφανιστεί ένα ίχνος στον χάρτη;</string>
<string name="help_gps_troubleshooting_title">Το OpenTracks δεν καταγράφει τοποθεσίες;</string>
<string name="help_support_title">Πώς να υποστηρίξω το OpenTracks;</string>
<string name="open_track_as_trackfileformat">Ανοίγμα ως %1$s</string>
<string name="activity_type_escooter">ηλεκτρικό πατίνι</string>
<string name="activity_type_kickscooter">πατίνι</string>
<string name="power_x_watt">Ισχύς {x, plural, =1 {1 watt} other {# watts} }</string>
<string name="power">Ισχύς</string>
<string name="report_bug">Αναφορά προβλήματος</string>
<string name="always">Πάντα</string>
<string name="settings_recording_min_sampling_interval_title">Χρονικό μεσοδιάστημα δειγματοληψίας</string>
<string name="settings_recording_location_distance_summary">%1$s μεταξύ καταγεγραμμένων τοποθεσιών</string>
<string name="field_remove_from_layout">Αφαίρεση</string>
<string name="value_float_kilometer_hour">%1$.1f χλμ/ώρα</string>
<string name="value_float_kilometer_hour_recommended">%1$.1f χλμ/ώρα (συνιστάται)</string>
<string name="value_integer_knots">%1$d κόμβοι</string>
<string name="value_float_mile_hour">%1$.1f μίλια/ώρα</string>
<string name="value_float_knots">%1$.1f κόμβοι</string>
<string name="value_float_mile_hour_recommended">%1$.1f μίλια/ώρα (συνιστάται)</string>
<string name="value_float_knots_recommended">%1$.1f κόμβοι (συνιστάται)</string>
<string name="value_internal_sensor">Εσωτερικός αισθητήρας</string>
<string name="value_int_seconds">%1$d δευτ (συνιστάται)</string>
<string name="voice_per_kilometer">ανά χιλιόμετρο</string>
<string name="voice_per_mile">ανά μίλι</string>
<string name="voice_per_nautical_mile">ανά ναυτικό μίλι</string>
<string name="lap_time">Χρόνος γύρου</string>
<string name="pace">Ρυθμός</string>
<string name="lap_speed">Ταχύτητα γύρου</string>
<string name="speed">Μέση ταχύτητα κίνησης</string>
<string name="total_distance">Συνολική απόσταση</string>
<string name="voiceDistanceKilometersPlural">{n, plural, =1 {1 χιλιόμετρο} other {{n,number,#.0} χιλιόμετρα} }</string>
<string name="voiceDistanceMilesPlural">{n, plural, =1 {1 μίλι} other {{n,number,#.0} μίλια} }</string>
<string name="voiceDistanceNauticalMilesPlural">{n, plural, =1 {1 ναυτικό μίλι} other {{n,number,#.0} ναυτικά μίλια} }</string>
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
<string name="voiceHoursPlural">{n, plural, =1 {1 ώρα} other {{n,number} ώρες} }</string>
<string name="settings_import_section">Εισαγωγή</string>
<string name="settings_api_title">Δημόσιο API</string>
<string name="settings_public_api_enabled_title">Δημόσιο API</string>
<string name="settings_public_api_package_title">Όνομα πακέτου για Δημόσιο API</string>
<string name="settings_api_summary">API απομακρυσμένου ελέγχου και στατιστικών για άλλες εφαρμογές</string>
<string name="voiceMinutesPlural">{n, plural, =1 {1 λεπτό} other {{n,number} λεπτά} }</string>
<string name="voiceSecondsPlural">{n, plural, =1 {1 δευτερόλεπτο} other {{n,number} δευτερόλεπτα} }</string>
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 χιλιόμετρο ανά ώρα} other {{n,number,#.0} χιλιόμετρα ανά ώρα} }</string>
<string name="voiceSpeedMilesPerHourPlural">{n, plural, =1 {1 μίλι ανά ώρα} other {{n,number,#.0} μίλια ανά ώρα} }</string>
<string name="voiceSpeedMKnotsPlural">{n, plural, =1 {1 κόμβος} other {{n,number,#.0} κόμβοι} }</string>
<string name="voiceSpeed">{n, plural, =1 {1} other {{n,number,#.0}} }</string>
<string name="description_speed_nautical">Ταχύτητα (κόμβοι)</string>
<string name="description_sensor_temperature">Θερμοκρασία (Κελσίου)</string>
<string name="export_with_photos">με φωτογραφίες</string>
<string name="export_without_photos">χωρίς φωτογραφίες</string>
<string name="export_progress_summary_errors_msg">Σφάλματα</string>
<string name="import_progress_summary_errors_msg">Σφάλματα</string>
<string name="import_progress_summary_exists_msg">Υπάρχει ήδη</string>
<string name="import_error_info">%1$s: %2$s</string>
<string name="gps_recording_status">%1$s: %2$s</string>
<string name="generic_undo">Αναίρεση</string>
<string name="generic_filter">Φίλτρο</string>
<string name="menu_filter">Φίλτρο</string>
<string name="menu_voice_rate">Ταχύτητα φωνής</string>
<string name="sensor_state_cadence_avg">Μέσο πεταλάρισμα</string>
<string name="sensor_state_cadence_max">Μέγιστο πεταλάρισμα</string>
<string name="sensor_state_temperature">Θερμόμετρο</string>
<string name="sensor_state_pressure">Βαρόμετρο</string>
<string name="bluetooth_sensor_summary">%1$s (%2$s)</string>
<string name="bluetooth_disabled">Παρακαλούμε ενεργοποιήστε το Bluetooth.</string>
<string name="settings_ui_title">Διεπαφή Χρήστη</string>
<string name="settings_ui_summary">Εμφάνιση, Διάταξη Εγγραφής, Θέμα</string>
<string name="settings_gps_sensor">GPS</string>
<string name="description_speed_source_gps">GPS</string>
<string name="settings_sensors_title">Αισθητήρες</string>
<string name="settings_sensors_summary">Αισθητήρες Ποδηλασίας, Περιφέρεια Ρόδας, Αισθητήρες Τρεξίματος</string>
<string name="settings_defaults_title">Προεπιλεγμένες Μονάδες και Δραστηριότητα</string>
<string name="settings_announcements_summary">Χρόνος, Απόσταση, Ταχύτητα Φωνής</string>
<string name="settings_import_export_title">Εισαγωγή και Εξαγωγή</string>
<string name="settings_import_export_summary">Εισαγωγή, Εξαγωγή, Αυτόματη Εξαγωγή</string>
<string name="settings_units_category">Μονάδες</string>
<string name="settings_activity_defaults_category">Προεπιλογές Δραστηριότητας</string>
<string name="settings_reset_summary">Επαναφορά Ρυθμίσεων στις Προεπιλεγμένες Τιμές</string>
<string name="generic_from">Από</string>
<string name="generic_to">Μέχρι</string>
<string name="generic_today">Σήμερα</string>
<string name="generic_yesterday">Χθες</string>
<string name="generic_dismiss">Παράβλεψη</string>
<string name="generic_completed">Ολοκληρώθηκε</string>
<string name="generic_click_twice_cancel">Πατήστε ξανά πίσω για ακύρωση</string>
<string name="generic_choose_an_option">Επιλέξτε μια επιλογή</string>
<string name="generic_skip">Παράλειψη</string>
<string name="generic_separator_done_total">/</string>
<string name="generic_show_errors">Εμφάνιση σφαλμάτων</string>
<plurals name="generic_completed_with_errors">
<item quantity="one">Ολοκληρώθηκε με ένα σφάλμα</item>
<item quantity="other">Ολοκληρώθηκε με %1$d σφάλματα</item>
</plurals>
<string name="generic_overwrite">Αντικατάσταση</string>
<string name="gps_disabled_msg">GPS απενεργοποιημένο</string>
<string name="gps_fixed_and_ready">GPS σταθερό και έτοιμο</string>
<string name="image_discard">Απόρριψη</string>
<string name="menu_select_layout">Επιλογή διάταξης</string>
<string name="custom_layout_select_layout">Επιλογή διάταξης</string>
<string name="settings_announcements_distance_frequency">Μήκος γύρου</string>
<string name="settings_locale_title">Γλώσσα</string>
<string name="settings_locale_system_default">Προεπιλογή συστήματος</string>
<string name="settings_night_mode_option_system">Σύστημα</string>
<string name="settings_night_mode_option_day">Μέρα</string>
<string name="settings_night_mode_option_night">Νύχτα</string>
<string name="custom_layout_list_title">Διατάξεις</string>
<string name="stats_custom_layout_default_layout">Προεπιλεγμένη διάταξη</string>
<string name="settings_all_reset">Επαναφορά</string>
<string name="settings_recording_fullscreen_on_while_recording_title">Πλήρης οθόνη</string>
<string name="settings_recording_show_gps_disabled_warning_title">Προειδοποίηση απενεργοποιημένου GPS</string>
<string name="settings_recording_keep_screen_on_while_recording_title">Διατήρηση της οθόνης ενεργοποιημένης</string>
<string name="settings_recording_show_on_lockscreen_while_recording_title">Εμφάνιση στατιστικών στο κλείδωμα οθόνης</string>
<string name="settings_ui_dynamic_colors_title">Δυναμικά Χρώματα</string>
<string name="settings_recording_customize_layout_title">Προσαρμόστε τη διάταξη εγγραφής</string>
<string name="settings_sensor_cycling_cadence">Πεταλάρισμα</string>
<string name="stats_sensors_cadence">Πεταλάρισμα</string>
<string name="stats_sensors_temperature">Θερμοκρασία</string>
<string name="stats_clock">Ρολόι</string>
<string name="track_recording_notification_accuracy">Ακρίβεια τοποθεσίας: %1$s</string>
<string name="track_distance_notification">Απόσταση: %1$s</string>
<string name="track_speed_notification">Ταχύτητα: %1$s</string>
<string name="track_pace_notification">Ρυθμός: %1$s</string>
<string name="stats_coordinates">Γεωγραφικό πλάτος/Γεωγραφικό μήκος</string>
<string name="settings_sensor_running_speed_and_cadence">Απόσταση, Ταχύτητα και Πεταλάρισμα</string>
<string name="settings_sensor_cycling_distance_speed">Απόσταση και Ταχύτητα</string>
<string name="settings_sensor_bluetooth_service_filter_title">Φιλτράτισμα Συσκευών Bluetooth</string>
<string name="settings_sensor_wheel_circumference">Περιφέρεια Ρόδας (mm)</string>
<string name="settings_public_api_dashboard_enabled_title">Αυτόματη μεταφορά δεδομένων</string>
</resources>
+1 -5
View File
@@ -174,7 +174,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Esperando una mejor señal de GPS</string>
<string name="gps_wait_for_signal">Esperando señal de GPS</string>
<!-- Image -->
<string name="image_arrow">Flecha</string>
<string name="image_marker_count">Recuento de marcadores</string>
<string name="image_photo_marker">Marcador de foto</string>
<string name="image_record">Grabar</string>
@@ -405,7 +404,6 @@ limitations under the License.
<!-- Share image -->
<string name="share_image_subject">Me gustaría compartir una imagen contigo</string>
<string name="share_image_body">Creo que esta imagen podría interesarte.</string>
<string name="open_track_as_trackfileformat">Abrir como %1$s</string>
<string name="activity_type_escooter">moto</string>
<string name="export_all_do_it_for_all">hacerlo para todos los conflictos</string>
<string name="export_track_errors">Archivos no exportados</string>
@@ -503,7 +501,7 @@ limitations under the License.
<string name="permission_bluetooth_failed_rejected">Es necesario conceder manualmente a OpenTracks el acceso a los Dispositivos Cercanos en la configuración del sistema.</string>
<string name="settings_running_sensor">Correr</string>
<string name="settings_import_section">Importar</string>
<string name="settings_export_section">Exportar</string>
<string name="settings_export_as_file_section">Exportar</string>
<string name="generic_today">Hoy</string>
<string name="generic_yesterday">Ayer</string>
<string name="menu_add_custom_layout">Nuevo diseño</string>
@@ -597,7 +595,6 @@ limitations under the License.
<string name="report_bug">Reportar error</string>
<string name="show_on_dashboard">Panel de control del API de OpenTracks</string>
<string name="show_on_dashboard_not_installed">No se ha instalado ninguna aplicación compatible con el panel de control del API de OpenTracks.</string>
<string name="always">Siempre</string>
<string name="settings_announcements_lap_heart_rate">Frecuencia cardiaca por vuelta</string>
<string name="settings_public_api_package_title">Nombre del paquete para el API público</string>
<string name="unit_knots">Nudo</string>
@@ -654,7 +651,6 @@ limitations under the License.
<string name="settings_announcements_lap_power">Potencia por vuelta</string>
<string name="power_x_watt">Potencia {x, plural, =1 {1 vatio} otros {# vatios} }</string>
<string name="create_marker_error">No se ha podido crear un marcador. Inténtelo de nuevo.</string>
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
<string name="power">Potencia</string>
<string name="settings_locale_title">Idioma</string>
<string name="settings_locale_system_default">Predeterminado del sistema</string>
+11 -5
View File
@@ -153,7 +153,6 @@ limitations under the License.
<string name="gps_wait_for_better_signal">Ootan paremat GPS-signaali</string>
<string name="gps_wait_for_signal">Ootan GPS-signaali</string>
<!-- Image -->
<string name="image_arrow">Nool</string>
<string name="image_marker_count">Markerite arv</string>
<string name="image_photo_marker">Fotomarker</string>
<string name="image_record">Salvesta</string>
@@ -346,7 +345,6 @@ limitations under the License.
<!-- Share image -->
<string name="share_image_subject">Tahaksin sinuga pilti jagada</string>
<string name="share_image_body">Ma arvan, et see pilt võiks sind huvitada.</string>
<string name="open_track_as_trackfileformat">Ava kui %1$s</string>
<string name="activity_type_escooter">elektritõukeratas</string>
<string name="description_altitude_loss">Kõrguse langus: %1$d m (%2$d jalga)</string>
<string name="export_all_do_it_for_all">tee seda iga konfilkti puhul</string>
@@ -442,7 +440,7 @@ limitations under the License.
<string name="settings_reset_summary">Lähtesta seadistused vaikeväärtusteks</string>
<string name="settings_recording_customize_layout_title">Kohanda oma salvestuse paigutust</string>
<string name="settings_recording_customize_layout_select_columns">Andmeväljad rea kohta</string>
<string name="settings_export_section">Eksport</string>
<string name="settings_export_as_file_section">Eksport</string>
<string name="settings_announcements_totaltime_frequency">Ajavahemik</string>
<string name="settings_layout_reset">Lähtesta kohandatud paigutus</string>
<string name="settings_layout_reset_confirm_title">Kas lähtestad oma paigutused?</string>
@@ -544,7 +542,6 @@ limitations under the License.
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 kilomeeter tunnis} other {{n,number,#.0} kilomeetrit tunnis} }</string>
<string name="voiceSpeedMilesPerHourPlural">{n, plural, =1 {1 miil tunnis} other {{n,number,#.0} miili tunnis} }</string>
<string name="voiceSpeedMKnotsPlural">{n, plural, =1 {1 sõlm} other {{n,number,#.0} sõlme} }</string>
<string name="always">Alati</string>
<string name="power">Võimsus</string>
<string name="power_x_watt">Võimsus {x, plural, =1 {1 vatt} other {# vatti} }</string>
<string name="settings_announcements_lap_power">Ringi võimsus</string>
@@ -595,8 +592,17 @@ limitations under the License.
<string name="show_on_dashboard_not_installed">Pole paigaldatud ühtegi rakendust, mis toetaks OpenTracksi juhtpaneeli API-t.</string>
<string name="introduction_osm_dashboard">Kaardi kuvamine pole OpenTracks-i automaatselt lisatud. Oma salvestiste kaardil vaatamiseks palun paigalda OSMDashboard.</string>
<string name="settings_recording_min_sampling_interval_title">Proovivõtu ajavahemik</string>
<string name="voice_announce_lap_power_key">RingiVõimsuseHäälteade</string>
<string name="description_sensor_temperature">Temperatuur (Celsius)</string>
<string name="sensor_state_temperature">Termomeeter</string>
<string name="stats_sensors_temperature">Temperatuur</string>
<string name="activity_type_gravel_biking">rattasõit kruusateedel</string>
<string name="menu_export_all_to_health_connect">Ekspordi kõik</string>
<string name="settings_export_to_health_connect">Ekspordi „Health Connect“ liidesesse</string>
<string name="health_connect_export_enabled_summary">Peale salvestamise lõppemist ekspordi tegevus „Health Connect“ taustateenusesse</string>
<string name="health_connect_not_available">„Health Connect“ taustateenus pole selles seadmes saadaval</string>
<string name="health_connect_open_settings_title">„Health Connect“ taustateenuse seadistused</string>
<string name="health_connect_open_settings_summary">Ava „Health Connect“ taustateenuse seadistused</string>
<string name="health_connect_permissions_rationale_title">„Health Connect“ taustateenuse õigused</string>
<string name="health_connect_permissions_rationale_heading">Kuidas OpenTracks kasutab „Health Connect“ taustateenust</string>
<string name="health_connect_permissions_rationale_summary">Kui see on seadistustest lubatud, siis OpenTracks salvestab treeningute andmed ka „Health Connect“ taustateenusesse. OpenTracks ei loe andmeid „Health Connect“ taustateenusest.</string>
</resources>
+1 -3
View File
@@ -176,7 +176,6 @@ GPS gailuak datu okerrak salatzen baditu (adibidez, kokapena, abiadura, kota), O
<string name="gps_wait_for_better_signal">GPS seinale hobearen zain</string>
<string name="gps_wait_for_signal">GPS seinalearen zain</string>
<!-- Image -->
<string name="image_arrow">Gezia</string>
<string name="image_marker_count">Markatzaile kopurua</string>
<string name="image_photo_marker">Argazki-markatzailea</string>
<string name="image_record">Grabatu</string>
@@ -371,7 +370,6 @@ GPS gailuak datu okerrak salatzen baditu (adibidez, kokapena, abiadura, kota), O
<!-- Share image -->
<string name="share_image_subject">Irudi bat zurekin partekatu nahi nuke</string>
<string name="share_image_body">Irudi hau interesatuko zaizuela uste dut.</string>
<string name="open_track_as_trackfileformat">Ireki gisa %1$s</string>
<string name="activity_type_escooter">eskooterra</string>
<string name="settings_recording_fullscreen_on_while_recording_summary">Grabatzen ari zaren bitartean, pantaila osoan ikusi.</string>
<string name="settings_recording_fullscreen_on_while_recording_title">Pantaila osoa</string>
@@ -458,7 +456,7 @@ GPS gailuak datu okerrak salatzen baditu (adibidez, kokapena, abiadura, kota), O
<string name="settings_layout_reset_done">Diseinuaren ezarpen guztiak balio lehenetsietara itzuli dira</string>
<string name="settings_all_reset">Berrezarri</string>
<string name="settings_import_section">Inportatu</string>
<string name="settings_export_section">Esportatu</string>
<string name="settings_export_as_file_section">Esportatu</string>
<string name="stats_coordinates">Latitudea /Longitudea</string>
<string name="stats_clock">Erlojua</string>
<string name="value_float_mile_hour">%1$.1f mi/h</string>

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