Compare commits

..

1 Commits

Author SHA1 Message Date
Dennis Guse b83f57bd20 Dashboard API: provide version 2 again (required by OSMDashboard until v6.4.1).
Part of #2265.
2026-02-08 19:38:34 +01:00
136 changed files with 1823 additions and 3722 deletions
-13
View File
@@ -112,16 +112,3 @@ Here is a list of the smartphones tested:
| Google | Nexus 4 | LineageOS 18.1 | Ok | | Google | Nexus 4 | LineageOS 18.1 | Ok |
| Samsung | S4 | Android 11 | Ok | | Samsung | S4 | Android 11 | Ok |
| Sony | Xperia XZ1 Compact | LineageOS 16.1 | Failure | | 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
+15 -20
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:9.2.1' classpath 'com.android.tools.build:gradle:9.0.0'
} }
} }
@@ -18,11 +18,11 @@ allprojects {
} }
def getVersionName = { -> def getVersionName = { ->
return 'git describe --tags'.execute(null, project.rootDir).text.trim() return 'git describe --tags'.execute().text.trim()
} }
def getVersionCodeNightly = { -> def getVersionCodeNightly = { ->
return Integer.valueOf('git rev-list HEAD --count'.execute(null, project.rootDir).text.trim()) return Integer.valueOf('git rev-list HEAD --count'.execute().text.trim())
} }
android { android {
@@ -33,7 +33,7 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
compileSdk = 37 compileSdk = 36
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
@@ -47,13 +47,13 @@ android {
defaultConfig { defaultConfig {
applicationId 'de.dennisguse.opentracks' applicationId 'de.dennisguse.opentracks'
versionCode 6669 versionCode 6554
versionName "v4.27.1" versionName "v4.25.1"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\"" buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 36
testInstrumentationRunner 'de.dennisguse.opentracks.TestRunner' testInstrumentationRunner 'de.dennisguse.opentracks.TestRunner'
testInstrumentationRunnerArguments clearPackageData: 'true' testInstrumentationRunnerArguments clearPackageData: 'true'
@@ -107,6 +107,7 @@ android {
nightly { nightly {
dimension 'version' dimension 'version'
applicationId 'de.dennisguse.opentracks.nightly' applicationId 'de.dennisguse.opentracks.nightly'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.nightly'
versionCode getVersionCodeNightly() versionCode getVersionCodeNightly()
versionName getVersionName() versionName getVersionName()
@@ -118,6 +119,7 @@ android {
// Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/ // Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/
dimension 'version' dimension 'version'
applicationId 'de.dennisguse.opentracks' applicationId 'de.dennisguse.opentracks'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks'
versionNameSuffix 'irreproducible' versionNameSuffix 'irreproducible'
} }
reproducible { reproducible {
@@ -126,6 +128,7 @@ android {
// PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore // PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore
dimension 'version' dimension 'version'
applicationId 'de.dennisguse.opentracks.playstore' applicationId 'de.dennisguse.opentracks.playstore'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.playstore'
base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}" base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
@@ -149,28 +152,20 @@ dependencies {
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0' implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.gridlayout:gridlayout:1.1.0' implementation 'androidx.gridlayout:gridlayout:1.1.0'
implementation 'com.google.android.material:material:1.14.0' implementation 'com.google.android.material:material:1.13.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.core:core:1.19.0' implementation 'androidx.core:core:1.17.0'
implementation 'androidx.core:core-splashscreen:1.2.0' implementation 'androidx.core:core-splashscreen:1.2.0'
implementation 'androidx.mediarouter:mediarouter:1.8.1' implementation 'androidx.mediarouter:mediarouter:1.8.1'
implementation 'androidx.core:core-location-altitude:1.0.0-rc01' implementation 'androidx.core:core-location-altitude:1.0.0-beta01'
implementation 'androidx.work:work-runtime:2.11.2' implementation 'androidx.work:work-runtime:2.11.1'
androidTestImplementation 'androidx.test:core:1.7.0' androidTestImplementation 'androidx.test:core:1.7.0'
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:rules:1.7.0' androidTestImplementation 'androidx.test:rules:1.7.0'
androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test:runner:1.7.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'org.mockito:mockito-android:5.21.0'
androidTestUtil 'androidx.test:orchestrator:1.6.1' 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)
)
}
}
@@ -1,10 +0,0 @@
(versionName): OpenTracks
Ændringer:
-
Fejlrettelser:
-
Udvikler:
-
@@ -1,8 +0,0 @@
v4.25.2: OpenTracks
Changes:
- Restored compatibility with OSMDashboard (Dashboard API protocol version 2)
Bugfix:
- Update UI immediately after resuming a track
@@ -1,12 +0,0 @@
v4.26.0: OpenTracks
Changes:
- GPS is optional: configurable in the settings
- BLE sensor address/name is only shown after successful connection
Bugfix:
- Altitude is now shown again in EGM2008 (introduced in v4.25.0)
- Altitude and speed were shown as 0.0 briefly when data was stored
Developer:
- Refactored sensor subsystem
@@ -1,6 +0,0 @@
v4.26.1: OpenTracks
Bugfix:
- Crash when deleting a UI layout
- Crash when heartrate sensor data could not be decoded
- Crash when exporting zero tracks
@@ -1,5 +0,0 @@
v4.26.2: OpenTracks
Bugfix:
- GPS via Bluetooth is not (yet) supported
- Altitude type (EGM2008) wasn't shown
@@ -1,14 +0,0 @@
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
@@ -1,11 +0,0 @@
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
@@ -1,17 +0,0 @@
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)
@@ -1,14 +0,0 @@
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
@@ -1,5 +0,0 @@
v4.26.2: OpenTracks
Arranxos:
- (aínda) non hai compatibilidade co GPS vía Bluetooth
- non se mostraba a altitude tipo (EGM2008)
@@ -1,5 +0,0 @@
v4.26.2: OpenTracks
Correzioni:
- Il GPS via Bluetooth non è (ancora) supportato
- Il tipo di altitudine (EGM2008) non viene mostrato
@@ -1,22 +0,0 @@
Un tracker sportivo che rispetta la tua privacy.
Sport e attività all'aria aperta portano a felicità, allenamento e fiducia
Valorizza la tua salute tenendo traccia dei tuoi progressi.
Registra quando stai correndo o passeggiando e regalati un monitor più grande quando fai ciclismo.
Segna con fotografie i posti interessanti incrociati durante il tragitto.
Tieni traccia delle statistiche in dettaglio per analizzarle.
Condividi solo i dati che vuoi.
* Annunci vocali.
* Supporto a sensori Bluetooth LE: battito cardiaco, velocità e distanza (ciclismo), cadenza (ciclismo), rilevamento potenza (ciclismo).
* Altitudine positiva o negativa tramite sensore barometrico.
* Altitudine mostrata in EGM2008 (sopra il livello del mare) ed esportata come WGS84.
* Esporta tracce come KMZ (con foto incluse), KML o GPX.
* Non richiede accesso a internet o altri permessi.
* Temi chiaro e scuro, o seguendo la impostazioni di sistema.
* Nessuna pubblicità.
Libre software / Free Software / Open Source
cioè puoi usare, studiare, cambiare e condividere il codice sorgente.
Licenza: Apache 2.0
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
-14
View File
@@ -1,14 +0,0 @@
<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,117 +2,76 @@ package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId; 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.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 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.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule; import androidx.test.rule.GrantPermissionRule;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import java.time.Duration;
@LargeTest @LargeTest
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class EspressoDeleteTrackTest { public class EspressoDeleteTrackTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule @Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule @Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@Test @Test
public void deleteTrackFromTrackStoppedActivity() { public void espressoDeleteTrackTest() {
recordTrackAndOpenTrackStoppedActivity(); // TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
// TrackStoppedActivity: discard the just-recorded track directly // TrackRecordingActivity
onView(withId(R.id.discard_button)) onView(withId(R.id.track_recording_fab_action))
.perform(performClick()); // wait; stay recording
onView(withText(android.R.string.ok)) .perform(waitFor(5000))
.inRoot(isDialog()) // stop;
.perform(performClick()); .perform(longClick());
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);
// TrackStoppedActivity
onView(withId(R.id.finish_button)) onView(withId(R.id.finish_button))
.perform(performClick()); .perform(click());
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))) // select track
.perform(performLongClickOnAncestor()); onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0)))
.perform(longClick());
clickDeleteSelectedTrackAction(); // open menu and delete selected track
onView(withText(android.R.string.ok)) onView(
.inRoot(isDialog()) allOf(withContentDescription("More options"),
.perform(performClick()); childAtPosition(
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT); childAtPosition(
withId(androidx.appcompat.R.id.action_mode_bar),
1),
2),
isDisplayed()))
.perform(click());
onView(withId(R.id.track_list)) onView(withText("Delete"))
.check(matches(withRecyclerViewItemCount(0))); .perform(click());
}
private void recordTrackAndOpenTrackStoppedActivity() { onView(withText("OK"))
startRecordingFromTrackList(UI_TIMEOUT); .perform(click());
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.discard_button, R.id.finish_button);
}
private void clickDeleteSelectedTrackAction() { // tracklist is empty now
try { onView(allOf(withText("Start recording your next adventure here"), isDisplayed()));
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,43 +2,34 @@ package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView; 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.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.assertion.ViewAssertions.matches; 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.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.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText; 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 org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule; import androidx.test.rule.GrantPermissionRule;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
@LargeTest @LargeTest
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class EspressoEditTrackRecordingTest { public class EspressoEditTrackRecordingTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule @Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH); public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
@@ -48,27 +39,27 @@ public class EspressoEditTrackRecordingTest {
@Rule @Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@LargeTest @LargeTest
@Test @Test
public void espressoEditTrackRecordingTest() { public void espressoEditTrackRecordingTest() {
startRecordingFromTrackList(UI_TIMEOUT); {
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{ {
// TrackRecordingActivity // TrackRecordingActivity
// wait; stay recording
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(4000));
// open menu // open menu
onView(allOf( openContextualActionModeOverflowMenu();
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
isDescendantOfA(withId(R.id.bottom_app_bar)))) // Click the item.
.perform(performClick());
onView(withText(R.string.menu_edit)) onView(withText(R.string.menu_edit))
.inRoot(isPlatformPopup()) .perform(click());
.perform(performClickOnAncestor());
waitUntilDisplayed(withId(R.id.track_edit_name), UI_TIMEOUT);
// change name for "New Name" // change name for "New Name"
onView(withId(R.id.track_edit_name)) onView(withId(R.id.track_edit_name))
@@ -79,11 +70,11 @@ public class EspressoEditTrackRecordingTest {
// save edition // save edition
onView(withId(R.id.track_edit_save)) onView(withId(R.id.track_edit_save))
.perform(performClick()); .perform(click());
waitUntilDisplayed(withId(R.id.track_recording_fab_action), UI_TIMEOUT);
// stop; // stop;
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button); onView(withId(R.id.track_recording_fab_action))
.perform(longClick());
// it's on track stopped activity and there are two buttons // it's on track stopped activity and there are two buttons
onView(withId(R.id.resume_button)) onView(withId(R.id.resume_button))
@@ -1,142 +1,100 @@
package de.dennisguse.opentracks; package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView; 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.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.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor; import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition;
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 static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.ViewInteraction; import androidx.test.espresso.ViewInteraction;
import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule; import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.runner.RunWith; 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 @LargeTest
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class EspressoUITest { public class EspressoUITest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); @Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
private final ActivityScenarioRule<TrackListActivity> activityRule = new ActivityScenarioRule<>(TrackListActivity.class);
private final GrantPermissionRule grantPermissionRule = TestUtil.createGrantPermissionRule();
@Rule @Rule
public RuleChain rules = RuleChain.outerRule(grantPermissionRule) public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
.around(activityRule);
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@LargeTest @LargeTest
@Test @Test
public void record_stop_resume_stop_finish() { public void record_stop_resume_stop_finish() {
startRecordingFromTrackList(UI_TIMEOUT);
{ {
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button); // TrackListActivity: start recording
resumeStoppedTrack(UI_TIMEOUT); onView(withId(R.id.track_list_fab_action))
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button); .perform(click());
finishStoppedTrack(UI_TIMEOUT); }
{
// TrackRecordingActivity: wait to record some time and then stop
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(5000))
.perform(longClick());
onView(withId(R.id.track_list)) // TrackStoppedActivity: resume
.check(matches(isDisplayed())); 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());
} }
} }
@LargeTest @LargeTest
@Test @Test
public void record_move_through_tabs() { public void record_move_through_tabs() {
startRecordingFromTrackList(UI_TIMEOUT); {
waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout), UI_TIMEOUT); // TrackListActivity: start recording
waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager), UI_TIMEOUT); onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{ {
// TrackRecordingActivity // TrackRecordingActivity
ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout)); ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout));
tabLayout.perform(selectTabAtIndex(1)); tabLayout.perform(selectTabAtIndex(1));
waitUntilTabSelectionSettlesAt(1); tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(2)); tabLayout.perform(selectTabAtIndex(2));
waitUntilTabSelectionSettlesAt(2); tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(3)); tabLayout.perform(selectTabAtIndex(3));
waitUntilTabSelectionSettlesAt(3); tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(0)); tabLayout.perform(selectTabAtIndex(0));
waitUntilTabSelectionSettlesAt(0); tabLayout.perform(waitFor(1000));
// stop // stop
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button); onView(withId(R.id.track_recording_fab_action)).
perform(longClick());
onView(withId(R.id.resume_button))
.check(matches(isDisplayed()));
onView(withId(R.id.finish_button))
.check(matches(isDisplayed()));
} }
} }
@LargeTest @LargeTest
@Test @Test
public void selectAndDeleteTrack() { 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(withId(R.id.track_list)).check(matches(isDisplayed()));
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))).perform(longClick());
.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,19 +20,22 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.when;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.database.MatrixCursor;
import android.util.Pair; import android.util.Pair;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; 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.File;
import java.io.IOException; import java.io.IOException;
@@ -75,6 +78,7 @@ import de.dennisguse.opentracks.util.FileUtils;
* @author Bartlomiej Niechwiej * @author Bartlomiej Niechwiej
* @author Youtao Liu * @author Youtao Liu
*/ */
@RunWith(MockitoJUnitRunner.class)
public class CustomContentProviderUtilsTest { public class CustomContentProviderUtilsTest {
private static final String NAME_PREFIX = "test name"; private static final String NAME_PREFIX = "test name";
@@ -86,6 +90,12 @@ public class CustomContentProviderUtilsTest {
private final Context context = ApplicationProvider.getApplicationContext(); private final Context context = ApplicationProvider.getApplicationContext();
private ContentProviderUtils contentProviderUtils; private ContentProviderUtils contentProviderUtils;
@Mock
private Cursor cursorMock;
@Mock
private ContentResolver contentResolverMock;
@Before @Before
public void setUp() { public void setUp() {
contentProviderUtils = new ContentProviderUtils(context); contentProviderUtils = new ContentProviderUtils(context);
@@ -94,21 +104,21 @@ public class CustomContentProviderUtilsTest {
@Test @Test
public void testLocationIterator_noPoints() { public void testLocationIterator_noPoints() {
assertEquals(0, testIterator(new Track.Id(1), 0).size()); testIterator(new Track.Id(1), 0);
} }
@Test @Test
public void testLocationIterator_noAscending() { public void testLocationIterator_noAscending() {
assertEquals(50, testIterator(new Track.Id(1), 50).size()); testIterator(new Track.Id(1), 50);
assertEquals(50, testIterator(new Track.Id(2), 50).size()); testIterator(new Track.Id(2), 50);
} }
@Test @Test
public void testLocationIterator_largeTrack() { public void testLocationIterator_largeTrack() {
assertEquals(20000 / 2, testIterator(new Track.Id(1), 20000 / 2).size()); testIterator(new Track.Id(1), 20000 / 2);
} }
private List<TrackPoint> testIterator(Track.Id trackId, int numPoints) { private void testIterator(Track.Id trackId, int numPoints) {
TrackPoint.Id lastPointId = initializeTrack(trackId, numPoints); TrackPoint.Id lastPointId = initializeTrack(trackId, numPoints);
List<TrackPoint> locations = new ArrayList<>(numPoints); List<TrackPoint> locations = new ArrayList<>(numPoints);
try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) { try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) {
@@ -121,7 +131,6 @@ public class CustomContentProviderUtilsTest {
} }
assertEquals(numPoints, locations.size()); assertEquals(numPoints, locations.size());
} }
return locations;
} }
private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) { private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) {
@@ -178,49 +187,27 @@ public class CustomContentProviderUtilsTest {
@Test @Test
public void testCreateTrack() { public void testCreateTrack() {
Track.Id trackId = new Track.Id(System.currentTimeMillis()); 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(); 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);
MatrixCursor cursor = new MatrixCursor(new String[]{ Track track = ContentProviderUtils.createTrack(cursorMock);
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(trackId, track.id());
assertEquals(name, track.name()); assertEquals(name, track.name());
} }
@@ -462,6 +449,8 @@ public class CustomContentProviderUtilsTest {
marker.setDescription(TEST_DESC); marker.setDescription(TEST_DESC);
contentProviderUtils.insertMarker(marker.getMarker()); contentProviderUtils.insertMarker(marker.getMarker());
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(contentResolverMock);
Marker.Id markerId = new Marker.Id(System.currentTimeMillis()); Marker.Id markerId = new Marker.Id(System.currentTimeMillis());
marker.setId(markerId); marker.setId(markerId);
ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId); ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId);
@@ -1195,7 +1184,7 @@ public class CustomContentProviderUtilsTest {
assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0f); assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0f);
} }
private Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) { private void testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) {
// given // given
Instant start = Instant.now(); Instant start = Instant.now();
TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil();
@@ -1218,27 +1207,22 @@ public class CustomContentProviderUtilsTest {
SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId);
TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats();
return Pair.create(sensorStatistics, stats); // 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);
} }
@Test @Test
public void testGetSensorStats_veryLongActivity12h() { public void testGetSensorStats_veryLongActivity12h() {
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(43200 / 6, false); 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 @Test
public void testGetSensorStats_withSeveralRandomStartSegments() { public void testGetSensorStats_withSeveralRandomStartSegments() {
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(5000, true); 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) { private TrackPoint getLastValidTrackPoint(Track.Id trackId) {
@@ -66,8 +66,7 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.io.file.TrackFileFormat; import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.exporter.TrackExporter; import de.dennisguse.opentracks.io.file.exporter.TrackExporter;
import de.dennisguse.opentracks.sensors.SensorManager; import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingPower;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
import de.dennisguse.opentracks.sensors.sensorData.Aggregator; import de.dennisguse.opentracks.sensors.sensorData.Aggregator;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer; import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence; import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
@@ -75,9 +74,10 @@ import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingDistanceSpee
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingPower; import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingPower;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate; import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorTemperature; import de.dennisguse.opentracks.sensors.sensorData.AggregatorTemperature;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorDataAggregator;
import de.dennisguse.opentracks.services.TrackRecordingService; import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator; import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/** /**
* Export a track to {@link TrackFileFormat} and verify that the import is identical. * Export a track to {@link TrackFileFormat} and verify that the import is identical.
@@ -94,20 +94,18 @@ public class ExportImportTest {
@Rule @Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
//For csv_export_only() as the timezone is hardcoded in the expectation. //For csv_export_only() as we the timezone is hardcoded in the expectation.
@Rule @Rule
public TimezoneRule timezoneRule = new TimezoneRule(TimeZone.getTimeZone("Europe/Berlin")); public TimezoneRule timezoneRule = new TimezoneRule(TimeZone.getTimeZone("Europe/Berlin"));
private static final Context context = ApplicationProvider.getApplicationContext();
@BeforeClass @BeforeClass
public static void preSetUp() { public static void preSetUp() {
// Prepare looper for Android's message queue // Prepare looper for Android's message queue
if (Looper.myLooper() == null) Looper.prepare(); if (Looper.myLooper() == null) Looper.prepare();
PreferencesUtils.resetPreferences(context, true);
} }
private final Context context = ApplicationProvider.getApplicationContext();
private final ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); private final ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
private static final ActivityType TRACK_ACTIVITY_TYPE = ActivityType.MOUNTAIN_BIKING; private static final ActivityType TRACK_ACTIVITY_TYPE = ActivityType.MOUNTAIN_BIKING;
@@ -199,6 +197,7 @@ public class ExportImportTest {
)); ));
trackPointCreator.setClock("2020-02-02T02:02:18Z"); trackPointCreator.setClock("2020-02-02T02:02:18Z");
trackPointCreator.getSensorManager().sensorDataAggregator = new SensorDataAggregator();
service.endCurrentTrack(); service.endCurrentTrack();
trackPointCreator.setClock("2020-02-02T02:03:20Z"); trackPointCreator.setClock("2020-02-02T02:03:20Z");
@@ -213,6 +212,7 @@ public class ExportImportTest {
sendLocation(trackPointCreator, "2020-02-02T02:03:50Z", 3.1234567, 16.001, 10, 27, 15, 999.123, 0f); sendLocation(trackPointCreator, "2020-02-02T02:03:50Z", 3.1234567, 16.001, 10, 27, 15, 999.123, 0f);
trackPointCreator.getSensorManager().sensorDataAggregator = new SensorDataAggregator();
trackPointCreator.setClock("2020-02-02T02:04:00Z"); trackPointCreator.setClock("2020-02-02T02:04:00Z");
service.endCurrentTrack(); service.endCurrentTrack();
@@ -296,22 +296,7 @@ public class ExportImportTest {
Power.of(50), Power.of(50),
new AltitudeGainLoss(0, 0) new AltitudeGainLoss(0, 0)
), ),
new TrackPoint( new TrackPoint(TrackPoint.Type.SEGMENT_END_MANUAL, Instant.parse("2020-02-02T02:02:18Z")),
null,
TrackPoint.Type.SEGMENT_END_MANUAL,
new Position(
Instant.parse("2020-02-02T02:02:18Z"),
3.123456, 14.001456, Distance.of(10),
Altitude.WGS84.of(1020.25), null,
null,
Speed.of(5)),
Distance.of(0),
HeartRate.of(69),
null,
Cadence.of(3),
Power.of(50),
new AltitudeGainLoss(0, 0)
),
new TrackPoint(TrackPoint.Type.SEGMENT_START_MANUAL, Instant.parse("2020-02-02T02:03:20Z")), new TrackPoint(TrackPoint.Type.SEGMENT_START_MANUAL, Instant.parse("2020-02-02T02:03:20Z")),
new TrackPoint( new TrackPoint(
null, null,
@@ -369,16 +354,7 @@ public class ExportImportTest {
null, null,
null, null,
new AltitudeGainLoss(0, 0)), new AltitudeGainLoss(0, 0)),
new TrackPoint( new TrackPoint(TrackPoint.Type.SEGMENT_END_MANUAL, Instant.parse("2020-02-02T02:04:00Z"))
null,
TrackPoint.Type.SEGMENT_END_MANUAL,
Position.of(Instant.parse("2020-02-02T02:04:00Z")),
null,
null,
null,
null,
null,
new AltitudeGainLoss(0, 0))
), actual); ), actual);
assertEquals(new Statistics( assertEquals(new Statistics(
@@ -684,17 +660,17 @@ public class ExportImportTest {
} }
} }
private static void mockSensorData(TrackPointCreator trackPointCreator, Float speed, Distance distance, float heartRate, float cadence, Float power, Float altitudeGain, float temperature) { private void mockSensorData(TrackPointCreator trackPointCreator, Float speed, Distance distance, float heartRate, float cadence, Float power, Float altitudeGain, float temperature) {
SensorManager sensorManager = trackPointCreator.getSensorManager(); SensorDataAggregator sensorDataAggregator = trackPointCreator.getSensorManager().sensorDataAggregator;
AggregatorCyclingPower cyclingPower = new AggregatorCyclingPower("", ""); AggregatorCyclingPower cyclingPower = new AggregatorCyclingPower("", "");
cyclingPower.add(trackPointCreator.getNow(), new CyclingPowerBluetooth.Data(Power.of(power), null)); cyclingPower.add(new Raw<>(trackPointCreator.createNow(), new BluetoothHandlerCyclingPower.Data(Power.of(power), null)));
sensorManager.setAggregator(cyclingPower); sensorDataAggregator.add(cyclingPower);
AggregatorHeartRate avgHeartRate = new AggregatorHeartRate("", ""); AggregatorHeartRate avgHeartRate = new AggregatorHeartRate("", "");
avgHeartRate.add(trackPointCreator.getNow(), HeartRate.of(heartRate)); avgHeartRate.add(new Raw<>(trackPointCreator.createNow(), HeartRate.of(heartRate)));
sensorManager.setAggregator(avgHeartRate); sensorDataAggregator.add(avgHeartRate);
AggregatorCyclingCadence cyclingCadence = new AggregatorCyclingCadence("", "") { AggregatorCyclingCadence cyclingCadence = new AggregatorCyclingCadence("", "") {
@NonNull @NonNull
@@ -708,7 +684,7 @@ public class ExportImportTest {
return true; return true;
} }
}; };
sensorManager.setAggregator(cyclingCadence); sensorDataAggregator.add(cyclingCadence);
if (distance != null && speed != null) { if (distance != null && speed != null) {
AggregatorCyclingDistanceSpeed aggregatorCyclingDistanceSpeed = new AggregatorCyclingDistanceSpeed("", "") { AggregatorCyclingDistanceSpeed aggregatorCyclingDistanceSpeed = new AggregatorCyclingDistanceSpeed("", "") {
@@ -723,14 +699,14 @@ public class ExportImportTest {
return true; return true;
} }
}; };
sensorManager.setAggregator(aggregatorCyclingDistanceSpeed); sensorDataAggregator.add(aggregatorCyclingDistanceSpeed);
} else { } else {
sensorManager.setAggregator(new AggregatorCyclingDistanceSpeed("", "")); sensorDataAggregator.add(new AggregatorCyclingDistanceSpeed("", ""));
} }
mockAltitudeChange(trackPointCreator, altitudeGain); mockAltitudeChange(trackPointCreator, altitudeGain);
sensorManager.setAggregator(new AggregatorTemperature("", "'") { sensorDataAggregator.add(new AggregatorTemperature("", "'") {
@NonNull @NonNull
@Override @Override
public Temperature getAggregatedValue(Instant now) { public Temperature getAggregatedValue(Instant now) {
@@ -746,11 +722,11 @@ public class ExportImportTest {
trackPointCreator.onChange(); trackPointCreator.onChange();
} }
private static void mockAltitudeChange(TrackPointCreator trackPointCreator, Float altitudeGain) { private void mockAltitudeChange(TrackPointCreator trackPointCreator, Float altitudeGain) {
SensorManager sensorManager = trackPointCreator.getSensorManager(); SensorDataAggregator sensorDataAggregator = trackPointCreator.getSensorManager().sensorDataAggregator;
if (altitudeGain == null) { if (altitudeGain == null) {
sensorManager.getAltitudeChangeHandler().setAggregator(new AggregatorBarometer("test", null)); sensorDataAggregator.add(new AggregatorBarometer("test", null));
return; return;
} }
@@ -766,10 +742,10 @@ public class ExportImportTest {
return true; return true;
} }
}; };
sensorManager.getAltitudeChangeHandler().setAggregator(aggregatorBarometer); sensorDataAggregator.add(aggregatorBarometer);
} }
public static void sendLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, float verticalAccuracy, float speed, double altitude, Float altitudeGain) { private void sendLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, float verticalAccuracy, float speed, double altitude, Float altitudeGain) {
Location location = new Location("mock"); Location location = new Location("mock");
location.setLatitude(latitude); location.setLatitude(latitude);
location.setLongitude(longitude); location.setLongitude(longitude);
@@ -781,6 +757,6 @@ public class ExportImportTest {
mockAltitudeChange(trackPointCreator, altitudeGain); mockAltitudeChange(trackPointCreator, altitudeGain);
trackPointCreator.setClock(time); trackPointCreator.setClock(time);
trackPointCreator.getSensorManager().getGpsHandler().onDataReceived(location); trackPointCreator.getSensorManager().getGpsManager().onLocationChanged(location);
} }
} }
@@ -26,7 +26,6 @@ public class TrackPointAssert {
Assert.assertEquals("has altitude", expected.position().hasAltitude(), actual.position().hasAltitude()); Assert.assertEquals("has altitude", expected.position().hasAltitude(), actual.position().hasAltitude());
if (expected.position().hasAltitude()) { if (expected.position().hasAltitude()) {
Assert.assertEquals("altitude", expected.position().altitude().getClass(), actual.position().altitude().getClass());
Assert.assertEquals("altitude", expected.position().altitude().toM(), actual.position().altitude().toM(), delta); Assert.assertEquals("altitude", expected.position().altitude().toM(), actual.position().altitude().toM(), delta);
} }
@@ -14,7 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import de.dennisguse.opentracks.data.CustomContentProvider; import de.dennisguse.opentracks.data.ContentProviderUtils;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class DataProviderTest { public class DataProviderTest {
@@ -24,7 +24,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion2_tracks() { public void supportsVersion2_tracks() {
// given // given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/tracks/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/tracks/1");
String[] expectedTracksColumns = new String[]{ String[] expectedTracksColumns = new String[]{
"_id", "_id",
"name", "name",
@@ -64,7 +64,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion2_trackpoints() { public void supportsVersion2_trackpoints() {
// given // given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/trackpoints/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/trackpoints/1");
String[] expectedTrackpointsColumns = { String[] expectedTrackpointsColumns = {
"_id", "_id",
"trackid", "trackid",
@@ -94,7 +94,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion2_markers() { public void supportsVersion2_markers() {
// given // given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/markers/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/markers/1");
String[] expectedMarkersColumns = { String[] expectedMarkersColumns = {
"_id", "_id",
"name", "name",
@@ -126,7 +126,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion3_tracks() { public void supportsVersion3_tracks() {
// given // given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/tracks/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/tracks/1");
String[] expectedTracksColumns = { String[] expectedTracksColumns = {
"_id", "_id",
"name", "name",
@@ -164,7 +164,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion3_trackpoints() { public void supportsVersion3_trackpoints() {
//given //given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/trackpoints/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/trackpoints/1");
String[] expectedTrackpointsColumns = { String[] expectedTrackpointsColumns = {
"_id", "_id",
"trackid", "trackid",
@@ -194,7 +194,7 @@ public class DataProviderTest {
@Test @Test
public void supportsVersion3_markers() { public void supportsVersion3_markers() {
// given // given
Uri uri = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/dashboard/markers/1"); Uri uri = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/markers/1");
String[] expectedMarkersColumns = { String[] expectedMarkersColumns = {
"_id", "_id",
"name", "name",
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -13,11 +13,11 @@ public class BluetoothHandlerBarometricPressureTest {
@Test @Test
public void parseEnvironmentalSensing_Pa() { public void parseEnvironmentalSensing_Pa() {
// given // given
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BarometerBluetooth.BAROMETRIC_PRESSURE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerBarometricPressure.BAROMETRIC_PRESSURE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{(byte) 0xB2, (byte) 0x48, (byte) 0x0F, (byte) 0x00}); characteristic.setValue(new byte[]{(byte) 0xB2, (byte) 0x48, (byte) 0x0F, (byte) 0x00});
// when // when
AtmosphericPressure pressure = BarometerBluetooth.parseEnvironmentalSensing(characteristic); AtmosphericPressure pressure = BluetoothHandlerBarometricPressure.parseEnvironmentalSensing(characteristic);
// then // then
assertEquals(AtmosphericPressure.ofHPA(1001.65f), pressure); assertEquals(AtmosphericPressure.ofHPA(1001.65f), pressure);
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
@@ -8,14 +8,14 @@ import android.util.Pair;
import org.junit.Test; import org.junit.Test;
public class CyclingDistanceSpeedBluetoothTest { public class BluetoothHandlerCyclingDistanceSpeedTest {
@Test @Test
public void parseCyclingSpeedCadence_crankOnly() { public void parseCyclingSpeedCadence_crankOnly() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerCyclingDistanceSpeed.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x02, (byte) 0xC8, 0x00, 0x00, 0x00, 0x06, (byte) 0x99}); characteristic.setValue(new byte[]{0x02, (byte) 0xC8, 0x00, 0x00, 0x00, 0x06, (byte) 0x99});
// when // when
Pair<CyclingDistanceSpeedBluetooth.WheelData, CyclingCadenceBluetooth.CrankData> sensor = CyclingDistanceSpeedBluetooth.parseCyclingCrankAndWheel(characteristic); Pair<BluetoothHandlerCyclingDistanceSpeed.WheelData, BluetoothHandlerCyclingCadence.CrankData> sensor = BluetoothHandlerCyclingDistanceSpeed.parseCyclingCrankAndWheel("address", "sensorName", characteristic);
// then // then
assertNull(sensor.first); assertNull(sensor.first);
@@ -24,11 +24,11 @@ public class CyclingDistanceSpeedBluetoothTest {
@Test @Test
public void parseCyclingSpeedCadence_wheelOnly() { public void parseCyclingSpeedCadence_wheelOnly() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerCyclingDistanceSpeed.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x01, (byte) 0xFF, (byte) 0xFF, 0, 1, 0x45, (byte) 0x99}); characteristic.setValue(new byte[]{0x01, (byte) 0xFF, (byte) 0xFF, 0, 1, 0x45, (byte) 0x99});
// when // when
Pair<CyclingDistanceSpeedBluetooth.WheelData, CyclingCadenceBluetooth.CrankData> sensor = CyclingDistanceSpeedBluetooth.parseCyclingCrankAndWheel(characteristic); Pair<BluetoothHandlerCyclingDistanceSpeed.WheelData, BluetoothHandlerCyclingCadence.CrankData> sensor = BluetoothHandlerCyclingDistanceSpeed.parseCyclingCrankAndWheel("address", "sensorName", characteristic);
// then // then
assertEquals(65535 + 16777216, sensor.first.wheelRevolutionsCount()); assertEquals(65535 + 16777216, sensor.first.wheelRevolutionsCount());
@@ -37,11 +37,11 @@ public class CyclingDistanceSpeedBluetoothTest {
@Test @Test
public void parseCyclingSpeedCadence_crankWheel() { public void parseCyclingSpeedCadence_crankWheel() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerCyclingDistanceSpeed.CYCLING_SPEED_CADENCE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x03, (byte) 0xC8, 0x00, 0x00, 0x01, 0x06, (byte) 0x99, (byte) 0xE1, 0x00, 0x45, (byte) 0x99}); characteristic.setValue(new byte[]{0x03, (byte) 0xC8, 0x00, 0x00, 0x01, 0x06, (byte) 0x99, (byte) 0xE1, 0x00, 0x45, (byte) 0x99});
// when // when
Pair<CyclingDistanceSpeedBluetooth.WheelData, CyclingCadenceBluetooth.CrankData> sensor = CyclingDistanceSpeedBluetooth.parseCyclingCrankAndWheel(characteristic); Pair<BluetoothHandlerCyclingDistanceSpeed.WheelData, BluetoothHandlerCyclingCadence.CrankData> sensor = BluetoothHandlerCyclingDistanceSpeed.parseCyclingCrankAndWheel("address", "sensorName", characteristic);
// then // then
assertEquals(200 + 16777216, sensor.first.wheelRevolutionsCount()); assertEquals(200 + 16777216, sensor.first.wheelRevolutionsCount());
@@ -0,0 +1,38 @@
package de.dennisguse.opentracks.sensors;
import static org.junit.Assert.assertEquals;
import android.bluetooth.BluetoothGattCharacteristic;
import org.junit.Test;
public class BluetoothHandlerCyclingPowerTest {
@Test
public void parseCyclingPower_power() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerCyclingPower.CYCLING_POWER.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0, 0, 40, 0});
// when
BluetoothHandlerCyclingPower.Data powerCadence = BluetoothHandlerCyclingPower.parseCyclingPower(characteristic);
// then
assertEquals(40, powerCadence.power().getW(), 0.01);
}
@Test
public void parseCyclingPower_power_with_cadence() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerCyclingPower.CYCLING_POWER.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x2C, 0x00, 0x00, 0x00, (byte) 0x9F, 0x00, 0x0C, 0x00, (byte) 0xE5, 0x42});
// when
BluetoothHandlerCyclingPower.Data powerCadence = BluetoothHandlerCyclingPower.parseCyclingPower(characteristic);
// then
assertEquals(0, powerCadence.power().getW(), 0.01);
assertEquals(12, powerCadence.crank().crankRevolutionsCount());
assertEquals(17125, powerCadence.crank().crankRevolutionsTime());
}
}
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -13,11 +13,11 @@ public class BluetoothHandlerHeartRateTest {
@Test @Test
public void parseHeartRate_uint8() { public void parseHeartRate_uint8() {
// given // given
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(HeartRateBluetooth.HEARTRATE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerHeartRate.HEARTRATE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x02, 0x3C}); characteristic.setValue(new byte[]{0x02, 0x3C});
// when // when
HeartRate heartRate = HeartRateBluetooth.parseHeartRate(characteristic); HeartRate heartRate = BluetoothHandlerHeartRate.parseHeartRate(characteristic);
// then // then
assertEquals(HeartRate.of(60), heartRate); assertEquals(HeartRate.of(60), heartRate);
@@ -26,11 +26,11 @@ public class BluetoothHandlerHeartRateTest {
@Test @Test
public void parseHeartRate_uint16() { public void parseHeartRate_uint16() {
// given // given
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(HeartRateBluetooth.HEARTRATE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerHeartRate.HEARTRATE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x01, 0x01, 0x01}); characteristic.setValue(new byte[]{0x01, 0x01, 0x01});
// when // when
HeartRate heartRate = HeartRateBluetooth.parseHeartRate(characteristic); HeartRate heartRate = BluetoothHandlerHeartRate.parseHeartRate(characteristic);
// then // then
assertEquals(HeartRate.of(257), heartRate); assertEquals(HeartRate.of(257), heartRate);
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -10,15 +10,15 @@ import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Speed;
public class RunningSpeedAndCadenceBluetoothTest { public class BluetoothHandlerRunningSpeedAndCadenceTest {
@Test @Test
public void parseRunningSpeedAndCadence_with_distance() { public void parseRunningSpeedAndCadence_with_distance() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(RunningSpeedAndCadenceBluetooth.RUNNING_SPEED_CADENCE.serviceUUID(), 0, 0); BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothHandlerRunningSpeedAndCadence.RUNNING_SPEED_CADENCE.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{2, 0, 5, 80, (byte) 0xFF, (byte) 0xFF, 0, 1}); characteristic.setValue(new byte[]{2, 0, 5, 80, (byte) 0xFF, (byte) 0xFF, 0, 1});
// when // when
RunningSpeedAndCadenceBluetooth.Data sensor = RunningSpeedAndCadenceBluetooth.parseRunningSpeedAndCadence("sensorName", characteristic); BluetoothHandlerRunningSpeedAndCadence.Data sensor = BluetoothHandlerRunningSpeedAndCadence.parseRunningSpeedAndCadence("sensorName", characteristic);
// then // then
assertEquals(Speed.of(5), sensor.speed()); assertEquals(Speed.of(5), sensor.speed());
@@ -1,137 +0,0 @@
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,62 +0,0 @@
package de.dennisguse.opentracks.sensors.driver;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import android.bluetooth.BluetoothGattCharacteristic;
import org.junit.Test;
public class CyclingPowerBluetoothTest {
@Test
public void parseCyclingPower_power() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(CyclingPowerBluetooth.CYCLING_POWER.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0, 0, 40, 0});
// when
CyclingPowerBluetooth.Data powerCadence = CyclingPowerBluetooth.parseCyclingPower(characteristic);
// then
assertEquals(40, powerCadence.power().getW(), 0.01);
}
@Test
public void parseCyclingPower_power_with_cadence() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(CyclingPowerBluetooth.CYCLING_POWER.serviceUUID(), 0, 0);
characteristic.setValue(new byte[]{0x2C, 0x00, 0x00, 0x00, (byte) 0x9F, 0x00, 0x0C, 0x00, (byte) 0xE5, 0x42});
// when
CyclingPowerBluetooth.Data powerCadence = CyclingPowerBluetooth.parseCyclingPower(characteristic);
// then
assertEquals(0, powerCadence.power().getW(), 0.01);
assertEquals(12, powerCadence.crank().crankRevolutionsCount());
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);
}
}
@@ -11,7 +11,7 @@ public class AggregatorBarometerTest {
private static void addSensorValue(AggregatorBarometer aggregatorBarometer, float[] values) { private static void addSensorValue(AggregatorBarometer aggregatorBarometer, float[] values) {
for (float f : values) { for (float f : values) {
aggregatorBarometer.add(Instant.MIN, AtmosphericPressure.ofHPA(f)); aggregatorBarometer.add(new Raw<>(Instant.MIN, AtmosphericPressure.ofHPA(f)));
} }
} }
@@ -24,7 +24,7 @@ public class AggregatorBarometerTest {
addSensorValue(subject, new float[]{1015f, 1015.01f, 1015.02f, 1015.03f, 1015.04f, 1015.05f, 1015.06f, 1015.07f, 1015.08f, 1015.09f, 1015.10f, 1015.11f, 1015.12f, 1015.13f, 1018f, 1018.1f, 1018.1f, 1018.1f, 1018.1f}); addSensorValue(subject, new float[]{1015f, 1015.01f, 1015.02f, 1015.03f, 1015.04f, 1015.05f, 1015.06f, 1015.07f, 1015.08f, 1015.09f, 1015.10f, 1015.11f, 1015.12f, 1015.13f, 1018f, 1018.1f, 1018.1f, 1018.1f, 1018.1f});
// then // then
Assert.assertEquals(0f, subject.output.gain_m(), 0.01); Assert.assertEquals(0f, subject.aggregatedValue.gain_m(), 0.01);
Assert.assertEquals(15f, subject.output.loss_m(), 0.01); Assert.assertEquals(15f, subject.aggregatedValue.loss_m(), 0.01);
} }
} }
@@ -2,7 +2,6 @@ package de.dennisguse.opentracks.sensors.sensorData;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -13,9 +12,10 @@ import java.time.Instant;
import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingCadence;
import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingDistanceSpeed;
import de.dennisguse.opentracks.sensors.UintUtils; import de.dennisguse.opentracks.sensors.UintUtils;
import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
import de.dennisguse.opentracks.sensors.driver.CyclingDistanceSpeedBluetooth;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class SensorDataCyclingTest { public class SensorDataCyclingTest {
@@ -25,8 +25,8 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, 1024)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(2, 2048)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(2, 2048)));
// then // then
assertEquals(60, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01); assertEquals(60, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
@@ -37,8 +37,8 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, 6184)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, 6184)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(2, 8016)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(2, 8016)));
// then // then
assertEquals(33.53, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01); assertEquals(33.53, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
@@ -49,8 +49,8 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, 1024)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, 2048)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, 2048)));
// then // then
assertEquals(Cadence.of(0), current.getAggregatedValue(Instant.MIN)); assertEquals(Cadence.of(0), current.getAggregatedValue(Instant.MIN));
@@ -62,8 +62,8 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, 1024)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(2, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(2, 1024)));
// then // then
assertFalse(current.hasReceivedData()); //TODO Cadence should be 0? assertFalse(current.hasReceivedData()); //TODO Cadence should be 0?
@@ -74,8 +74,8 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1, UintUtils.UINT16_MAX - 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(1, UintUtils.UINT16_MAX - 1024)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(2, 0)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(2, 0)));
// then // then
assertEquals(60, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01); assertEquals(60, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
@@ -87,32 +87,13 @@ public class SensorDataCyclingTest {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", ""); AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
// when // when
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(UintUtils.UINT32_MAX - 1, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(UintUtils.UINT32_MAX - 1, 1024)));
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(0, 2048)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingCadence.CrankData(0, 2048)));
// then // then
// TODO See #953 // TODO See #953
// assertEquals(60, current.getValue().getRPM(), 0.01); // assertEquals(60, current.getValue().getRPM(), 0.01);
assertNull(current.getAggregatedValue(Instant.MIN)); assertEquals(Cadence.of(0), 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 @Test
@@ -121,8 +102,8 @@ public class SensorDataCyclingTest {
current.setWheelCircumference(Distance.ofMM(2150)); current.setWheelCircumference(Distance.ofMM(2150));
// when // when
current.add(Instant.MIN, new CyclingDistanceSpeedBluetooth.WheelData(1, 6184)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingDistanceSpeed.WheelData(1, 6184)));
current.add(Instant.MIN, new CyclingDistanceSpeedBluetooth.WheelData(2, 8016)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingDistanceSpeed.WheelData(2, 8016)));
// then // then
assertEquals(2.15, current.getAggregatedValue(Instant.MIN).distance().toM(), 0.01); assertEquals(2.15, current.getAggregatedValue(Instant.MIN).distance().toM(), 0.01);
@@ -136,13 +117,14 @@ public class SensorDataCyclingTest {
current.setWheelCircumference(Distance.ofMM(2000)); current.setWheelCircumference(Distance.ofMM(2000));
// when // when
current.add(Instant.MIN, new CyclingDistanceSpeedBluetooth.WheelData(UintUtils.UINT32_MAX - 1, 1024)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingDistanceSpeed.WheelData(UintUtils.UINT32_MAX - 1, 1024)));
current.add(Instant.MIN, new CyclingDistanceSpeedBluetooth.WheelData(0, 2048)); current.add(new Raw<>(Instant.MIN, new BluetoothHandlerCyclingDistanceSpeed.WheelData(0, 2048)));
// then // then
// TODO See #953 // TODO See #953
// assertEquals(2, current.getValue().getDistance().toM(), 0.01); // assertEquals(2, current.getValue().getDistance().toM(), 0.01);
// assertEquals(2, current.getValue().getSpeed().toMPS(), 0.01); // assertEquals(2, current.getValue().getSpeed().toMPS(), 0.01);
assertNull(current.getAggregatedValue(Instant.MIN)); assertEquals(new AggregatorCyclingDistanceSpeed.Data(Distance.ZERO, Distance.ZERO, Speed.ZERO), current.getAggregatedValue(Instant.MIN));
} }
} }
@@ -1,23 +1,24 @@
package de.dennisguse.opentracks.services; package de.dennisguse.opentracks.services;
import static org.junit.Assert.assertEquals; 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 android.app.NotificationChannel;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Altitude; import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
@@ -28,24 +29,32 @@ import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.settings.UnitSystem; import de.dennisguse.opentracks.settings.UnitSystem;
@RunWith(MockitoJUnitRunner.class)
public class TrackRecordingServiceNotificationManagerTest { public class TrackRecordingServiceNotificationManagerTest {
private static final String CHANNEL_ID = "TrackRecordingServiceNotificationManagerTest";
private final Context context = ApplicationProvider.getApplicationContext(); private final Context context = ApplicationProvider.getApplicationContext();
private final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
@After @Mock
public void tearDown() { private NotificationCompat.Builder notificationCompatBuilder;
notificationManager.cancelAll();
} @Mock
private NotificationManager notificationManager;
@Test @Test
public void updateLocation_triggersAlertOnlyOnFirstInaccurateLocation() { public void updateLocation_triggersAlertOnlyOnFirstInaccurateLocation() {
notificationManager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, CHANNEL_ID, NotificationManager.IMPORTANCE_DEFAULT)); TrackPoint trackPointMock = new TrackPoint(TrackPoint.Type.TRACKPOINT,
new Position(
null,
null, null, Distance.of(999f),
Altitude.WGS84.of(10), null,
null,
Speed.ZERO)
);
TrackingNotificationBuilder notificationBuilder = new TrackingNotificationBuilder(context, CHANNEL_ID); when(notificationCompatBuilder.setOnlyAlertOnce(anyBoolean()))
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationBuilder); .thenReturn(notificationCompatBuilder);
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationCompatBuilder);
subject.setUnitSystem(UnitSystem.METRIC); subject.setUnitSystem(UnitSystem.METRIC);
Track track = new Track( Track track = new Track(
@@ -59,42 +68,16 @@ public class TrackRecordingServiceNotificationManagerTest {
Statistics.DEFAULT Statistics.DEFAULT
); );
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100)); RecordingData data = new RecordingData(track, trackPointMock, null, null);
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) { // when
TrackPoint trackPoint = new TrackPoint( subject.updateTrackPoint(context, data, Distance.of(100));
TrackPoint.Type.TRACKPOINT, subject.updateTrackPoint(context, data, Distance.of(100));
new Position( subject.updateTrackPoint(context, data, Distance.of(1000));
null, subject.updateTrackPoint(context, data, Distance.of(100));
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 { // then
private final List<Boolean> onlyAlertOnceValues = new ArrayList<>(); verify(notificationCompatBuilder, times(6)).setOnlyAlertOnce(true);
verify(notificationCompatBuilder, times(2)).setOnlyAlertOnce(false);
private TrackingNotificationBuilder(Context context, String channelId) {
super(context, channelId);
setSmallIcon(R.drawable.ic_logo_color_24dp);
}
@Override
public NotificationCompat.Builder setOnlyAlertOnce(boolean onlyAlertOnce) {
onlyAlertOnceValues.add(onlyAlertOnce);
return super.setOnlyAlertOnce(onlyAlertOnce);
}
} }
} }
@@ -2,11 +2,11 @@ package de.dennisguse.opentracks.services;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.location.Location; import android.location.Location;
import android.location.LocationManager;
import android.os.Looper; import android.os.Looper;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -44,12 +44,12 @@ import de.dennisguse.opentracks.data.models.Statistics;
import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.io.file.importer.TrackPointAssert; import de.dennisguse.opentracks.io.file.importer.TrackPointAssert;
import de.dennisguse.opentracks.sensors.BluetoothHandlerRunningSpeedAndCadence;
import de.dennisguse.opentracks.sensors.SensorManager; import de.dennisguse.opentracks.sensors.SensorManager;
import de.dennisguse.opentracks.sensors.driver.GpsInternal;
import de.dennisguse.opentracks.sensors.driver.RunningSpeedAndCadenceBluetooth;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer; import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate; import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorRunning; import de.dennisguse.opentracks.sensors.sensorData.AggregatorRunning;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator; import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils; import de.dennisguse.opentracks.settings.PreferencesUtils;
@@ -94,41 +94,6 @@ public class TrackRecordingServiceRecordingTest {
service = startService(); 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 @MediumTest
@Test @Test
public void recording_startStop() { public void recording_startStop() {
@@ -189,8 +154,7 @@ public class TrackRecordingServiceRecordingTest {
Track.Id trackId = service.startNewTrack(); Track.Id trackId = service.startNewTrack();
//We do not want the real GPS to interfere. //We do not want the real GPS to interfere.
GpsInternal gpsInternal = (GpsInternal)trackPointCreator.getSensorManager().getGpsHandler().driver; LocationManagerCompat.removeUpdates((LocationManager) context.getSystemService(Context.LOCATION_SERVICE), trackPointCreator.getSensorManager().getGpsManager());
LocationManagerCompat.removeUpdates(gpsInternal.locationManager, gpsInternal.locationListenerCompat);
String gps1 = "2020-02-02T02:02:03Z"; String gps1 = "2020-02-02T02:02:03Z";
sendGPSLocation(trackPointCreator, gps1, 45.0, 35.0, 1, 15); sendGPSLocation(trackPointCreator, gps1, 45.0, 35.0, 1, 15);
@@ -375,21 +339,16 @@ public class TrackRecordingServiceRecordingTest {
mockAltitudeChange(trackPointCreator, 0); mockAltitudeChange(trackPointCreator, 0);
SensorManager sensorManager = trackPointCreator.getSensorManager(); SensorManager sensorManager = trackPointCreator.getSensorManager();
sensorManager.sensorDataAggregator.add(new AggregatorHeartRate("", ""));
// when // when
String sensor1 = "2020-02-02T02:02:03Z"; String sensor1 = "2020-02-02T02:02:03Z";
trackPointCreator.setClock(sensor1); trackPointCreator.setClock(sensor1);
AggregatorHeartRate avgHeartRate = new AggregatorHeartRate("", ""); sensorManager.onChanged(new Raw<>(sensor1, HeartRate.of(5))); //Should be ignored
sensorManager.setAggregator(avgHeartRate);
//Should be ignored
avgHeartRate.add(trackPointCreator.getNow(), HeartRate.of(5));
sensorManager.onChange();
String sensor3 = "2020-02-02T02:02:13Z"; String sensor3 = "2020-02-02T02:02:13Z";
trackPointCreator.setClock(sensor3); trackPointCreator.setClock(sensor3);
avgHeartRate.add(trackPointCreator.getNow(), HeartRate.of(7)); sensorManager.onChanged(new Raw<>(sensor3, HeartRate.of(7)));
sensorManager.onChange();
String stopTime = "2020-02-02T02:02:15Z"; String stopTime = "2020-02-02T02:02:15Z";
trackPointCreator.setClock(stopTime); trackPointCreator.setClock(stopTime);
@@ -849,22 +808,19 @@ public class TrackRecordingServiceRecordingTest {
trackPointCreator.setClock(startTime); trackPointCreator.setClock(startTime);
Track.Id trackId = service.startNewTrack(); Track.Id trackId = service.startNewTrack();
AggregatorRunning aggregatorRunning = new AggregatorRunning("", "");
SensorManager sensorManager = trackPointCreator.getSensorManager(); SensorManager sensorManager = trackPointCreator.getSensorManager();
sensorManager.setAggregator(aggregatorRunning); sensorManager.sensorDataAggregator.add(new AggregatorRunning("", ""));
sensorManager.sensorDataAggregator.barometer = null;
// when // when
String sensor1 = "2020-02-02T02:02:03Z"; String sensor1 = "2020-02-02T02:02:03Z";
trackPointCreator.setClock(sensor1); trackPointCreator.setClock(sensor1);
//Should be ignored sensorManager.onChanged(new Raw<>(sensor1, new BluetoothHandlerRunningSpeedAndCadence.Data(Speed.of(5), Cadence.of(1), Distance.ZERO))); //Should be ignored
aggregatorRunning.add(trackPointCreator.getNow(), new RunningSpeedAndCadenceBluetooth.Data(Speed.of(5), Cadence.of(1), Distance.ZERO));
sensorManager.onChange();
// when // when
String sensor2 = "2020-02-02T02:02:04Z"; String sensor2 = "2020-02-02T02:02:04Z";
trackPointCreator.setClock(sensor2); trackPointCreator.setClock(sensor2);
aggregatorRunning.add(trackPointCreator.getNow(), new RunningSpeedAndCadenceBluetooth.Data(Speed.of(5), Cadence.of(2), Distance.of(2))); sensorManager.onChanged(new Raw<>(sensor2, new BluetoothHandlerRunningSpeedAndCadence.Data(Speed.of(5), Cadence.of(2), Distance.of(2))));
sensorManager.onChange();
// when // when
String gps1 = "2020-02-02T02:02:05Z"; String gps1 = "2020-02-02T02:02:05Z";
@@ -873,15 +829,12 @@ public class TrackRecordingServiceRecordingTest {
// when // when
String sensor3 = "2020-02-02T02:02:06Z"; String sensor3 = "2020-02-02T02:02:06Z";
trackPointCreator.setClock(sensor3); trackPointCreator.setClock(sensor3);
aggregatorRunning.add(trackPointCreator.getNow(), new RunningSpeedAndCadenceBluetooth.Data(Speed.of(5), Cadence.of(3), Distance.of(12))); sensorManager.onChanged(new Raw<>(sensor3, new BluetoothHandlerRunningSpeedAndCadence.Data(Speed.of(5), Cadence.of(3), Distance.of(12))));
sensorManager.onChange();
// when // when
String sensor4 = "2020-02-02T02:02:07Z"; String sensor4 = "2020-02-02T02:02:07Z";
trackPointCreator.setClock(sensor4); trackPointCreator.setClock(sensor4);
//Should be ignored sensorManager.onChanged(new Raw<>(sensor4, new BluetoothHandlerRunningSpeedAndCadence.Data(Speed.of(5), Cadence.of(4), Distance.of(14)))); //Should be ignored
aggregatorRunning.add(trackPointCreator.getNow(), new RunningSpeedAndCadenceBluetooth.Data(Speed.of(5), Cadence.of(4), Distance.of(14)));
sensorManager.onChange();
// when // when
String gps2 = "2020-02-02T02:02:08Z"; String gps2 = "2020-02-02T02:02:08Z";
@@ -890,9 +843,7 @@ public class TrackRecordingServiceRecordingTest {
// when // when
String sensor5 = "2020-02-02T02:02:10Z"; String sensor5 = "2020-02-02T02:02:10Z";
trackPointCreator.setClock(sensor5); trackPointCreator.setClock(sensor5);
//Should be ignored sensorManager.onChanged(new Raw<>(sensor5, new BluetoothHandlerRunningSpeedAndCadence.Data(Speed.of(5), Cadence.of(5), Distance.of(16)))); //Should be ignored
aggregatorRunning.add(trackPointCreator.getNow(), new RunningSpeedAndCadenceBluetooth.Data(Speed.of(5), Cadence.of(5), Distance.of(16)));
sensorManager.onChange();
// when // when
String gps3 = "2020-02-02T02:02:12Z"; String gps3 = "2020-02-02T02:02:12Z";
@@ -1011,7 +962,7 @@ public class TrackRecordingServiceRecordingTest {
} }
private void mockAltitudeChange(TrackPointCreator trackPointCreator, float altitudeGain) { private void mockAltitudeChange(TrackPointCreator trackPointCreator, float altitudeGain) {
trackPointCreator.getSensorManager().getAltitudeChangeHandler().setAggregator(new AggregatorBarometer("", "") { trackPointCreator.getSensorManager().sensorDataAggregator.barometer = new AggregatorBarometer("", "") {
@Override @Override
public boolean hasReceivedData() { public boolean hasReceivedData() {
return true; return true;
@@ -1022,7 +973,7 @@ public class TrackRecordingServiceRecordingTest {
public AltitudeGainLoss getAggregatedValue(Instant now) { public AltitudeGainLoss getAggregatedValue(Instant now) {
return new AltitudeGainLoss(altitudeGain, altitudeGain); return new AltitudeGainLoss(altitudeGain, altitudeGain);
} }
}); };
} }
private static void sendGPSLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, long speed) { private static void sendGPSLocation(TrackPointCreator trackPointCreator, String time, double latitude, double longitude, float accuracy, long speed) {
@@ -1034,6 +985,6 @@ public class TrackRecordingServiceRecordingTest {
location.setSpeed(speed); location.setSpeed(speed);
trackPointCreator.setClock(time); trackPointCreator.setClock(time);
trackPointCreator.getSensorManager().getGpsHandler().onDataReceived(location); trackPointCreator.getSensorManager().getGpsManager().onLocationChanged(location);
} }
} }
@@ -15,7 +15,6 @@
*/ */
package de.dennisguse.opentracks.services; package de.dennisguse.opentracks.services;
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotEquals;
@@ -42,7 +41,6 @@ import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@@ -58,8 +56,6 @@ import de.dennisguse.opentracks.sensors.GpsStatusValue;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class TrackRecordingServiceStateMachineTest { public class TrackRecordingServiceStateMachineTest {
private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(2);
@Rule @Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS); public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
@@ -127,7 +123,6 @@ public class TrackRecordingServiceStateMachineTest {
// when // when
service.tryStartSensors(); service.tryStartSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then // then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue()); assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
@@ -136,12 +131,11 @@ public class TrackRecordingServiceStateMachineTest {
// when // when
service.stopSensors(); service.stopSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then // then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue()); assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
assertEquals(RecordingData.NOT_RECORDING, service.getRecordingDataObservable().getValue()); assertEquals(RecordingData.NOT_RECORDING, service.getRecordingDataObservable().getValue());
assertEquals(GpsStatusValue.GPS_NONE, service.getGpsStatusObservable().getValue()); // assertEquals(GpsStatusValue.GPS_NONE, service.getGpsStatusObservable().getValue()); TODO BUG: GPS can only be stopped if a service is recording.
} }
@MediumTest @MediumTest
@@ -152,9 +146,6 @@ public class TrackRecordingServiceStateMachineTest {
// when // when
Track.Id trackId = service.startNewTrack(); Track.Id trackId = service.startNewTrack();
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilRecordingDataIsNotDefault();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then // then
assertTrue(service.isRecording()); assertTrue(service.isRecording());
@@ -163,13 +154,9 @@ public class TrackRecordingServiceStateMachineTest {
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue()); assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
service.endCurrentTrack(); service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// when // when
service.resumeTrack(trackId); service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then // then
assertTrue(service.isRecording()); assertTrue(service.isRecording());
@@ -180,8 +167,6 @@ public class TrackRecordingServiceStateMachineTest {
// when // when
service.endCurrentTrack(); service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then // then
assertFalse(service.isRecording()); assertFalse(service.isRecording());
@@ -192,8 +177,6 @@ public class TrackRecordingServiceStateMachineTest {
// when // when
service.resumeTrack(trackId); service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then // then
assertTrue(service.isRecording()); assertTrue(service.isRecording());
@@ -271,25 +254,4 @@ public class TrackRecordingServiceStateMachineTest {
assertNotNull(trackId); assertNotNull(trackId);
assertNull(newTrackId); 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);
}
} }
@@ -1,121 +0,0 @@
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.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.GrantPermissionRule;
import androidx.test.rule.ServiceTestRule;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import de.dennisguse.opentracks.TestUtil;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.Position;
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;
@RunWith(AndroidJUnit4.class)
public class TrackRecordingServiceUITest {
@Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
private static final Context context = ApplicationProvider.getApplicationContext();
@BeforeClass
public static void preSetUp() {
// Prepare looper for Android's message queue
if (Looper.myLooper() == null) Looper.prepare();
}
@Test
public void getDataForUI_gps() throws TimeoutException {
TrackRecordingService service = ((TrackRecordingService.Binder) mServiceRule.bindService(new Intent(context, TrackRecordingService.class)))
.getService();
RecordingData recordingData;
// given
TrackPointCreator trackPointCreator = service.getTrackPointCreator();
trackPointCreator.setClock("2020-02-02T02:02:02Z");
// when
service.startNewTrack();
disconnectRealGps(trackPointCreator.getSensorManager().getGpsHandler());
trackPointCreator.getSensorManager().reset();
RecordingData initialRecordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:02Z"), false);
// then
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
new TrackPointAssert().assertEquals(new TrackPoint(
null,
TrackPoint.Type.TRACKPOINT,
new Position(
Instant.parse("2020-02-02T02:02:03Z"),
3.1234567, 14.0014567, null,
Altitude.EGM2008.of(1013.05), null,
null,
Speed.of(15)),
null,
null,
null,
null,
null,
new AltitudeGainLoss(1, 1)
),
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,7 +52,6 @@ public class VoiceAnnouncementUtilsTest {
@Before @Before
public void setUp() { public void setUp() {
PreferencesUtils.setVoiceAnnounceUnit(true);
PreferencesUtils.setVoiceAnnounceHeartRateCurrent(false); PreferencesUtils.setVoiceAnnounceHeartRateCurrent(false);
PreferencesUtils.setVoiceAnnounceLapHeartRate(false); PreferencesUtils.setVoiceAnnounceLapHeartRate(false);
PreferencesUtils.setVoiceAnnounceAverageHeartRate(false); PreferencesUtils.setVoiceAnnounceAverageHeartRate(false);
@@ -222,48 +221,6 @@ public class VoiceAnnouncementUtilsTest {
assertEquals("12:00 AM. Total distance 19.9 kilometers. 1 hour. Average moving speed 19.9 kilometers per hour.", announcement); 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 @Test
public void getAnnouncement_withInterval_metric_speed() { public void getAnnouncement_withInterval_metric_speed() {
// given // given
@@ -599,7 +556,6 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapHeartRate(true); PreferencesUtils.setVoiceAnnounceLapHeartRate(true);
Pair<Track, Statistics> data = buildTrackWithTrackPoints(); 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); SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -617,7 +573,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString(); String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then // 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 " + lapHeartRate + " 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 133 bpm.", announcement);
} }
@Test @Test
@@ -633,7 +589,6 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapSpeedPace(false); PreferencesUtils.setVoiceAnnounceLapSpeedPace(false);
Pair<Track, Statistics> data = buildTrackWithTrackPoints(); 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); SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -651,7 +606,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString(); String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then // then
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement); assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
} }
@Test @Test
@@ -1,51 +0,0 @@
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,41 +1,36 @@
package de.dennisguse.opentracks.ui.aggregatedStatistics; package de.dennisguse.opentracks.ui.aggregatedStatistics;
import static androidx.test.espresso.Espresso.onView; 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.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.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText; 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 org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.util.Pair; import android.util.Pair;
import android.view.View;
import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.matcher.ViewMatchers;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule; import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.core.IsInstanceOf; import org.hamcrest.core.IsInstanceOf;
import org.hamcrest.Matcher;
import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List; import java.util.List;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TestUtil; import de.dennisguse.opentracks.TestUtil;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.content.data.TestDataUtil; import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.Track;
@@ -46,69 +41,53 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class EspressoAggregatedFilterTest { public class EspressoAggregatedFilterTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized"; private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule @Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
private ActivityScenario<AggregatedStatisticsActivity> scenario;
@Before @Before
public void setUp() { 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); Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20);
TrackBuilder trackBuilder = new TrackBuilder(pair.first); TrackBuilder trackBuilder = new TrackBuilder(pair.first);
trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED); trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED);
TestDataUtil.insertTrackWithLocations(contentProviderUtils, trackBuilder.getTrack(), pair.second); TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
scenario = ActivityScenario.launch(AggregatedStatisticsActivity.class);
}
@After
public void tearDown() {
stopTrackRecordingService();
if (scenario != null) {
scenario.close();
}
} }
@Test @Test
public void espressoAggregatedFilterTest() { public void espressoAggregatedFilterTest() {
waitUntilDisplayed(withId(R.id.bottom_app_bar), UI_TIMEOUT); // open AggregatedStatisticsActivity through toolbar's menu item
waitUntilDisplayed(withId(R.id.aggregated_statistics_filter), UI_TIMEOUT); onView(ViewMatchers.withId(R.id.bottom_app_bar))
waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1, UI_TIMEOUT); .check(matches(isDisplayed()));
waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED)), UI_TIMEOUT); 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());
// open FilterDialogFragment through the current bottom app bar action item // open FilterDialogFragment through toolbar's menu item
onView(withId(R.id.bottom_app_bar)) onView(withId(R.id.bottom_app_bar))
.check(matches(isDisplayed())); .check(matches(isDisplayed()));
onView(withId(R.id.bottom_app_bar)) onView(withId(R.id.bottom_app_bar))
.check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter)))); .check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter))));
onView(withId(R.id.aggregated_statistics_filter)) onView(withId(R.id.aggregated_statistics_filter))
.perform(performClick()); .perform(click());
// check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text // check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text
Matcher<View> activityTypeFilter = allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED), onView(allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED),
withParent(allOf(withId(R.id.filter_items), withParent(allOf(withId(R.id.filter_items),
withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))), withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))),
isDisplayed()); isDisplayed()))
onView(activityTypeFilter) .perform(waitFor(2000));
.inRoot(isDialog())
.check(matches(isDisplayed()));
// check there's an edit text for "from date" // check there's an edit text for "from date"
onView(withId(R.id.filter_date_edit_text_from)) onView(withId(R.id.filter_date_edit_text_from))
.inRoot(isDialog())
.check(matches(isDisplayed())); .check(matches(isDisplayed()));
// check there's an edit text for "to date" // check there's an edit text for "to date"
onView(withId(R.id.filter_date_edit_text_to)) onView(withId(R.id.filter_date_edit_text_to))
.inRoot(isDialog())
.check(matches(isDisplayed())); .check(matches(isDisplayed()));
} }
} }
@@ -35,10 +35,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f; float distanceInterval = 1000f;
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(50, distanceInterval); whenAndThen(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);
} }
/** /**
@@ -52,10 +49,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f; float distanceInterval = 1000f;
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(200, distanceInterval); whenAndThen(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);
} }
/** /**
@@ -69,10 +63,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f; float distanceInterval = 3000f;
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(3000, distanceInterval); whenAndThen(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);
} }
/** /**
@@ -86,10 +77,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f; float distanceInterval = 3000f;
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(1000, distanceInterval); whenAndThen(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);
} }
/** /**
@@ -103,10 +91,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f; float distanceInterval = 1000f;
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(10000, distanceInterval); whenAndThen(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 @Test
@@ -137,18 +122,15 @@ public class IntervalStatisticsUpdaterTest {
Pair<Track, List<TrackPoint>> trackWithStats = new Pair<>(dummyTrack, trackPoints); Pair<Track, List<TrackPoint>> trackWithStats = new Pair<>(dummyTrack, trackPoints);
// when and then // when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval); whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, 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 IntervalStatisticsComputation computeIntervalStatistics(int numberOfPoints, float distanceInterval) { private void whenAndThen(int numberOfPoints, float distanceInterval) {
Pair<Track, List<TrackPoint>> trackWithStats = buildTrackWithTrackPoints(numberOfPoints); Pair<Track, List<TrackPoint>> trackWithStats = buildTrackWithTrackPoints(numberOfPoints);
return computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval); whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
} }
private IntervalStatisticsComputation computeIntervalStatistics(Track track, List<TrackPoint> trackPoints, float distanceInterval) { private void whenAndThen(Track track, List<TrackPoint> trackPoints, int numberOfPoints, float distanceInterval) {
IntervalStatisticsUpdater intervalStatistics = new IntervalStatisticsUpdater(Distance.of(distanceInterval)); IntervalStatisticsUpdater intervalStatistics = new IntervalStatisticsUpdater(Distance.of(distanceInterval));
intervalStatistics.addTrackPoints(trackPoints.iterator()); intervalStatistics.addTrackPoints(trackPoints.iterator());
@@ -175,27 +157,23 @@ public class IntervalStatisticsUpdaterTest {
} }
} }
return new IntervalStatisticsComputation(track, intervalList, totalDistance, totalTime, totalGain, totalLoss); // then
} assertEquals(track.statistics().totalDuration().toSeconds(), totalTime, 0.01);
assertEquals(track.statistics().totalDistance().toM(), totalDistance.toM(), 0.01);
private void assertIntervalStatisticsComputation(IntervalStatisticsComputation computation, int numberOfPoints, float distanceInterval) { assertEquals(intervalList.size(), (int) Math.ceil(track.statistics().totalDistance().toM() / distanceInterval));
assertEquals(computation.track.statistics().totalDuration().toSeconds(), computation.totalTime, 0.01); if (totalGain != null && totalLoss != null) {
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); assertEquals(totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
assertEquals(computation.intervalList.size(), (int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval)); assertEquals(totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
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 { } else {
assertTrue(computation.intervalList.stream().noneMatch(i -> i.altitudeGainLoss() != null)); assertTrue(intervalStatistics.getIntervalList().stream().noneMatch(i -> i.altitudeGainLoss() != null));
} }
Distance remainingDistance = computation.totalDistance; for (int i = 0; i < intervalList.size() - 1; i++) {
for (int i = 0; i < computation.intervalList.size() - 1; i++) { assertEquals(intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
assertEquals(computation.intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001); totalDistance = totalDistance.minus(intervalList.get(i).totalDistance());
remainingDistance = remainingDistance.minus(computation.intervalList.get(i).totalDistance());
} }
assertEquals(computation.intervalList.get(computation.intervalList.size() - 1).totalDistance().toM(), remainingDistance.toM(), 0.01); assertEquals(intervalList.get(intervalList.size() - 1).totalDistance().toM(), totalDistance.toM(), 0.01);
} }
private static Pair<Track, List<TrackPoint>> buildTrackWithTrackPoints(int numberOfPoints) { private static Pair<Track, List<TrackPoint>> buildTrackWithTrackPoints(int numberOfPoints) {
@@ -217,27 +195,4 @@ public class IntervalStatisticsUpdaterTest {
return new Pair<>(track, trackPoints); 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;
}
}
} }
@@ -1,106 +0,0 @@
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.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(Activity activity) {
if (activityClass.isInstance(activity)) {
resumedActivityRef.set(activityClass.cast(activity));
latch.countDown();
}
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}
@Override
public void onActivityDestroyed(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,25 +1,14 @@
package de.dennisguse.opentracks.util; package de.dennisguse.opentracks.util;
import static org.hamcrest.Matchers.allOf;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import android.app.Activity;
import android.os.SystemClock;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent; import android.view.ViewParent;
import android.widget.AdapterView; import android.widget.ListView;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController; import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction; import androidx.test.espresso.ViewAction;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.espresso.util.TreeIterables;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout;
@@ -27,114 +16,61 @@ import org.hamcrest.Description;
import org.hamcrest.Matcher; import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher; import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
public class EspressoUtils { public class EspressoUtils {
private static final long VIEW_POLLING_INTERVAL_MS = 50; public static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
public static Matcher<View> withRecyclerViewItemCount(final int count) {
return new TypeSafeMatcher<>() { return new TypeSafeMatcher<>() {
@Override @Override
protected boolean matchesSafely(View view) { public void describeTo(Description description) {
if (!(view instanceof RecyclerView recyclerView)) { description.appendText("Child at position " + position + " in parent ");
return false; parentMatcher.describeTo(description);
}
RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
return adapter != null && adapter.getItemCount() == count;
} }
@Override @Override
public void describeTo(Description description) { public boolean matchesSafely(View view) {
description.appendText("RecyclerView should have " + count + " items"); ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
} }
}; };
} }
public static Matcher<View> withSelectedTab(final int index) { public static Matcher<View> withListSize (final int size) {
return new TypeSafeMatcher<>() { return new TypeSafeMatcher<>() {
@Override @Override
protected boolean matchesSafely(View view) { public boolean matchesSafely(final View view) {
return view instanceof TabLayout tabLayout return ((ListView) view).getCount() == size;
&& tabLayout.getSelectedTabPosition() == index;
} }
@Override @Override
public void describeTo(Description description) { public void describeTo(final Description description) {
description.appendText("TabLayout should have selected tab " + index); description.appendText("ListView should have " + size + " items");
} }
}; };
} }
public static Matcher<View> withViewPagerCurrentItem(final int index) { public static ViewAction waitFor(final long duration_ms) {
return new TypeSafeMatcher<>() { return new ViewAction() {
@Override @Override
protected boolean matchesSafely(View view) { public String getDescription() {
return view instanceof ViewPager2 viewPager2 return "Wait for milliseconds.";
&& viewPager2.getCurrentItem() == index
&& viewPager2.getScrollState() == ViewPager2.SCROLL_STATE_IDLE;
} }
@Override @Override
public void describeTo(Description description) { public Matcher<View> getConstraints() {
description.appendText("ViewPager2 should be idle on item " + index); return isDisplayed();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
} }
}; };
} }
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) { public static ViewAction selectTabAtIndex(final int index) {
return new ViewAction() { return new ViewAction() {
@Override @Override
@@ -155,135 +91,4 @@ 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();
}
};
}
} }
@@ -1,57 +0,0 @@
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();
}
}
@@ -1,30 +0,0 @@
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();
}
}
@@ -1,50 +0,0 @@
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));
}
}
}
@@ -1,70 +0,0 @@
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());
}
}
+2 -2
View File
@@ -4,10 +4,10 @@
"2020-02-02T03:02:04+01:00","TRACKPOINT",,,,,,54,1,1,10,66,3,50 "2020-02-02T03:02:04+01:00","TRACKPOINT",,,,,,54,1,1,10,66,3,50
"2020-02-02T03:02:15+01:00","TRACKPOINT",,,,,,,,,,68,3,50 "2020-02-02T03:02:15+01:00","TRACKPOINT",,,,,,,,,,68,3,50
"2020-02-02T03:02:17+01:00","TRACKPOINT",3.123456,14.001456,1020.2,10,,18,0,0,2,69,3,50 "2020-02-02T03:02:17+01:00","TRACKPOINT",3.123456,14.001456,1020.2,10,,18,0,0,2,69,3,50
"2020-02-02T03:02:18+01:00","SEGMENT_END_MANUAL",3.123456,14.001456,1020.2,10,,18,0,0,0,69,3,50 "2020-02-02T03:02:18+01:00","SEGMENT_END_MANUAL",,,,,,,,,,,,
"2020-02-02T03:03:20+01:00","SEGMENT_START_MANUAL",,,,,,,,,,,, "2020-02-02T03:03:20+01:00","SEGMENT_START_MANUAL",,,,,,,,,,,,
"2020-02-02T03:03:21+01:00","TRACKPOINT",3.123456,14.002456,999.1,10,,54,0,0,,,, "2020-02-02T03:03:21+01:00","TRACKPOINT",3.123456,14.002456,999.1,10,,54,0,0,,,,
"2020-02-02T03:03:22+01:00","SEGMENT_START_AUTOMATIC",3.123456,16,999.1,10,,54,0,0,,,, "2020-02-02T03:03:22+01:00","SEGMENT_START_AUTOMATIC",3.123456,16,999.1,10,,54,0,0,,,,
"2020-02-02T03:03:30+01:00","IDLE",,,,,,,0,0,,,, "2020-02-02T03:03:30+01:00","IDLE",,,,,,,0,0,,,,
"2020-02-02T03:03:50+01:00","TRACKPOINT",3.123456,16.001,999.1,10,,54,0,0,,,, "2020-02-02T03:03:50+01:00","TRACKPOINT",3.123456,16.001,999.1,10,,54,0,0,,,,
"2020-02-02T03:04:00+01:00","SEGMENT_END_MANUAL",,,,,,,0,0,,,, "2020-02-02T03:04:00+01:00","SEGMENT_END_MANUAL",,,,,,,,,,,,
1 #time trackpoint_type latitude longitude altitude accuracy_horizontal accuracy_vertical speed altitude_gain altitude_loss sensor_distance heartrate cadence power
4 2020-02-02T03:02:04+01:00 TRACKPOINT 54 1 1 10 66 3 50
5 2020-02-02T03:02:15+01:00 TRACKPOINT 68 3 50
6 2020-02-02T03:02:17+01:00 TRACKPOINT 3.123456 14.001456 1020.2 10 18 0 0 2 69 3 50
7 2020-02-02T03:02:18+01:00 SEGMENT_END_MANUAL 3.123456 14.001456 1020.2 10 18 0 0 0 69 3 50
8 2020-02-02T03:03:20+01:00 SEGMENT_START_MANUAL
9 2020-02-02T03:03:21+01:00 TRACKPOINT 3.123456 14.002456 999.1 10 54 0 0
10 2020-02-02T03:03:22+01:00 SEGMENT_START_AUTOMATIC 3.123456 16 999.1 10 54 0 0
11 2020-02-02T03:03:30+01:00 IDLE 0 0
12 2020-02-02T03:03:50+01:00 TRACKPOINT 3.123456 16.001 999.1 10 54 0 0
13 2020-02-02T03:04:00+01:00 SEGMENT_END_MANUAL 0 0
+1 -1
View File
@@ -14,5 +14,5 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="launcher_name" translatable="false">OpenTracks (Debug)</string> <string name="app_name" translatable="false">OpenTracks (Debug)</string>
</resources> </resources>
+1 -1
View File
@@ -78,7 +78,7 @@ limitations under the License.
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:hasFragileUserData="false" android:hasFragileUserData="false"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:label="@string/launcher_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/DayNightDynamicTheme"> <!-- Default for AppCompatActivities --> android:theme="@style/DayNightDynamicTheme"> <!-- Default for AppCompatActivities -->
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
import android.os.StrictMode;
import android.util.Log; import android.util.Log;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -28,11 +27,6 @@ public class Startup extends Application {
// Include version information into stack traces. // 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); 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); PreferencesUtils.initPreferences(this);
// Set default values of preferences on first start. // Set default values of preferences on first start.
@@ -36,6 +36,7 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Altitude; import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeExtremities; import de.dennisguse.opentracks.data.models.AltitudeExtremities;
@@ -68,10 +69,23 @@ public class ContentProviderUtils {
private static final String TAG = ContentProviderUtils.class.getSimpleName(); private static final String TAG = ContentProviderUtils.class.getSimpleName();
//TODO Move to CustomContentProvider
// The authority (the first part of the URI) for the app's content provider.
static final String AUTHORITY_PACKAGE = BuildConfig.APPLICATION_ID + ".content";
//TODO Move to CustomContentProvider
// The base URI for the app's content provider.
public static final String CONTENT_BASE_URI = "content://" + AUTHORITY_PACKAGE;
private static final String ID_SEPARATOR = ","; private static final String ID_SEPARATOR = ",";
private final ContentResolver contentResolver; private final ContentResolver contentResolver;
//TODO Check if this can be used in more than one place. If not remove.
public interface ContentProviderSelectionInterface {
SelectionData buildSelection();
}
public ContentProviderUtils(Context context) { public ContentProviderUtils(Context context) {
contentResolver = context.getContentResolver(); contentResolver = context.getContentResolver();
} }
@@ -741,7 +755,7 @@ public class ContentProviderUtils {
} }
@NonNull @NonNull
public List<AggregatedStatistic> getAggregatedStatisticsForTracks(TrackSelection selection) { public List<AggregatedStatistic> getAggregatedStatisticsForTracks(ContentProviderSelectionInterface selection) {
SelectionData data = selection.buildSelection(); 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(TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS, null, data.selection(), data.getSelectionArgs(), null)) {
ArrayList<AggregatedStatistic> aggregatedStatistics = new ArrayList<>(cursor.getCount()); ArrayList<AggregatedStatistic> aggregatedStatistics = new ArrayList<>(cursor.getCount());
@@ -34,7 +34,6 @@ import androidx.annotation.NonNull;
import java.util.Arrays; import java.util.Arrays;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.tables.MarkerColumns; import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.data.tables.TrackPointsColumns; import de.dennisguse.opentracks.data.tables.TrackPointsColumns;
@@ -51,11 +50,6 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
*/ */
public class CustomContentProvider extends ContentProvider { public class CustomContentProvider extends ContentProvider {
// The authority (the first part of the URI) for the app's content provider.
static final String AUTHORITY_PACKAGE = BuildConfig.APPLICATION_ID + ".content";
// The base URI for the app's content provider.
public static final String CONTENT_BASE_URI = "content://" + AUTHORITY_PACKAGE;
private static final String TAG = CustomContentProvider.class.getSimpleName(); private static final String TAG = CustomContentProvider.class.getSimpleName();
private static final String SQL_LIST_DELIMITER = ","; private static final String SQL_LIST_DELIMITER = ",";
@@ -102,22 +96,22 @@ public class CustomContentProvider extends ContentProvider {
public CustomContentProvider() { public CustomContentProvider() {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath(), UrlType.TRACKPOINTS.ordinal()); uriMatcher.addURI(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath() + "/#", UrlType.MARKERS_BY_ID.ordinal()); uriMatcher.addURI(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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(ContentProviderUtils.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()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.getPath() + "*", UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.ordinal());
} }
@Override @Override
@@ -153,7 +147,7 @@ public class CustomContentProvider extends ContentProvider {
} finally { } finally {
db.endTransaction(); db.endTransaction();
} }
notifyChanges(url); getContext().getContentResolver().notifyChange(url, null, false);
int totalChanges = getTotalChanges() - totalChangesBefore; int totalChanges = getTotalChanges() - totalChangesBefore;
Log.i(TAG, "Deleted " + totalChanges + " total rows from database"); Log.i(TAG, "Deleted " + totalChanges + " total rows from database");
@@ -212,8 +206,7 @@ public class CustomContentProvider extends ContentProvider {
} finally { } finally {
db.endTransaction(); db.endTransaction();
} }
notifyChanges(url); getContext().getContentResolver().notifyChange(url, null, false);
return result; return result;
} }
@@ -236,8 +229,7 @@ public class CustomContentProvider extends ContentProvider {
} finally { } finally {
db.endTransaction(); db.endTransaction();
} }
notifyChanges(url); getContext().getContentResolver().notifyChange(url, null, false);
return numInserted; return numInserted;
} }
@@ -350,36 +342,10 @@ public class CustomContentProvider extends ContentProvider {
} }
} }
notifyChanges(url); getContext().getContentResolver().notifyChange(url, null, false);
return count; 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 @NonNull
private UrlType getUrlType(Uri url) { private UrlType getUrlType(Uri url) {
UrlType[] urlTypes = UrlType.values(); UrlType[] urlTypes = UrlType.values();
@@ -450,9 +416,9 @@ public class CustomContentProvider extends ContentProvider {
@Deprecated //TODO Should not be used anymore @Deprecated //TODO Should not be used anymore
MARKERS_BY_TRACKID, MARKERS_BY_TRACKID,
DASHBOARD_TRACKS_BY_IDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/tracks/"), TracksColumns.CONTENT_TYPE), DASHBOARD_TRACKS_BY_IDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/tracks/"), TracksColumns.CONTENT_TYPE),
DASHBOARD_TRACKPOINTS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/trackpoints/"), TrackPointsColumns.CONTENT_TYPE), DASHBOARD_TRACKPOINTS_BY_TRACKIDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/trackpoints/"), TrackPointsColumns.CONTENT_TYPE),
DASHBOARD_MARKERS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/markers/"), MarkerColumns.CONTENT_TYPE); DASHBOARD_MARKERS_BY_TRACKIDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/markers/"), MarkerColumns.CONTENT_TYPE);
private final Uri uri; private final Uri uri;
@@ -59,12 +59,12 @@ public class ShareContentProvider extends CustomContentProvider {
private static final String TRACKID_DELIMITER = "_"; private static final String TRACKID_DELIMITER = "_";
static { static {
uriMatcher.addURI(CustomContentProvider.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.GPX.getPreferenceId() + "/*/*", URI_GPX); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.GPX.getPreferenceId() + "/*/*", URI_GPX);
uriMatcher.addURI(CustomContentProvider.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA.getPreferenceId() + "/*/*", URI_KML_WITH_TRACKDETAIL_SENSORDATA); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA.getPreferenceId() + "/*/*", URI_KML_WITH_TRACKDETAIL_SENSORDATA);
uriMatcher.addURI(CustomContentProvider.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getPreferenceId() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getPreferenceId() + "/*/*", URI_KMZ_WITH_TRACKDETAIL_SENSORDATA_AND_PICTURES);
uriMatcher.addURI(CustomContentProvider.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.CSV.getPreferenceId() + "/*/*", URI_CSV); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.CSV.getPreferenceId() + "/*/*", URI_CSV);
} }
/** /**
@@ -10,7 +10,7 @@ import java.util.List;
import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.TracksColumns; import de.dennisguse.opentracks.data.tables.TracksColumns;
public class TrackSelection { public class TrackSelection implements ContentProviderUtils.ContentProviderSelectionInterface {
private final List<Track.Id> trackIds = new ArrayList<>(); private final List<Track.Id> trackIds = new ArrayList<>();
private final List<String> categories = new ArrayList<>(); private final List<String> categories = new ArrayList<>();
private Instant from; private Instant from;
@@ -40,6 +40,7 @@ public class TrackSelection {
return trackIds.isEmpty() && categories.isEmpty() && from == null && to == null; return trackIds.isEmpty() && categories.isEmpty() && from == null && to == null;
} }
@Override
public ContentProviderUtils.SelectionData buildSelection() { public ContentProviderUtils.SelectionData buildSelection() {
String selection = ""; String selection = "";
List<String> selectionArgs = new ArrayList<>(); List<String> selectionArgs = new ArrayList<>();
@@ -30,7 +30,6 @@ public enum ActivityType {
FERRY("ferry", R.string.activity_type_ferry, R.drawable.ic_activity_boat_24dp, true), 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), FRISBEE("frisbee", R.string.activity_type_frisbee, ActivityIcon.ICON_UNKNOWN, true),
GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true), GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true),
GRAVEL_BIKING("gravel biking", R.string.activity_type_biking, R.drawable.ic_activity_bike_24dp, true),
HANG_GLIDING("hang gliding", R.string.activity_type_hang_gliding, ActivityIcon.ICON_UNKNOWN, 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), HELICOPTER("helicopter", R.string.activity_type_helicopter, ActivityIcon.ICON_UNKNOWN, true),
HIKING("hiking", R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false), HIKING("hiking", R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false),
@@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting;
* @author Leif Hendrik Wilden * @author Leif Hendrik Wilden
* @author Rodrigo Damazio * @author Rodrigo Damazio
*/ */
//TODO All data should be final; no default values.
public final class MarkerBuilder { public final class MarkerBuilder {
private Marker.Id id; private Marker.Id id;
@@ -56,6 +57,14 @@ public final class MarkerBuilder {
this.position = trackPoint.position(); this.position = trackPoint.position();
} }
public MarkerBuilder(@Nullable Track.Id trackId, @NonNull TrackPoint trackPoint, String name, String description, String typeLocalized, Uri photoUrl) {
this(trackId, trackPoint);
this.name = name;
this.description = description;
this.typeLocalized = typeLocalized;
this.photoUrl = photoUrl;
}
public MarkerBuilder(Marker marker) { public MarkerBuilder(Marker marker) {
this.id = marker.id(); this.id = marker.id();
this.trackId = marker.trackId(); this.trackId = marker.trackId();
@@ -8,7 +8,7 @@ import androidx.annotation.Nullable;
import java.time.Instant; import java.time.Instant;
public record Position( public record Position(
@Nullable Instant time, //TODO It may be a good idea to move time out of Position @Nullable Instant time,
@Nullable Double latitude, @Nullable Double latitude,
@Nullable Double longitude, @Nullable Double longitude,
@Nullable Distance horizontalAccuracy, @Nullable Distance horizontalAccuracy,
@@ -17,6 +17,10 @@ public record Position(
@Nullable Float bearing, @Nullable Float bearing,
@Nullable Speed speed @Nullable Speed speed
) { ) {
@Deprecated
public static Position empty() {
return of(null);
}
public static Position of(@NonNull Instant time) { public static Position of(@NonNull Instant time) {
return new Position( return new Position(
@@ -20,7 +20,6 @@ import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.time.Instant; import java.time.Instant;
@@ -64,7 +63,7 @@ public record TrackPoint(
); );
} }
@VisibleForTesting @Deprecated //TOOD Private?
public TrackPoint(Type type, Instant now) { public TrackPoint(Type type, Instant now) {
this( this(
null, null,
@@ -22,7 +22,7 @@ import android.provider.BaseColumns;
import android.widget.ArrayAdapter; import android.widget.ArrayAdapter;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.CustomContentProvider; import de.dennisguse.opentracks.data.ContentProviderUtils;
/** /**
* Constants for markers table. * Constants for markers table.
@@ -32,8 +32,8 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface MarkerColumns extends BaseColumns { public interface MarkerColumns extends BaseColumns {
String TABLE_NAME = "markers"; String TABLE_NAME = "markers";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME); Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid"); Uri CONTENT_URI_BY_TRACKID = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint"; String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.waypoint";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.waypoint"; String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.waypoint";
String DEFAULT_SORT_ORDER = _ID; String DEFAULT_SORT_ORDER = _ID;
@@ -19,7 +19,7 @@ package de.dennisguse.opentracks.data.tables;
import android.net.Uri; import android.net.Uri;
import android.provider.BaseColumns; import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider; import de.dennisguse.opentracks.data.ContentProviderUtils;
/** /**
* Constants for the track points table. * Constants for the track points table.
@@ -29,8 +29,8 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TrackPointsColumns extends BaseColumns { public interface TrackPointsColumns extends BaseColumns {
String TABLE_NAME = "trackpoints"; String TABLE_NAME = "trackpoints";
Uri CONTENT_URI_BY_ID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME); Uri CONTENT_URI_BY_ID = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid"); Uri CONTENT_URI_BY_TRACKID = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint"; String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.trackpoint";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.trackpoint"; String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.trackpoint";
String DEFAULT_SORT_ORDER = _ID; String DEFAULT_SORT_ORDER = _ID;
@@ -19,7 +19,7 @@ package de.dennisguse.opentracks.data.tables;
import android.net.Uri; import android.net.Uri;
import android.provider.BaseColumns; import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider; import de.dennisguse.opentracks.data.ContentProviderUtils;
/** /**
* Constants for the tracks table. * Constants for the tracks table.
@@ -29,9 +29,9 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TracksColumns extends BaseColumns { public interface TracksColumns extends BaseColumns {
String TABLE_NAME = "tracks"; String TABLE_NAME = "tracks";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME); Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_SENSOR_STATS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats"); Uri CONTENT_URI_SENSOR_STATS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats");
Uri CONTENT_URI_AGGREGATED_STATISTICS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/"); Uri CONTENT_URI_AGGREGATED_STATISTICS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/");
String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track"; String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track";
String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track"; String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track";
String DEFAULT_SORT_ORDER = _ID; String DEFAULT_SORT_ORDER = _ID;
@@ -167,11 +167,8 @@ public class ExportActivity extends AbstractActivity {
new Thread(() -> { new Thread(() -> {
directoryFiles = ExportUtils.getAllFiles(ExportActivity.this, documentFile.getUri()); directoryFiles = ExportUtils.getAllFiles(ExportActivity.this, documentFile.getUri());
runOnUiThread(() -> { runOnUiThread(() -> {
if (createExportTasks(allInOneFile)) { createExportTasks(allInOneFile);
nextExport(null); nextExport(null);
} else {
onExportEnded();
}
}); });
}).start(); }).start();
} else { } else {
@@ -220,12 +217,8 @@ public class ExportActivity extends AbstractActivity {
exportTasks.clear(); exportTasks.clear();
} }
private boolean createExportTasks(boolean allInOneFile) { private void createExportTasks(boolean allInOneFile) {
List<Track> tracks = contentProviderUtils.getTracks(); List<Track> tracks = contentProviderUtils.getTracks();
if (tracks.isEmpty()) {
return false;
}
exportTasks = new ArrayList<>(); exportTasks = new ArrayList<>();
if (allInOneFile) { if (allInOneFile) {
String filename = "OpenTracks-Backup"; String filename = "OpenTracks-Backup";
@@ -234,7 +227,6 @@ public class ExportActivity extends AbstractActivity {
exportTasks.addAll(tracks.stream().map(it -> new ExportTask(null, trackFileFormat, List.of(it.id()))).toList()); exportTasks.addAll(tracks.stream().map(it -> new ExportTask(null, trackFileFormat, List.of(it.id()))).toList());
} }
trackExportTotalCount = exportTasks.size(); trackExportTotalCount = exportTasks.size();
return true;
} }
/** /**
@@ -399,7 +391,7 @@ public class ExportActivity extends AbstractActivity {
/** /**
* Try to resolve the conflict if user gave the info needed. * Try to resolve the conflict if user gave the info needed.
* Otherwise, shows the buttons and views needed to gives the user the possibility of take a decision. * Otherwise shows the buttons and views needed to gives the user the possibility of take a decision.
* *
* @return true if it could resolve the conflict or false otherwise. * @return true if it could resolve the conflict or false otherwise.
*/ */
@@ -297,17 +297,7 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
} }
if (latitude == null || longitude == null) { if (latitude == null || longitude == null) {
return new TrackPoint( return new TrackPoint(TrackPoint.Type.TRACKPOINT, parsedTime.toInstant());
null,
TrackPoint.Type.TRACKPOINT,
Position.of(parsedTime.toInstant()),
null,
null,
null,
null,
null,
null
);
} }
double latitudeParsed; double latitudeParsed;
@@ -1,50 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.AtmosphericPressure;
import de.dennisguse.opentracks.sensors.driver.BarometerBluetooth;
import de.dennisguse.opentracks.sensors.driver.BarometerInternal;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
/**
* Estimates the altitude gain and altitude loss using the device's pressure sensor (i.e., barometer).
*/
//TODO Rename class
public class AltitudeChangeHandler extends SensorHandler<AtmosphericPressure, AltitudeGainLoss> {
public AltitudeChangeHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverInternal() {
return new BarometerInternal(this);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new BarometerBluetooth()
);
}
@NonNull
@Override
public AggregatorBarometer createAggregator(String sensorAddress, String sensorName) {
return new AggregatorBarometer(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_pressure_key;
}
}
@@ -14,7 +14,7 @@
* the License. * the License.
*/ */
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothAdapter;
@@ -31,25 +31,23 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.sensors.BluetoothUtils; import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
/** /**
* Manages connection to a Bluetooth LE sensor and subscribes for onChange-notifications. * Manages connection to a Bluetooth LE sensor and subscribes for onChange-notifications.
*/ */
//TODO Re-act to Bluetooth disabled/enabled
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
public class BluetoothDriver<T> implements Driver { public class BluetoothConnectionManager implements Driver {
private static final String TAG = BluetoothDriver.class.getSimpleName(); private static final String TAG = BluetoothConnectionManager.class.getSimpleName();
private final DriverObserver<T> observer; private final SensorManager.SensorDataChangedObserver observer;
private final BluetoothParser<T> sensorHandler; private final SensorHandlerInterface sensorHandler;
private final BluetoothAdapter bluetoothAdapter; private final BluetoothAdapter bluetoothAdapter;
private BluetoothGatt bluetoothGatt; private BluetoothGatt bluetoothGatt;
@@ -57,29 +55,27 @@ public class BluetoothDriver<T> implements Driver {
@Override @Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
switch (newState) { switch (newState) {
case BluetoothProfile.STATE_CONNECTING -> { case BluetoothProfile.STATE_CONNECTING ->
Log.i(TAG, gatt.getDevice() + ": connecting to sensor"); Log.i(TAG, gatt.getDevice() + ": connecting to sensor");
}
case BluetoothProfile.STATE_CONNECTED -> { case BluetoothProfile.STATE_CONNECTED -> {
Log.i(TAG, gatt.getDevice() + ": connected to sensor; discovering services"); Log.i(TAG, gatt.getDevice() + ": connected to sensor; discovering services");
gatt.discoverServices(); gatt.discoverServices();
} }
case BluetoothProfile.STATE_DISCONNECTING -> Log.i(TAG, gatt.getDevice() + ": disconnecting from sensor: "); case BluetoothProfile.STATE_DISCONNECTING ->
Log.i(TAG, gatt.getDevice() + ": disconnecting from sensor: ");
case BluetoothProfile.STATE_DISCONNECTED -> { case BluetoothProfile.STATE_DISCONNECTED -> {
//This is also triggered, if no connection was established (ca. 30s) //This is also triggered, if no connection was established (ca. 30s)
Log.i(TAG, gatt.getDevice() + ": disconnected from sensor: trying to reconnect"); Log.i(TAG, gatt.getDevice() + ": disconnected from sensor: trying to reconnect");
if (gatt.connect()) { if (gatt.connect()) {
Log.e(TAG, gatt.getDevice() + ": could not trigger reconnect for sensor"); Log.e(TAG, gatt.getDevice() + ": could not trigger reconnect for sensor");
} }
observer.onConnectionLost(); clearData();
} }
} }
} }
@Override @Override
public void onServicesDiscovered(@NonNull BluetoothGatt gatt, int status) { public void onServicesDiscovered(@NonNull BluetoothGatt gatt, int status) {
BluetoothDevice device = gatt.getDevice();
BluetoothGattService gattService = null; BluetoothGattService gattService = null;
ServiceMeasurementUUID serviceMeasurement = null; ServiceMeasurementUUID serviceMeasurement = null;
for (ServiceMeasurementUUID s : sensorHandler.getServices()) { for (ServiceMeasurementUUID s : sensorHandler.getServices()) {
@@ -91,13 +87,13 @@ public class BluetoothDriver<T> implements Driver {
} }
if (gattService == null) { if (gattService == null) {
Log.e(TAG, device + ": could not get gattService for serviceUUID=" + serviceMeasurement); Log.e(TAG, gatt.getDevice() + ": could not get gattService for serviceUUID=" + serviceMeasurement);
return; return;
} }
BluetoothGattCharacteristic characteristic = gattService.getCharacteristic(serviceMeasurement.measurementUUID()); BluetoothGattCharacteristic characteristic = gattService.getCharacteristic(serviceMeasurement.measurementUUID());
if (characteristic == null) { if (characteristic == null) {
Log.e(TAG, device + ": could not get BluetoothCharacteristic for serviceUUID=" + serviceMeasurement.serviceUUID() + " characteristicUUID=" + serviceMeasurement.measurementUUID()); Log.e(TAG, gatt.getDevice() + ": could not get BluetoothCharacteristic for serviceUUID=" + serviceMeasurement.serviceUUID() + " characteristicUUID=" + serviceMeasurement.measurementUUID());
return; return;
} }
gatt.setCharacteristicNotification(characteristic, true); gatt.setCharacteristicNotification(characteristic, true);
@@ -115,8 +111,6 @@ public class BluetoothDriver<T> implements Driver {
if (!gatt.writeDescriptor(descriptor)) { if (!gatt.writeDescriptor(descriptor)) {
Log.e(TAG, "CLIENT_CHARACTERISTIC_CONFIG_UUID descriptor could not be written"); Log.e(TAG, "CLIENT_CHARACTERISTIC_CONFIG_UUID descriptor could not be written");
} }
observer.onConnected(device.getAddress(), device.getName());
} }
@Override @Override
@@ -133,15 +127,11 @@ public class BluetoothDriver<T> implements Driver {
return; return;
} }
T value = sensorHandler.parsePayload(serviceMeasurementUUID.get(), gatt.getDevice().getName(), characteristic); sensorHandler.handlePayload(observer, serviceMeasurementUUID.get(), gatt.getDevice().getName(), gatt.getDevice().getAddress(), characteristic);
if (value == null) {
return;
}
observer.onDataReceived(value);
} }
}; };
public BluetoothDriver(BluetoothAdapter bluetoothAdapter, DriverObserver<T> observer, BluetoothParser<T> sensorHandler) { BluetoothConnectionManager(BluetoothAdapter bluetoothAdapter, SensorManager.SensorDataChangedObserver observer, SensorHandlerInterface sensorHandler) {
this.bluetoothAdapter = bluetoothAdapter; this.bluetoothAdapter = bluetoothAdapter;
this.observer = observer; this.observer = observer;
this.sensorHandler = sensorHandler; this.sensorHandler = sensorHandler;
@@ -154,11 +144,26 @@ public class BluetoothDriver<T> implements Driver {
return; return;
} }
if (SensorType.NONE.getPreferenceValue().equals(address)) {
Log.w(TAG, "NONE: going to disconnect");
if (isConnected()) { if (isConnected()) {
Log.e(TAG, "Already connected; ignoring."); disconnect();
observer.onRemove(sensorHandler.createEmptySensorData(null, null));
}
return; return;
} }
if (isConnected()) {
Log.w(TAG, "Already connected; ignoring.");
return;
}
if (isSameBluetoothDevice(address)) {
return;
} else {
disconnect();
}
BluetoothDevice device; BluetoothDevice device;
try { try {
device = bluetoothAdapter.getRemoteDevice(address); device = bluetoothAdapter.getRemoteDevice(address);
@@ -170,23 +175,37 @@ public class BluetoothDriver<T> implements Driver {
Log.d(TAG, device + ": trying to connect"); Log.d(TAG, device + ": trying to connect");
bluetoothGatt = device.connectGatt(context, false, connectCallback, BluetoothDevice.TRANSPORT_AUTO, 0, handler); bluetoothGatt = device.connectGatt(context, false, connectCallback, BluetoothDevice.TRANSPORT_AUTO, 0, handler);
observer.onConnect(sensorHandler.createEmptySensorData(device.getAddress(), device.getName()));
}
private synchronized void clearData() {
observer.onDisconnect(sensorHandler.createEmptySensorData(bluetoothGatt.getDevice().getAddress(), bluetoothGatt.getDevice().getName()));
} }
@Override @Override
public synchronized void disconnect() { public synchronized void disconnect() {
if (!isConnected()) { if (!isConnected()) {
Log.w(TAG, "Not connected; cannot disconnect."); Log.w(TAG, "Not connected; no need to re-connect.");
return; return;
} }
Log.i(TAG, bluetoothGatt.getDevice() + ": start disconnect"); Log.i(TAG, bluetoothGatt.getDevice() + ": start disconnect");
bluetoothGatt.disconnect(); bluetoothGatt.disconnect();
bluetoothGatt.close(); bluetoothGatt.close();
clearData();
Log.i(TAG, bluetoothGatt.getDevice() + ": disconnect finished"); Log.i(TAG, bluetoothGatt.getDevice() + ": disconnect finished");
bluetoothGatt = null; bluetoothGatt = null;
observer.onDisconnected();
} }
private synchronized boolean isSameBluetoothDevice(String address) {
if (bluetoothGatt == null) {
return false;
}
return address.equals(bluetoothGatt.getDevice().getAddress());
}
private boolean isBluetoothEnabled() { private boolean isBluetoothEnabled() {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled(); return bluetoothAdapter != null && bluetoothAdapter.isEnabled();
} }
@@ -195,11 +214,4 @@ public class BluetoothDriver<T> implements Driver {
public boolean isConnected() { public boolean isConnected() {
return bluetoothGatt != null; return bluetoothGatt != null;
} }
public interface BluetoothParser<T> {
List<ServiceMeasurementUUID> getServices();
T parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic);
}
} }
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
@@ -6,9 +6,12 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.data.models.AtmosphericPressure; import de.dennisguse.opentracks.data.models.AtmosphericPressure;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.Aggregator;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class BarometerBluetooth implements BluetoothDriver.BluetoothParser<AtmosphericPressure> { public class BluetoothHandlerBarometricPressure implements SensorHandlerInterface {
static final UUID ENVIRONMENTAL_SENSING_SERVICE = new UUID(0x181A00001000L, 0x800000805f9b34fbL); static final UUID ENVIRONMENTAL_SENSING_SERVICE = new UUID(0x181A00001000L, 0x800000805f9b34fbL);
public static final ServiceMeasurementUUID BAROMETRIC_PRESSURE = new ServiceMeasurementUUID( public static final ServiceMeasurementUUID BAROMETRIC_PRESSURE = new ServiceMeasurementUUID(
ENVIRONMENTAL_SENSING_SERVICE, ENVIRONMENTAL_SENSING_SERVICE,
@@ -21,8 +24,16 @@ public class BarometerBluetooth implements BluetoothDriver.BluetoothParser<Atmos
} }
@Override @Override
public AtmosphericPressure parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { public Aggregator<?, ?> createEmptySensorData(String address, String name) {
return parseEnvironmentalSensing(characteristic); return new AggregatorBarometer(address, name);
}
@Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
AtmosphericPressure value = parseEnvironmentalSensing(characteristic);
if (value == null) return;
observer.onChange(new Raw<>(observer.getNow(), value));
} }
/** /**
@@ -0,0 +1,57 @@
package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic;
import android.util.Log;
import android.util.Pair;
import java.util.List;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class BluetoothHandlerCyclingCadence implements SensorHandlerInterface {
private static final String TAG = BluetoothHandlerCyclingCadence.class.getSimpleName();
public static final List<ServiceMeasurementUUID> CYCLING_CADENCE = List.of(
BluetoothHandlerCyclingPower.CYCLING_POWER,
BluetoothHandlerCyclingDistanceSpeed.CYCLING_SPEED_CADENCE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return CYCLING_CADENCE;
}
@Override
public AggregatorCyclingCadence createEmptySensorData(String address, String name) {
return new AggregatorCyclingCadence(address, name);
}
@Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
if (serviceMeasurementUUID.equals(BluetoothHandlerCyclingPower.CYCLING_POWER)) {
BluetoothHandlerCyclingPower.Data data = BluetoothHandlerCyclingPower.parseCyclingPower(characteristic);
if (data != null && data.crank() != null) {
observer.onChange(new Raw<>(observer.getNow(), data.crank()));
}
return;
}
if (serviceMeasurementUUID.equals(BluetoothHandlerCyclingDistanceSpeed.CYCLING_SPEED_CADENCE)) {
Pair<BluetoothHandlerCyclingDistanceSpeed.WheelData, CrankData> data = BluetoothHandlerCyclingDistanceSpeed.parseCyclingCrankAndWheel(address, sensorName, characteristic);
if (data != null && data.second != null) {
observer.onChange(new Raw<>(observer.getNow(), data.second));
}
return;
}
Log.e(TAG, "Don't know how to decode this payload.");
}
public record CrankData(
long crankRevolutionsCount, // UINT32
int crankRevolutionsTime // UINT16; 1/1024s
) {}
}
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
import android.util.Pair; import android.util.Pair;
@@ -9,9 +9,11 @@ import androidx.annotation.VisibleForTesting;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingDistanceSpeed;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class CyclingDistanceSpeedBluetooth implements BluetoothDriver.BluetoothParser<CyclingDistanceSpeedBluetooth.WheelData> { public class BluetoothHandlerCyclingDistanceSpeed implements SensorHandlerInterface {
public static final ServiceMeasurementUUID CYCLING_SPEED_CADENCE = new ServiceMeasurementUUID( public static final ServiceMeasurementUUID CYCLING_SPEED_CADENCE = new ServiceMeasurementUUID(
new UUID(0x181600001000L, 0x800000805f9b34fbL), new UUID(0x181600001000L, 0x800000805f9b34fbL),
@@ -24,18 +26,21 @@ public class CyclingDistanceSpeedBluetooth implements BluetoothDriver.BluetoothP
} }
@Override @Override
public WheelData parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { public AggregatorCyclingDistanceSpeed createEmptySensorData(String address, String name) {
Pair<WheelData, CyclingCadenceBluetooth.CrankData> data = parseCyclingCrankAndWheel(characteristic); return new AggregatorCyclingDistanceSpeed(address, name);
if (data != null && data.first != null) {
return data.first;
} }
return null; @Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
Pair<WheelData, BluetoothHandlerCyclingCadence.CrankData> data = parseCyclingCrankAndWheel(address, sensorName, characteristic);
if (data.first != null) {
observer.onChange(new Raw<>(observer.getNow(), data.first));
}
} }
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static Pair<WheelData, CyclingCadenceBluetooth.CrankData> parseCyclingCrankAndWheel(@NonNull BluetoothGattCharacteristic characteristic) { public static Pair<WheelData, BluetoothHandlerCyclingCadence.CrankData> parseCyclingCrankAndWheel(String address, String sensorName, @NonNull BluetoothGattCharacteristic characteristic) {
// DOCUMENTATION https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.csc_measurement.xml // DOCUMENTATION https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.csc_measurement.xml
int valueLength = characteristic.getValue().length; int valueLength = characteristic.getValue().length;
if (valueLength == 0) { if (valueLength == 0) {
@@ -56,13 +61,13 @@ public class CyclingDistanceSpeedBluetooth implements BluetoothDriver.BluetoothP
index += 2; index += 2;
} }
CyclingCadenceBluetooth.CrankData crankData = null; BluetoothHandlerCyclingCadence.CrankData crankData = null;
if (hasCrank && valueLength - index >= 4) { if (hasCrank && valueLength - index >= 4) {
long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index);
index += 2; index += 2;
int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s
crankData = new CyclingCadenceBluetooth.CrankData(crankCount, crankTime); crankData = new BluetoothHandlerCyclingCadence.CrankData(crankCount, crankTime);
} }
return new Pair<>(wheelData, crankData); return new Pair<>(wheelData, crankData);
@@ -73,6 +78,5 @@ public class CyclingDistanceSpeedBluetooth implements BluetoothDriver.BluetoothP
long wheelRevolutionsCount, // UINT32 long wheelRevolutionsCount, // UINT32
int wheelRevolutionsTime // UINT16; 1/1024s int wheelRevolutionsTime // UINT16; 1/1024s
) { ) {}
}
} }
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
@@ -9,38 +9,35 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.data.models.Power; import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingPower;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser<CyclingPowerBluetooth.Data> { public class BluetoothHandlerCyclingPower implements SensorHandlerInterface {
public static final ServiceMeasurementUUID CYCLING_POWER = new ServiceMeasurementUUID( public static final ServiceMeasurementUUID CYCLING_POWER = new ServiceMeasurementUUID(
new UUID(0x181800001000L, 0x800000805f9b34fbL), new UUID(0x181800001000L, 0x800000805f9b34fbL),
new UUID(0x2A6300001000L, 0x800000805f9b34fbL) new UUID(0x2A6300001000L, 0x800000805f9b34fbL)
); );
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
CYCLING_POWER,
BoschEbikeParser.BOSCH_EBIKE
);
@Override @Override
public List<ServiceMeasurementUUID> getServices() { public List<ServiceMeasurementUUID> getServices() {
return SUPPORTED_SERVICES; return List.of(CYCLING_POWER);
} }
@Override @Override
public CyclingPowerBluetooth.Data parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { public AggregatorCyclingPower createEmptySensorData(String address, String name) {
if (serviceMeasurementUUID.equals(CYCLING_POWER)) { return new AggregatorCyclingPower(address, name);
return parseCyclingPower(characteristic);
} }
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue()); @Override
if (boschData != null && boschData.humanPower() != null) { public void handlePayload(SensorManager.SensorDataChangedObserver observer, @NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
return new Data(boschData.humanPower(), null); Data cyclingPower = parseCyclingPower(characteristic);
if (cyclingPower != null) {
observer.onChange(new Raw<>(observer.getNow(), cyclingPower));
} }
} }
return null;
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
@@ -72,22 +69,19 @@ public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser<Cy
index += 2 + 2; index += 2 + 2;
} }
CyclingCadenceBluetooth.CrankData crankData = null; BluetoothHandlerCyclingCadence.CrankData crankData = null;
if (hasCrank && valueLength - index >= 4) { if (hasCrank && valueLength - index >= 4) {
long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index);
index += 2; index += 2;
int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s
crankData = new CyclingCadenceBluetooth.CrankData(crankCount, crankTime); crankData = new BluetoothHandlerCyclingCadence.CrankData(crankCount, crankTime);
} }
return new Data(Power.of(instantaneousPower), crankData); return new Data(Power.of(instantaneousPower), crankData);
} }
public record Data( public record Data(Power power, BluetoothHandlerCyclingCadence.CrankData crank) {}
Power power,
CyclingCadenceBluetooth.CrankData crank
) {
}
} }
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
@@ -9,9 +9,11 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class HeartRateBluetooth implements BluetoothDriver.BluetoothParser<HeartRate> { public class BluetoothHandlerHeartRate implements SensorHandlerInterface {
public static final ServiceMeasurementUUID HEARTRATE = new ServiceMeasurementUUID( public static final ServiceMeasurementUUID HEARTRATE = new ServiceMeasurementUUID(
new UUID(0x180D00001000L, 0x800000805f9b34fbL), new UUID(0x180D00001000L, 0x800000805f9b34fbL),
@@ -34,8 +36,17 @@ public class HeartRateBluetooth implements BluetoothDriver.BluetoothParser<Heart
} }
@Override @Override
public HeartRate parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { public AggregatorHeartRate createEmptySensorData(String address, String name) {
return parseHeartRate(characteristic); return new AggregatorHeartRate(address, name);
}
@Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, @NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
HeartRate heartRate = parseHeartRate(characteristic);
if (heartRate != null) {
observer.onChange(new Raw<>(observer.getNow(), heartRate));
}
} }
@VisibleForTesting @VisibleForTesting
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
@@ -11,9 +11,11 @@ import java.util.UUID;
import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; import de.dennisguse.opentracks.sensors.sensorData.AggregatorRunning;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class RunningSpeedAndCadenceBluetooth implements BluetoothDriver.BluetoothParser<RunningSpeedAndCadenceBluetooth.Data> { public class BluetoothHandlerRunningSpeedAndCadence implements SensorHandlerInterface {
public static final ServiceMeasurementUUID RUNNING_SPEED_CADENCE = new ServiceMeasurementUUID( public static final ServiceMeasurementUUID RUNNING_SPEED_CADENCE = new ServiceMeasurementUUID(
@@ -27,8 +29,14 @@ public class RunningSpeedAndCadenceBluetooth implements BluetoothDriver.Bluetoot
} }
@Override @Override
public Data parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { public AggregatorRunning createEmptySensorData(String address, String name) {
return parseRunningSpeedAndCadence(sensorName, characteristic); return new AggregatorRunning(address, name);
}
@Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, @NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
Data data = parseRunningSpeedAndCadence(sensorName, characteristic);
observer.onChange(new Raw<>(observer.getNow(), data));
} }
@VisibleForTesting @VisibleForTesting
@@ -0,0 +1,53 @@
package de.dennisguse.opentracks.sensors;
import static de.dennisguse.opentracks.sensors.BluetoothHandlerBarometricPressure.ENVIRONMENTAL_SENSING_SERVICE;
import android.bluetooth.BluetoothGattCharacteristic;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.data.models.Temperature;
import de.dennisguse.opentracks.sensors.sensorData.Aggregator;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorTemperature;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.sensors.sensorData.SensorHandlerInterface;
public class BluetoothHandlerTemperature implements SensorHandlerInterface {
public static final ServiceMeasurementUUID TEMPERATURE = new ServiceMeasurementUUID(
ENVIRONMENTAL_SENSING_SERVICE,
new UUID(0x2A6E00001000L, 0x800000805f9b34fbL)
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return List.of(TEMPERATURE);
}
@Override
public Aggregator<?, ?> createEmptySensorData(String address, String name) {
return new AggregatorTemperature(address, name);
}
@Override
public void handlePayload(SensorManager.SensorDataChangedObserver observer, ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, String address, BluetoothGattCharacteristic characteristic) {
Temperature value = parseEnvironmentalSensing(characteristic);
if (value == null) return;
observer.onChange(new Raw<>(observer.getNow(), value));
}
/**
* Decoding: org.bluetooth.characteristic.temperature.xml
*/
public static Temperature parseEnvironmentalSensing(BluetoothGattCharacteristic characteristic) {
byte[] raw = characteristic.getValue();
if (raw.length < 2) {
return null;
}
Integer pressure = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT16, 0);
return Temperature.ofCentiCelsius(pressure);
}
}
@@ -0,0 +1,126 @@
/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package de.dennisguse.opentracks.sensors;
import android.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/**
* Bluetooth LE sensor manager: manages connections to Bluetooth LE sensors.
* <p>
* Note: should only be instantiated once.
* TODO: listen for Bluetooth enabled/disabled events.
* @author Sandor Dornbush
*/
public class BluetoothRemoteSensorManager implements SensorConnector, SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = BluetoothRemoteSensorManager.class.getSimpleName();
private final Context context;
private final Handler handler;
private boolean started = false;
private final BluetoothConnectionManager heartRate;
private final BluetoothConnectionManager temperature;
private final BluetoothConnectionManager cyclingCadence;
private final BluetoothConnectionManager cyclingSpeed;
private final BluetoothConnectionManager cyclingPower;
private final BluetoothConnectionManager runningSpeedAndCadence;
public BluetoothRemoteSensorManager(@NonNull Context context, @NonNull Handler handler, @Nullable SensorManager.SensorDataChangedObserver observer) {
this.context = context;
this.handler = handler;
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(context);
this.heartRate = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerHeartRate());
this.temperature = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerTemperature());
this.cyclingCadence = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerCyclingCadence());
this.cyclingSpeed = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerCyclingDistanceSpeed());
this.cyclingPower = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerCyclingPower());
this.runningSpeedAndCadence = new BluetoothConnectionManager(bluetoothAdapter, observer, new BluetoothHandlerRunningSpeedAndCadence());
}
@Override
public void start(Context context, Handler handler) {
started = true;
// Triggers connection startup
onSharedPreferenceChanged(null, null);
}
@Override
public synchronized void stop(Context context) {
heartRate.disconnect();
temperature.disconnect();
cyclingCadence.disconnect();
cyclingSpeed.disconnect();
cyclingPower.disconnect();
runningSpeedAndCadence.disconnect();
started = false;
}
private synchronized void connect(BluetoothConnectionManager connectionManager, String address) {
connectionManager.connect(context, handler, address);
}
@Override
public void onSharedPreferenceChanged(SharedPreferences unused, @Nullable String key) {
if (!started) return;
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_heart_rate_key, key)) {
String address = PreferencesUtils.getBluetoothHeartRateSensorAddress();
connect(heartRate, address);
}
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_temperature_key, key)) {
String address = PreferencesUtils.getBluetoothTemperatureSensorAddress();
connect(temperature, address);
}
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_cycling_cadence_key, key)) {
String address = PreferencesUtils.getBluetoothCyclingCadenceSensorAddress();
connect(cyclingCadence, address);
}
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_cycling_speed_key, key)) {
String address = PreferencesUtils.getBluetoothCyclingSpeedSensorAddress();
connect(cyclingSpeed, address);
}
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_cycling_power_key, key)) {
String address = PreferencesUtils.getBluetoothCyclingPowerSensorAddress();
connect(cyclingPower, address);
}
if (PreferencesUtils.isKey(R.string.settings_sensor_bluetooth_running_speed_and_cadence_key, key)) {
String address = PreferencesUtils.getBluetoothRunningSpeedAndCadenceAddress();
connect(runningSpeedAndCadence, address);
}
}
}
@@ -1,38 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
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.CyclingCadenceMeasurement, Cadence> {
protected CyclingCadenceHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new CyclingCadenceBluetooth()
);
}
@NonNull
@Override
protected AggregatorCyclingCadence createAggregator(String sensorAddress, String sensorName) {
return new AggregatorCyclingCadence(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_cycling_cadence_key;
}
}
@@ -1,46 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.CyclingDistanceSpeedBluetooth;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingDistanceSpeed;
import de.dennisguse.opentracks.settings.PreferencesUtils;
public class CyclingDistanceSpeedHandler extends SensorHandler<CyclingDistanceSpeedBluetooth.WheelData, AggregatorCyclingDistanceSpeed.Data> {
protected CyclingDistanceSpeedHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new CyclingDistanceSpeedBluetooth()
);
}
@NonNull
@Override
protected AggregatorCyclingDistanceSpeed createAggregator(String sensorAddress, String sensorName) {
return new AggregatorCyclingDistanceSpeed(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_cycling_speed_key;
}
@Override
public void onDataReceived(CyclingDistanceSpeedBluetooth.WheelData value) {
//TODO Fetch once and then listen for changes.
((AggregatorCyclingDistanceSpeed) aggregator).setWheelCircumference(PreferencesUtils.getWheelCircumference());
super.onDataReceived(value);
}
}
@@ -1,38 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingPower;
public class CyclingPowerHandler extends SensorHandler<CyclingPowerBluetooth.Data, Power> {
protected CyclingPowerHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new CyclingPowerBluetooth()
);
}
@NonNull
@Override
protected AggregatorCyclingPower createAggregator(String sensorAddress, String sensorName) {
return new AggregatorCyclingPower(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_cycling_power_key;
}
}
@@ -0,0 +1,77 @@
package de.dennisguse.opentracks.sensors;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.util.Log;
import de.dennisguse.opentracks.sensors.driver.BarometerInternal;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/**
* Estimates the altitude gain and altitude loss using the device's pressure sensor (i.e., barometer).
*/
public class GainManager implements SensorConnector {
private static final String TAG = GainManager.class.getSimpleName();
private final SensorManager.SensorDataChangedObserver listener;
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> connect();
private Context context;
private Handler handler;
private Driver driver;
public GainManager(SensorManager.SensorDataChangedObserver listener) {
this.listener = listener;
}
public void start(Context context, Handler handler) {
this.context = context;
this.handler = handler;
PreferencesUtils.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
}
public void stop(Context context) {
Log.d(TAG, "Stop");
this.context = null;
this.handler = null;
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
onDisconnect();
}
private void connect() {
onDisconnect();
String address = PreferencesUtils.getBarometerSensorAddress();
switch (PreferencesUtils.getSensorType(address)) {
case NONE -> {
driver = null;
listener.onRemove(new AggregatorBarometer(null, null));
return;
}
case INTERNAL -> driver = new BarometerInternal(listener);
case REMOTE -> driver =
new BluetoothConnectionManager(
BluetoothUtils.getAdapter(context),
listener,
new BluetoothHandlerBarometricPressure()
);
default -> throw new RuntimeException("Not implemented");
}
driver.connect(context, handler, address);
}
private void onDisconnect() {
if (driver == null) return;
driver.disconnect();
listener.onDisconnect(new AggregatorBarometer("GainManager", null));
}
}
@@ -1,120 +0,0 @@
package de.dennisguse.opentracks.sensors;
import android.content.SharedPreferences;
import android.location.Location;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.GpsInternal;
import de.dennisguse.opentracks.sensors.driver.SensorType;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorGPS;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public class GpsHandler extends SensorHandler<Location, Position> {
private final String TAG = GpsHandler.class.getSimpleName();
//TODO Refactor to just pass information via SensorManager.
private TrackPointCreator trackPointCreator;
private GpsStatusManager gpsStatusManager;
private Distance thresholdHorizontalAccuracy;
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> {
if (PreferencesUtils.isKey(R.string.recording_distance_interval_key, key)) {
if (gpsStatusManager != null) {
Distance gpsMinDistance = PreferencesUtils.getRecordingDistanceInterval();
gpsStatusManager.onRecordingDistanceChanged(gpsMinDistance);
}
}
if (PreferencesUtils.isKey(R.string.recording_gps_accuracy_key, key)) {
thresholdHorizontalAccuracy = PreferencesUtils.getThresholdHorizontalAccuracy();
}
if (PreferencesUtils.isKey(R.string.min_sampling_interval_key, key)) {
if (gpsStatusManager != null) {
gpsStatusManager.onMinSamplingIntervalChanged(PreferencesUtils.getMinSamplingInterval());
}
//TODO
connect();
}
};
public GpsHandler(SensorManager sensorManager, TrackPointCreator trackPointCreator) {
super(sensorManager);
this.trackPointCreator = trackPointCreator;
gpsStatusManager = new GpsStatusManager(sensorManager.getContext(), trackPointCreator::sendGpsStatus, sensorManager.getHandler());
sharedPreferenceChangeListener.onSharedPreferenceChanged(null, null);
}
@Override
public void onConnected(String sensorAddress, String sensorName) {
super.onConnected(sensorAddress, sensorName);
gpsStatusManager.onGpsEnabled();
gpsStatusManager.start();
}
@Override
public void onDisconnected() {
super.onDisconnected();
gpsStatusManager.stop();
}
@Override
public void onSensorDeactivated() {
super.onSensorDeactivated();
gpsStatusManager.onGpsDisabled();
}
@Override
public void onDataReceived(Location location) {
// Send each update to the status; please note that this TrackPoint is not stored.
Position position = Position.of(location, trackPointCreator.getNow());
gpsStatusManager.onNewTrackPoint(position);
//TODO We could move the following check into the AggregatorGPS
if (!position.hasValidLocation()) {
Log.w(TAG, "Ignore newTrackPoint. Location is invalid.");
return;
}
if (!position.fulfillsAccuracy(thresholdHorizontalAccuracy)) {
Log.d(TAG, "Ignore newTrackPoint. Poor accuracy.");
return;
}
super.onDataReceived(location);
}
@NonNull
@Override
protected Driver createDriverInternal() {
return new GpsInternal(this);
}
@NonNull
@Override
protected AggregatorGPS createAggregator(String sensorAddress, String sensorName) {
return new AggregatorGPS(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_gps_key;
}
@Override
protected String getSensorPreferenceDefaultValue() {
return SensorType.INTERNAL.getPreferenceValue();
}
}
@@ -0,0 +1,181 @@
package de.dennisguse.opentracks.sensors;
import android.content.Context;
import android.content.SharedPreferences;
import android.location.Location;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.location.LocationListenerCompat;
import androidx.core.location.LocationManagerCompat;
import androidx.core.location.LocationRequestCompat;
import java.time.Duration;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorGPS;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.PermissionRequester;
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public class GpsManager implements SensorConnector, LocationListenerCompat, GpsStatusManager.GpsStatusListener, SharedPreferences.OnSharedPreferenceChangeListener {
private final String TAG = GpsManager.class.getSimpleName();
private static final String LOCATION_PROVIDER = LocationManager.GPS_PROVIDER;
private TrackPointCreator trackPointCreator;
private SensorManager.SensorDataChangedObserver observer;
private Context context;
private Handler handler;
private LocationManager locationManager;
private GpsStatusManager gpsStatusManager;
private Duration gpsInterval;
private Distance thresholdHorizontalAccuracy;
public GpsManager(TrackPointCreator trackPointCreator, SensorManager.SensorDataChangedObserver observer) {
this.trackPointCreator = trackPointCreator;
this.observer = observer;
}
public void start(@NonNull Context context, @NonNull Handler handler) {
this.context = context;
this.handler = handler;
onSharedPreferenceChanged(null, null);
gpsStatusManager = new GpsStatusManager(context, this, handler);
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
observer.onConnect(new AggregatorGPS("internal"));
registerLocationListener();
gpsStatusManager.start();
}
private boolean isStarted() {
return locationManager != null;
}
@SuppressWarnings({"MissingPermission"})
public void stop(Context context) {
if (isStarted()) {
LocationManagerCompat.removeUpdates(locationManager, this);
}
locationManager = null;
this.context = null;
handler = null;
gpsStatusManager.stop();
gpsStatusManager = null;
observer.onDisconnect(new AggregatorGPS("internal"));
observer = null;
trackPointCreator = null;
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
boolean registerListener = false;
if (PreferencesUtils.isKey(R.string.min_sampling_interval_key, key)) {
registerListener = true;
gpsInterval = PreferencesUtils.getMinSamplingInterval();
if (gpsStatusManager != null) {
gpsStatusManager.onMinSamplingIntervalChanged(gpsInterval);
}
}
if (PreferencesUtils.isKey(R.string.recording_gps_accuracy_key, key)) {
thresholdHorizontalAccuracy = PreferencesUtils.getThresholdHorizontalAccuracy();
}
if (PreferencesUtils.isKey(R.string.recording_distance_interval_key, key)) {
registerListener = true;
if (gpsStatusManager != null) {
Distance gpsMinDistance = PreferencesUtils.getRecordingDistanceInterval();
gpsStatusManager.onRecordingDistanceChanged(gpsMinDistance);
}
}
if (registerListener && isStarted()) {
registerLocationListener();
}
}
/**
* Checks if location is valid and builds a track point that will be send through TrackPointCreator.
*
* @param location {@link Location} object.
*/
@Override
public void onLocationChanged(@NonNull Location location) {
// Send each update to the status; please note that this TrackPoint is not stored.
Position position = Position.of(location, trackPointCreator.createNow());
gpsStatusManager.onNewTrackPoint(position);
if (!position.hasValidLocation()) {
Log.w(TAG, "Ignore newTrackPoint. Location is invalid.");
return;
}
if (!position.fulfillsAccuracy(thresholdHorizontalAccuracy)) {
Log.d(TAG, "Ignore newTrackPoint. Poor accuracy.");
return;
}
observer.onChange(new Raw<>(observer.getNow(), position));
}
@Override
public void onStatusChanged(@NonNull String provider, int status, Bundle extras) {
}
@Override
public void onProviderEnabled(@NonNull String provider) {
gpsStatusManager.onGpsEnabled();
}
@Override
public void onProviderDisabled(@NonNull String provider) {
gpsStatusManager.onGpsDisabled();
}
private void registerLocationListener() {
if (!LocationManagerCompat.hasProvider(locationManager, LOCATION_PROVIDER)) {
Log.e(TAG, "Device doesn't have GPS.");
return;
}
LocationRequestCompat locationRequest = new LocationRequestCompat.Builder(gpsInterval.toMillis())
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.setMaxUpdateDelayMillis(0)
.build();
if (PermissionRequester.GPS.hasPermission(context)) {
try {
Log.i(TAG, "Register for location updates " + context);
LocationManagerCompat.requestLocationUpdates(locationManager, LOCATION_PROVIDER, locationRequest, handler::post, this);
} catch (SecurityException e) {
Log.e(TAG, "Could not register location listener; permissions not granted.", e);
}
}
}
@Override
public void onGpsStatusChanged(GpsStatusValue currentStatus) {
trackPointCreator.sendGpsStatus(currentStatus);
}
}
@@ -31,7 +31,7 @@ class GpsStatusManager {
private Duration signalLostThreshold; private Duration signalLostThreshold;
private GpsStatusValue gpsStatus = GpsStatusValue.GPS_NONE; private GpsStatusValue gpsStatus = GpsStatusValue.GPS_NONE;
private GpsStatusListener observer; private GpsStatusListener client;
private final Context context; private final Context context;
@Nullable @Nullable
@@ -44,8 +44,8 @@ class GpsStatusManager {
}; };
public GpsStatusManager(Context context, GpsStatusListener observer, Handler handler) { public GpsStatusManager(Context context, GpsStatusListener client, Handler handler) {
this.observer = observer; this.client = client;
this.context = context; this.context = context;
this.handler = handler; this.handler = handler;
@@ -54,7 +54,7 @@ class GpsStatusManager {
} }
public void start() { public void start() {
observer.onGpsStatusChanged(GpsStatusValue.GPS_ENABLED); client.onGpsStatusChanged(GpsStatusValue.GPS_ENABLED);
} }
/** /**
@@ -62,7 +62,9 @@ class GpsStatusManager {
*/ */
public void stop() { public void stop() {
stopTimer(); stopTimer();
observer.onGpsStatusChanged(GpsStatusValue.GPS_NONE); client.onGpsStatusChanged(GpsStatusValue.GPS_NONE);
client = null;
handler = null;
} }
/** /**
@@ -157,8 +159,8 @@ class GpsStatusManager {
private void setGpsStatus(GpsStatusValue current) { private void setGpsStatus(GpsStatusValue current) {
gpsStatus = current; gpsStatus = current;
if (observer != null) { if (client != null) {
observer.onGpsStatusChanged(current); client.onGpsStatusChanged(current);
} }
} }
@@ -1,38 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.HeartRateBluetooth;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate;
public class HeartRateHandler extends SensorHandler<HeartRate, HeartRate> {
protected HeartRateHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new HeartRateBluetooth()
);
}
@NonNull
@Override
protected AggregatorHeartRate createAggregator(String sensorAddress, String sensorName) {
return new AggregatorHeartRate(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_heart_rate_key;
}
}
@@ -1,37 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.RunningSpeedAndCadenceBluetooth;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorRunning;
public class RunningSpeedCadenceDistanceHandler extends SensorHandler<RunningSpeedAndCadenceBluetooth.Data, AggregatorRunning.Data> {
protected RunningSpeedCadenceDistanceHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new RunningSpeedAndCadenceBluetooth()
);
}
@NonNull
@Override
protected AggregatorRunning createAggregator(String sensorAddress, String sensorName) {
return new AggregatorRunning("", null);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_running_speed_and_cadence_key;
}
}
@@ -0,0 +1,11 @@
package de.dennisguse.opentracks.sensors;
import android.content.Context;
import android.os.Handler;
public interface SensorConnector {
void start(Context context, Handler handler);
void stop(Context context);
}
@@ -1,134 +0,0 @@
package de.dennisguse.opentracks.sensors;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.time.Instant;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.DriverObserver;
import de.dennisguse.opentracks.sensors.driver.SensorType;
import de.dennisguse.opentracks.sensors.sensorData.Aggregator;
import de.dennisguse.opentracks.sensors.sensorData.SensorData;
import de.dennisguse.opentracks.settings.PreferencesUtils;
public abstract class SensorHandler<AggregatorInput, AggregatorOutput> implements DriverObserver<AggregatorInput> {
private static final String TAG = SensorHandler.class.getSimpleName();
private final SensorManager sensorManager;
@VisibleForTesting
public Driver driver;
protected Aggregator<AggregatorInput, AggregatorOutput> aggregator;
protected SensorHandler(SensorManager sensorManager) {
this.sensorManager = sensorManager;
}
void connect() {
disconnect();
String address = PreferencesUtils.getString(getSensorPreferenceKey(), getSensorPreferenceDefaultValue());
switch (PreferencesUtils.getSensorType(address)) {
case NONE -> {
onDisconnected();
return;
}
case INTERNAL -> {
driver = createDriverInternal();
}
case REMOTE -> {
driver = createDriverBluetooth(address);
}
default -> throw new UnsupportedOperationException();
}
driver.connect(sensorManager.getContext(), sensorManager.getHandler(), address);
}
void disconnect() {
if (driver == null) return;
driver.disconnect();
driver = null;
}
@NonNull
protected Driver createDriverInternal() {
throw new RuntimeException("Not implemented");
}
@NonNull
protected Driver createDriverBluetooth(String address) {
throw new RuntimeException("Not implemented");
}
@NonNull
protected abstract Aggregator<AggregatorInput, AggregatorOutput> createAggregator(String sensorAddress, String sensorName);
protected abstract int getSensorPreferenceKey();
protected String getSensorPreferenceDefaultValue() {
return SensorType.NONE.getPreferenceValue();
}
protected Context getContext() {
return sensorManager.getContext();
}
void resetAggregated() {
if (aggregator != null) {
aggregator.resetAggregated();
}
}
@Nullable
SensorData<AggregatorOutput> getSensorData(Instant now) {
if (aggregator == null) return null;
if (!aggregator.hasReceivedData()) return null;
return aggregator.getAggregatedValueWithSensorName(now);
}
//TODO REMOVE
@VisibleForTesting
public void setAggregator(Aggregator<AggregatorInput, AggregatorOutput> aggregator) {
this.aggregator = aggregator;
}
public void onConnected(String sensorAddress, String sensorName) {
SensorHandler.this.aggregator = createAggregator(sensorAddress, sensorName);
}
@Override
public void onConnectionLost() {
if (SensorHandler.this.aggregator == null) {
Log.d(TAG, "Connection lost, but where not connected.");
return;
}
SensorHandler.this.aggregator.resetAggregated();
}
@Override
public void onDataReceived(AggregatorInput value) {
if (aggregator == null) {
throw new RuntimeException("Received data while being disconnected.");
}
SensorHandler.this.aggregator.add(sensorManager.getNow(), value);
sensorManager.onChange();
}
@Override
public void onDisconnected() {
SensorHandler.this.aggregator = null;
}
}
@@ -6,290 +6,153 @@ import android.os.Handler;
import android.os.PowerManager; import android.os.PowerManager;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import java.time.Instant; import java.time.Instant;
import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.sensors.sensorData.Aggregator;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.sensors.sensorData.Raw;
import de.dennisguse.opentracks.data.models.Position; import de.dennisguse.opentracks.sensors.sensorData.SensorDataAggregator;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingDistanceSpeed;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingPower;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorHeartRate;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorRunning;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorTemperature;
import de.dennisguse.opentracks.sensors.sensorData.SensorData;
import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet; import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet;
import de.dennisguse.opentracks.services.handlers.TrackPointCreator; import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.SystemUtils; import de.dennisguse.opentracks.util.SystemUtils;
public class SensorManager implements SharedPreferences.OnSharedPreferenceChangeListener { public class SensorManager implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = SensorManager.class.getSimpleName(); private static final String TAG = SensorManager.class.getSimpleName();
private Context context; //TODO Should be final and not be visible for testing
@VisibleForTesting
private Handler handler; public SensorDataAggregator sensorDataAggregator;
private PowerManager.WakeLock wakeLock; private PowerManager.WakeLock wakeLock;
private final TrackPointCreator observer; private final TrackPointCreator observer;
private HeartRateHandler heartRateHandler; private final SensorDataChangedObserver listener = new SensorDataChangedObserver() {
private TemperatureHandler temperatureHandler;
private CyclingCadenceHandler cyclingCadenceHandler;
private CyclingDistanceSpeedHandler cyclingDistanceSpeedHandler;
private CyclingPowerHandler cyclingPowerHandler;
private RunningSpeedCadenceDistanceHandler runningSpeedCadenceDistanceHandler;
private AltitudeChangeHandler altitudeChangeHandler; @Override
public void onConnect(Aggregator<?, ?> aggregator) {
sensorDataAggregator.add(aggregator);
}
private GpsHandler gpsHandler; @Override
public void onChange(Raw<?> data) {
sensorDataAggregator.update(data);
observer.onChange();
}
@Override
public void onDisconnect(Aggregator<?, ?> aggregator) {
sensorDataAggregator.add(aggregator);
}
@Override
public void onRemove(Aggregator<?, ?> aggregator) {
sensorDataAggregator.remove(aggregator);
}
@Override
public Instant getNow() {
return observer.createNow();
}
};
private BluetoothRemoteSensorManager bluetoothSensorManager;
private GainManager altitudeSumManager;
private GpsManager gpsManager;
public SensorManager(TrackPointCreator observer) { public SensorManager(TrackPointCreator observer) {
this.observer = observer; this.observer = observer;
this.sensorDataAggregator = new SensorDataAggregator();
} }
public void start(Context context, Handler handler) { public void start(Context context, Handler handler) {
if (isStarted()) { if (gpsManager != null) {
throw new RuntimeException("SensorManager cannot be started twice; stop first."); throw new RuntimeException("SensorManager cannot be started twice; stop first.");
} }
wakeLock = SystemUtils.acquireWakeLock(context, wakeLock); wakeLock = SystemUtils.acquireWakeLock(context, wakeLock);
this.context = context;
this.handler = handler;
gpsHandler = new GpsHandler(this, observer); gpsManager = new GpsManager(observer, listener);
altitudeChangeHandler = new AltitudeChangeHandler(this); altitudeSumManager = new GainManager(listener);
heartRateHandler = new HeartRateHandler(this); bluetoothSensorManager = new BluetoothRemoteSensorManager(context, handler, listener);
temperatureHandler = new TemperatureHandler(this);
cyclingCadenceHandler = new CyclingCadenceHandler(this);
cyclingDistanceSpeedHandler = new CyclingDistanceSpeedHandler(this);
cyclingPowerHandler = new CyclingPowerHandler(this);
runningSpeedCadenceDistanceHandler = new RunningSpeedCadenceDistanceHandler(this);
onSharedPreferenceChanged(null, null); onSharedPreferenceChanged(null, null);
gpsManager.start(context, handler);
altitudeSumManager.start(context, handler);
bluetoothSensorManager.start(context, handler);
} }
public void stop() { public void stop(Context context) {
wakeLock = SystemUtils.releaseWakeLock(wakeLock); wakeLock = SystemUtils.releaseWakeLock(wakeLock);
heartRateHandler.disconnect(); bluetoothSensorManager.stop(context);
heartRateHandler = null; bluetoothSensorManager = null;
temperatureHandler.disconnect(); altitudeSumManager.stop(context);
temperatureHandler = null; altitudeSumManager = null;
cyclingCadenceHandler.disconnect(); gpsManager.stop(context);
cyclingCadenceHandler = null; gpsManager = null;
cyclingDistanceSpeedHandler.disconnect(); sensorDataAggregator.clear();
cyclingDistanceSpeedHandler = null; }
cyclingPowerHandler.disconnect(); public SensorDataSet getSensorDataSet(Instant now) {
cyclingPowerHandler = null; return sensorDataAggregator.getSensorData(now);
runningSpeedCadenceDistanceHandler.disconnect();
runningSpeedCadenceDistanceHandler = null;
altitudeChangeHandler.disconnect();
altitudeChangeHandler = null;
gpsHandler.disconnect();
gpsHandler = null;
context = null;
handler = null;
} }
public void reset() { public void reset() {
if (gpsHandler == null || altitudeChangeHandler == null) { if (bluetoothSensorManager == null || altitudeSumManager == null) {
Log.d(TAG, "No recording running and no reset necessary."); Log.d(TAG, "No recording running and no reset necessary.");
return; return;
} }
sensorDataAggregator.reset();
Log.i(TAG, "Resetting data");
heartRateHandler.resetAggregated();
temperatureHandler.resetAggregated();
cyclingCadenceHandler.resetAggregated();
cyclingDistanceSpeedHandler.resetAggregated();
cyclingPowerHandler.resetAggregated();
runningSpeedCadenceDistanceHandler.resetAggregated();
altitudeChangeHandler.resetAggregated();
gpsHandler.resetAggregated();
} }
@VisibleForTesting @VisibleForTesting
public GpsHandler getGpsHandler() { public void onChanged(Raw<?> data) {
return gpsHandler; listener.onChange(data);
}
public GpsManager getGpsManager() {
return gpsManager;
} }
@Deprecated @Deprecated
@VisibleForTesting @VisibleForTesting
public AltitudeChangeHandler getAltitudeChangeHandler() { public GainManager getAltitudeSumManager() {
return altitudeChangeHandler; return altitudeSumManager;
}
@Deprecated
@VisibleForTesting
public void setAltitudeSumManager(GainManager altitudeSumManager) {
this.altitudeSumManager = altitudeSumManager;
} }
@Override @Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @Nullable String key) { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @Nullable String key) {
if (!isStarted()) return; if (gpsManager != null) {
gpsManager.onSharedPreferenceChanged(sharedPreferences, key);
if (PreferencesUtils.isKey(gpsHandler.getSensorPreferenceKey(), key)) { bluetoothSensorManager.onSharedPreferenceChanged(sharedPreferences, key);
gpsHandler.connect();
}
if (PreferencesUtils.isKey(altitudeChangeHandler.getSensorPreferenceKey(), key)) {
altitudeChangeHandler.connect();
}
if (PreferencesUtils.isKey(heartRateHandler.getSensorPreferenceKey(), key)) {
heartRateHandler.connect();
}
if (PreferencesUtils.isKey(temperatureHandler.getSensorPreferenceKey(), key)) {
temperatureHandler.connect();
}
if (PreferencesUtils.isKey(cyclingCadenceHandler.getSensorPreferenceKey(), key)) {
cyclingCadenceHandler.connect();
}
if (PreferencesUtils.isKey(cyclingDistanceSpeedHandler.getSensorPreferenceKey(), key)) {
cyclingDistanceSpeedHandler.connect();
}
if (PreferencesUtils.isKey(cyclingPowerHandler.getSensorPreferenceKey(), key)) {
cyclingPowerHandler.connect();
}
if (PreferencesUtils.isKey(runningSpeedCadenceDistanceHandler.getSensorPreferenceKey(), key)) {
runningSpeedCadenceDistanceHandler.connect();
} }
} }
public Instant getNow() { public interface SensorDataChangedObserver {
return observer.getNow();
}
public Context getContext() { void onConnect(Aggregator<?, ?> sensorData);
return context; void onChange(Raw<?> sensorData);
}
public Handler getHandler() { void onDisconnect(Aggregator<?, ?> sensorData);
return handler;
}
private SensorData<Cadence> getCadence(Instant now) { void onRemove(Aggregator<?, ?> sensorData);
{
SensorData<Cadence> value = cyclingCadenceHandler.getSensorData(now);
if (value != null) {
return cyclingCadenceHandler.getSensorData(now);
}
}
SensorData<AggregatorRunning.Data> value = runningSpeedCadenceDistanceHandler.getSensorData(now); Instant getNow();
if (value != null && value.data().cadence() != null) {
return new SensorData<>(value.data().cadence(), value.sensorNameOrAddress());
}
return null;
}
private SensorData<Distance> getDistance(Instant now) {
{
SensorData<AggregatorCyclingDistanceSpeed.Data> value = cyclingDistanceSpeedHandler.getSensorData(now);
if (value != null) {
return new SensorData<>(value.data().distanceOverall(), value.sensorNameOrAddress());
}
}
SensorData<AggregatorRunning.Data> value = runningSpeedCadenceDistanceHandler.getSensorData(now);
if (value != null) {
return new SensorData<>(value.data().distance(), value.sensorNameOrAddress());
}
return null;
}
//TOOD simplify?
private SensorData<Speed> getSpeed(Instant now) {
{
SensorData<AggregatorCyclingDistanceSpeed.Data> value = cyclingDistanceSpeedHandler.getSensorData(now);
if (value != null && value.data() != null && value.data().speed() != null) {
return new SensorData<>(value.data().speed(), value.sensorNameOrAddress());
}
}
SensorData<AggregatorRunning.Data> value = runningSpeedCadenceDistanceHandler.getSensorData(now);
if (value != null && value.data() != null && value.data().speed() != null) {
return new SensorData<>(value.data().speed(), value.sensorNameOrAddress());
}
return null;
}
public void onChange() {
observer.onChange();
}
public SensorDataSet getSensorDataSet(Instant now) {
//We always need a Position with now
SensorData<Position> gpsSensorData = gpsHandler.getSensorData(now);
if (gpsSensorData != null && gpsSensorData.data() != null) {
gpsSensorData = new SensorData<>(gpsSensorData.data().with(now), gpsSensorData.sensorNameOrAddress());
} else {
gpsSensorData = new SensorData<>(Position.of(now), "");
}
return new SensorDataSet(
gpsSensorData,
getSpeed(now),
getDistance(now),
heartRateHandler.getSensorData(now),
temperatureHandler.getSensorData(now),
getCadence(now),
cyclingPowerHandler.getSensorData(now),
altitudeChangeHandler.getSensorData(now)
);
}
private boolean isStarted() {
return wakeLock != null;
}
@VisibleForTesting
public void setAggregator(AggregatorHeartRate data) {
heartRateHandler.setAggregator(data);
}
@VisibleForTesting
public void setAggregator(@NonNull AggregatorTemperature data) {
temperatureHandler.setAggregator(data);
}
@VisibleForTesting
public void setAggregator(@NonNull AggregatorCyclingCadence data) {
cyclingCadenceHandler.setAggregator(data);
}
@VisibleForTesting
public void setAggregator(@NonNull AggregatorCyclingDistanceSpeed data) {
cyclingDistanceSpeedHandler.setAggregator(data);
}
@VisibleForTesting
public void setAggregator(@NonNull AggregatorCyclingPower data) {
cyclingPowerHandler.setAggregator(data);
}
@VisibleForTesting
public void setAggregator(@NonNull AggregatorRunning data) {
runningSpeedCadenceDistanceHandler.setAggregator(data);
} }
} }
@@ -1,10 +1,8 @@
package de.dennisguse.opentracks.sensors.driver; package de.dennisguse.opentracks.sensors;
public enum SensorType { public enum SensorType {
NONE("NONE"), //TODO Use R.string.sensor_type_value_none NONE("NONE"),
INTERNAL("INTERNAL"), //TODO Use R.string.sensor_type_value_none INTERNAL("INTERNAL"),
//NOTE: preferenceValue of REMOTE should not be used anywhere.
REMOTE("*"); REMOTE("*");
private final String preferenceValue; private final String preferenceValue;
@@ -1,38 +0,0 @@
package de.dennisguse.opentracks.sensors;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Temperature;
import de.dennisguse.opentracks.sensors.driver.BluetoothDriver;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.driver.TemperatureBluetooth;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorTemperature;
public class TemperatureHandler extends SensorHandler<Temperature, Temperature> {
protected TemperatureHandler(SensorManager sensorManager) {
super(sensorManager);
}
@NonNull
@Override
protected Driver createDriverBluetooth(String address) {
return new BluetoothDriver<>(
BluetoothUtils.getAdapter(getContext()),
this,
new TemperatureBluetooth()
);
}
@NonNull
@Override
protected AggregatorTemperature createAggregator(String sensorAddress, String sensorName) {
return new AggregatorTemperature(sensorAddress, sensorName);
}
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_bluetooth_temperature_key;
}
}
@@ -9,23 +9,22 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import de.dennisguse.opentracks.data.models.AtmosphericPressure; import de.dennisguse.opentracks.data.models.AtmosphericPressure;
import de.dennisguse.opentracks.sensors.SensorManager;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorBarometer;
import de.dennisguse.opentracks.sensors.sensorData.Raw;
public class BarometerInternal implements Driver { public class BarometerInternal implements Driver {
private static final String TAG = BarometerInternal.class.getSimpleName(); private static final String TAG = BarometerInternal.class.getSimpleName();
private static final Duration SAMPLING_PERIOD = Duration.ofSeconds(5); private static final int SAMPLING_PERIOD = (int) TimeUnit.SECONDS.toMicros(5);
private final DriverObserver<AtmosphericPressure> observer; private final SensorManager.SensorDataChangedObserver observer;
private android.hardware.SensorManager sensorService; private Context context;
private Instant lastEventTime = Instant.EPOCH;
private final SensorEventListener sensorEventListener = new SensorEventListener() { private final SensorEventListener sensorEventListener = new SensorEventListener() {
@Override @Override
@@ -35,14 +34,7 @@ public class BarometerInternal implements Driver {
return; return;
} }
Instant currentTime = Instant.now(); observer.onChange(new Raw<>(observer.getNow(), AtmosphericPressure.ofHPA(event.values[0])));
if (lastEventTime.plus(SAMPLING_PERIOD).isAfter(currentTime)) {
return;
}
lastEventTime = currentTime;
observer.onDataReceived(AtmosphericPressure.ofHPA(event.values[0]));
} }
@Override @Override
@@ -51,35 +43,40 @@ public class BarometerInternal implements Driver {
} }
}; };
public BarometerInternal(@NonNull DriverObserver<AtmosphericPressure> observer) { public BarometerInternal(@NonNull SensorManager.SensorDataChangedObserver observer) {
this.observer = observer; this.observer = observer;
} }
@Override @Override
public void connect(Context context, Handler handler, String addressIgnored) { 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, samplingPeriodUs, handler)) { android.hardware.SensorManager sensorManager = (android.hardware.SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
observer.onConnected(null, null); Sensor pressureSensor = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE);
if (pressureSensor == null) {
Log.w(TAG, "No pressure sensor available.");
return; return;
} }
Log.w(TAG, "No pressure sensor available."); if (sensorManager.registerListener(sensorEventListener, pressureSensor, SAMPLING_PERIOD, handler)) {
observer.onDisconnected(); this.context = context;
observer.onConnect(new AggregatorBarometer("internal", null));
return;
}
disconnect();
} }
@Override
public boolean isConnected() { public boolean isConnected() {
return sensorService != null; return context != null;
} }
@Override @Override
public void disconnect() { public void disconnect() {
if (!isConnected()) return; if (!isConnected()) return;
sensorService.unregisterListener(sensorEventListener); android.hardware.SensorManager sensorManager = (android.hardware.SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
sensorService = null; sensorManager.unregisterListener(sensorEventListener);
this.context = null;
} }
} }
@@ -1,217 +0,0 @@
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.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(decodedFrame -> decodedFrame != null)
.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) {
}
}
@@ -1,67 +0,0 @@
package de.dennisguse.opentracks.sensors.driver;
import android.bluetooth.BluetoothGattCharacteristic;
import android.util.Log;
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.CyclingCadenceMeasurement> {
private static final String TAG = CyclingCadenceBluetooth.class.getSimpleName();
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
CyclingPowerBluetooth.CYCLING_POWER,
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE,
BoschEbikeParser.BOSCH_EBIKE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return SUPPORTED_SERVICES;
}
@Override
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) {
return data.crank();
}
}
if (serviceMeasurementUUID.equals(CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE)) {
Pair<CyclingDistanceSpeedBluetooth.WheelData, CrankData> data = CyclingDistanceSpeedBluetooth.parseCyclingCrankAndWheel(characteristic);
if (data != null && data.second != null) {
return data.second;
}
}
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 {
}
}
@@ -4,11 +4,8 @@ import android.content.Context;
import android.os.Handler; import android.os.Handler;
public interface Driver { public interface Driver {
//TODO Address is optional for internal drivers. Move address to driver instantiation.
void connect(Context context, Handler handler, String address); void connect(Context context, Handler handler, String address);
//TODO Remove from interface?
boolean isConnected(); boolean isConnected();
void disconnect(); void disconnect();
@@ -1,31 +0,0 @@
package de.dennisguse.opentracks.sensors.driver;
public interface DriverObserver<T> {
/**
* The Driver could connect to the sensor.
*/
void onConnected(String sensorAddress, String sensorName);
/**
* Sensor got temporarily disconnected, Driver should try to re-connect.
*/
void onConnectionLost();
/**
* The Driver provided data from the sensor.
*/
void onDataReceived(T value);
/**
* The Driver was shutdown and no further data will be provided.
*/
//TODO check if really need this, because the driver was told to disconnect and then just signalizes that it finished this task.
void onDisconnected();
/**
* The Driver detected that the sensor got deactivated.
* For example, the user deactivated the internal GPS.
*/
default void onSensorDeactivated() {}
}
@@ -1,95 +0,0 @@
package de.dennisguse.opentracks.sensors.driver;
import android.content.Context;
import android.location.Location;
import android.location.LocationManager;
import android.os.Handler;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.location.LocationListenerCompat;
import androidx.core.location.LocationManagerCompat;
import androidx.core.location.LocationRequestCompat;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.PermissionRequester;
public class GpsInternal implements Driver {
private static final String TAG = GpsInternal.class.getSimpleName();
public static final String LOCATION_PROVIDER = LocationManager.GPS_PROVIDER;
@VisibleForTesting
public final LocationListenerCompat locationListenerCompat = new LocationListenerCompat() {
@Override
public void onLocationChanged(@NonNull Location location) {
driverObserver.onDataReceived(location);
}
@Override
public void onProviderEnabled(@NonNull String provider) {
driverObserver.onConnected(null, provider);
}
@Override
public void onProviderDisabled(@NonNull String provider) {
driverObserver.onSensorDeactivated();
}
};
private final DriverObserver<Location> driverObserver;
@VisibleForTesting
public LocationManager locationManager;
public GpsInternal(DriverObserver<Location> driverObserver) {
this.driverObserver = driverObserver;
}
@Override
public void connect(Context context, Handler handler, String address) {
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
if (!LocationManagerCompat.hasProvider(locationManager, LOCATION_PROVIDER)) {
Log.e(TAG, "Device doesn't have GPS.");
return;
}
LocationRequestCompat locationRequest = new LocationRequestCompat.Builder(PreferencesUtils.getMinSamplingInterval().toMillis())
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.setMaxUpdateDelayMillis(0)
.build();
if (PermissionRequester.GPS.hasPermission(context)) {
try {
Log.i(TAG, "Register for location updates " + context);
driverObserver.onConnected(null, null);
LocationManagerCompat.requestLocationUpdates(locationManager, LOCATION_PROVIDER, locationRequest, handler::post, locationListenerCompat);
return;
} catch (SecurityException e) {
Log.e(TAG, "Could not register location listener; permissions not granted.", e);
}
}
driverObserver.onDisconnected();
}
@SuppressWarnings({"MissingPermission"})
@Override
public void disconnect() {
if (!isConnected()) return;
LocationManagerCompat.removeUpdates(locationManager, locationListenerCompat);
locationManager = null;
driverObserver.onDisconnected();
}
@Override
public boolean isConnected() {
return locationManager != null;
}
}
@@ -1,42 +0,0 @@
package de.dennisguse.opentracks.sensors.driver;
import static de.dennisguse.opentracks.sensors.driver.BarometerBluetooth.ENVIRONMENTAL_SENSING_SERVICE;
import android.bluetooth.BluetoothGattCharacteristic;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.data.models.Temperature;
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
public class TemperatureBluetooth implements BluetoothDriver.BluetoothParser<Temperature> {
public static final ServiceMeasurementUUID TEMPERATURE = new ServiceMeasurementUUID(
ENVIRONMENTAL_SENSING_SERVICE,
new UUID(0x2A6E00001000L, 0x800000805f9b34fbL)
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return List.of(TEMPERATURE);
}
@Override
public Temperature parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
return parseEnvironmentalSensing(characteristic);
}
/**
* Decoding: org.bluetooth.characteristic.temperature.xml
*/
public static Temperature parseEnvironmentalSensing(BluetoothGattCharacteristic characteristic) {
byte[] raw = characteristic.getValue();
if (raw.length < 2) {
return null;
}
Integer pressure = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT16, 0);
return Temperature.ofCentiCelsius(pressure);
}
}
@@ -1,74 +1,79 @@
package de.dennisguse.opentracks.sensors.sensorData; package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
public abstract class Aggregator<InputType, OutputType> { public abstract class Aggregator<Input, Output> {
private static final Duration MAX_SENSOR_DATE_SET_AGE = Duration.ofSeconds(5); private static final Duration MAX_SENSOR_DATE_SET_AGE = Duration.ofSeconds(5);
@Nullable protected Raw<Input> previous;
protected Raw<InputType> previous;
//TODO Make private protected Output aggregatedValue;
protected OutputType output;
private final String sensorAddress; private final String sensorAddress;
private final String sensorName; private final String sensorName;
Aggregator(String sensorAddress) {
this(sensorAddress, null);
}
Aggregator(String sensorAddress, String sensorName) { Aggregator(String sensorAddress, String sensorName) {
this.sensorAddress = sensorAddress; this.sensorAddress = sensorAddress;
this.sensorName = sensorName; this.sensorName = sensorName;
} }
private String getSensorNameOrAddress() { public String getSensorNameOrAddress() {
return sensorName != null ? sensorName : sensorAddress; return sensorName != null ? sensorName : sensorAddress;
} }
public final void add(Instant now, InputType current) { public final void add(Raw<Input> current) {
Raw<InputType> next = new Raw<>(now, current); computeValue(current);
computeValue(next); previous = current;
previous = next;
} }
protected abstract void computeValue(@NonNull Raw<InputType> current); protected abstract void computeValue(Raw<Input> current);
/** /**
* @return did we process data from a sensor. * @return did we process data from a sensor.
* NOTE: for some sensors this may require more than one measurement. * NOTE: for some sensors this may require more than one measurement.
*/ */
public boolean hasReceivedData() { public boolean hasReceivedData() {
return output != null; return aggregatedValue != null;
} }
@Nullable @Deprecated //TODO Remove: for sensor there is no default value. Make getAggregatedValue() @Nullable
protected OutputType getAggregatedValue(Instant now) { @NonNull
protected abstract Output getNoneValue();
@NonNull
public Output getAggregatedValue(Instant now) {
if (!hasReceivedData()) {
return getNoneValue();
}
if (isOutdated(now)) { if (isOutdated(now)) {
resetOutdated(); resetImmediate();
} }
return output; return aggregatedValue;
} }
@NonNull @NonNull
public SensorData<OutputType> getAggregatedValueWithSensorName(Instant now) { public SensorData<Output> getAggregatedValueWithSensorName(Instant now) {
return new SensorData<>(getAggregatedValue(now), getSensorNameOrAddress()); return new SensorData<>(getAggregatedValue(now), getSensorNameOrAddress());
} }
/** /**
* Reset short-term (i.e., non-aggregated) values that were directly derived from sensor data. * Reset short-term (i.e., non-aggregated) values that were directly derived from sensor data.
*/ */
protected void resetOutdated() { protected abstract void resetImmediate();
output = null;
};
/** /**
* Reset long-term (i.e., aggregated) values (more than derived from previous SensorData) like overall distance. * Reset long-term (i.e., aggregated) values (more than derived from previous SensorData) like overall distance.
*/ */
public void resetAggregated() {}; public abstract void resetAggregated();
/** /**
* Is the data recent considering the current time. * Is the data recent considering the current time.
@@ -92,12 +97,6 @@ public abstract class Aggregator<InputType, OutputType> {
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {
return "sensorAddress=" + sensorAddress + " data=" + output; return "sensorAddress=" + sensorAddress + " data=" + aggregatedValue;
}
protected record Raw<T>(
@NonNull Instant time,
@NonNull T value
) {
} }
} }
@@ -2,6 +2,7 @@ package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss; import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.AtmosphericPressure; import de.dennisguse.opentracks.data.models.AtmosphericPressure;
import de.dennisguse.opentracks.sensors.PressureSensorUtils; import de.dennisguse.opentracks.sensors.PressureSensorUtils;
@@ -15,30 +16,35 @@ public class AggregatorBarometer extends Aggregator<AtmosphericPressure, Altitud
} }
@Override @Override
protected void computeValue(@NonNull Raw<AtmosphericPressure> current) { protected void computeValue(Raw<AtmosphericPressure> current) {
if (previous == null) { if (previous == null) {
lastAcceptedSensorValue = current.value(); lastAcceptedSensorValue = current.value();
output = new AltitudeGainLoss(0f, 0f); aggregatedValue = getNoneValue();
return; return;
} }
PressureSensorUtils.AltitudeChange altitudeChange = PressureSensorUtils.computeChangesWithSmoothing_m(lastAcceptedSensorValue, previous.value(), current.value()); PressureSensorUtils.AltitudeChange altitudeChange = PressureSensorUtils.computeChangesWithSmoothing_m(lastAcceptedSensorValue, previous.value(), current.value());
if (altitudeChange != null) { if (altitudeChange != null) {
if (output == null) { aggregatedValue = new AltitudeGainLoss(aggregatedValue.gain_m() + altitudeChange.getAltitudeGain_m(), aggregatedValue.loss_m() + altitudeChange.getAltitudeLoss_m());
output = new AltitudeGainLoss(0f, 0f);
}
output = new AltitudeGainLoss(output.gain_m() + altitudeChange.getAltitudeGain_m(), output.loss_m() + altitudeChange.getAltitudeLoss_m());
lastAcceptedSensorValue = altitudeChange.currentSensorValue(); lastAcceptedSensorValue = altitudeChange.currentSensorValue();
} }
} }
@Override @Override
protected void resetOutdated() { protected void resetImmediate() {
} }
@Override @Override
public void resetAggregated() { public void resetAggregated() {
output = null; aggregatedValue = getNoneValue();
} }
@NonNull
@Override
protected AltitudeGainLoss getNoneValue() {
return new AltitudeGainLoss(0f, 0f);
}
public record Data(Altitude gain, Altitude loss) {}
} }
@@ -7,10 +7,10 @@ import androidx.annotation.NonNull;
import java.time.Duration; import java.time.Duration;
import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingCadence;
import de.dennisguse.opentracks.sensors.UintUtils; import de.dennisguse.opentracks.sensors.UintUtils;
import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> { public class AggregatorCyclingCadence extends Aggregator<BluetoothHandlerCyclingCadence.CrankData, Cadence> {
private final String TAG = AggregatorCyclingCadence.class.getSimpleName(); private final String TAG = AggregatorCyclingCadence.class.getSimpleName();
@@ -19,20 +19,12 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
} }
@Override @Override
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CyclingCadenceMeasurement> current) { protected void computeValue(Raw<BluetoothHandlerCyclingCadence.CrankData> current) {
if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) { if (previous == null) {
output = directCadenceData.cadence();
return; return;
} }
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value()); float timeDiff_ms = UintUtils.diff(current.value().crankRevolutionsTime(), previous.value().crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
}
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); Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
if (timeDiff.isZero()) { if (timeDiff.isZero()) {
@@ -40,26 +32,32 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
} }
if (timeDiff.isNegative()) { if (timeDiff.isNegative()) {
Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence."); Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence.");
output = null; aggregatedValue = null;
return; return;
} }
// TODO We have to treat with overflow according to the documentation: read https://codeberg.org/OpenTracksApp/OpenTracks/pulls/953#issuecomment-6466930 // TODO We have to treat with overflow according to the documentation: read https://codeberg.org/OpenTracksApp/OpenTracks/pulls/953#issuecomment-6466930
if (currentRaw.crankRevolutionsCount() < previousRaw.crankRevolutionsCount()) { if (current.value().crankRevolutionsCount() < previous.value().crankRevolutionsCount()) {
Log.e(TAG, "Crank revolutions count difference is invalid: cannot compute cadence."); Log.e(TAG, "Crank revolutions count difference is invalid: cannot compute cadence.");
output = null;
return; return;
} }
long crankDiff = UintUtils.diff(currentRaw.crankRevolutionsCount(), previousRaw.crankRevolutionsCount(), UintUtils.UINT32_MAX); long crankDiff = UintUtils.diff(current.value().crankRevolutionsCount(), previous.value().crankRevolutionsCount(), UintUtils.UINT32_MAX);
output = Cadence.of(crankDiff, timeDiff); aggregatedValue = Cadence.of(crankDiff, timeDiff);
} }
private CyclingCadenceBluetooth.CrankData getPreviousRaw() { @Override
if (previous == null) { protected void resetImmediate() {
return null; aggregatedValue = getNoneValue();
} }
return previous.value() instanceof CyclingCadenceBluetooth.CrankData raw ? raw : null; @Override
public void resetAggregated() {
}
@NonNull
@Override
protected Cadence getNoneValue() {
return Cadence.of(0);
} }
} }
@@ -8,10 +8,10 @@ import java.time.Duration;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingDistanceSpeed;
import de.dennisguse.opentracks.sensors.UintUtils; import de.dennisguse.opentracks.sensors.UintUtils;
import de.dennisguse.opentracks.sensors.driver.CyclingDistanceSpeedBluetooth;
public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSpeedBluetooth.WheelData, AggregatorCyclingDistanceSpeed.Data> { public class AggregatorCyclingDistanceSpeed extends Aggregator<BluetoothHandlerCyclingDistanceSpeed.WheelData, AggregatorCyclingDistanceSpeed.Data> {
private final String TAG = AggregatorCyclingDistanceSpeed.class.getSimpleName(); private final String TAG = AggregatorCyclingDistanceSpeed.class.getSimpleName();
@@ -22,7 +22,7 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
} }
@Override @Override
protected void computeValue(@NonNull Raw<CyclingDistanceSpeedBluetooth.WheelData> current) { protected void computeValue(Raw<BluetoothHandlerCyclingDistanceSpeed.WheelData> current) {
if (previous == null) { if (previous == null) {
return; return;
} }
@@ -35,39 +35,43 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
} }
if (timeDiff.isNegative()) { if (timeDiff.isNegative()) {
Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence."); Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence.");
output = null; aggregatedValue = null;
return; return;
} }
if (current.value().wheelRevolutionsCount() < previous.value().wheelRevolutionsCount()) { if (current.value().wheelRevolutionsCount() < previous.value().wheelRevolutionsCount()) {
Log.e(TAG, "Wheel revolutions count difference is invalid: cannot compute speed."); Log.e(TAG, "Wheel revolutions count difference is invalid: cannot compute speed.");
output = null;
return; return;
} }
long wheelDiff = UintUtils.diff(current.value().wheelRevolutionsCount(), previous.value().wheelRevolutionsCount(), UintUtils.UINT32_MAX); long wheelDiff = UintUtils.diff(current.value().wheelRevolutionsCount(), previous.value().wheelRevolutionsCount(), UintUtils.UINT32_MAX);
Distance distance = wheelCircumference.multipliedBy(wheelDiff); Distance distance = wheelCircumference.multipliedBy(wheelDiff);
Distance distanceOverall = distance; Distance distanceOverall = distance;
if (output != null) { if (aggregatedValue != null) {
distanceOverall = distance.plus(output.distanceOverall); distanceOverall = distance.plus(aggregatedValue.distanceOverall);
} }
Speed speed_mps = Speed.of(distance, timeDiff); Speed speed_mps = Speed.of(distance, timeDiff);
output = new Data(distance, distanceOverall, speed_mps); aggregatedValue = new Data(distance, distanceOverall, speed_mps);
} }
@Override @Override
protected void resetOutdated() { protected void resetImmediate() {
Distance overallDistance = output != null ? output.distanceOverall : Distance.ZERO; aggregatedValue = new Data(Distance.ZERO, aggregatedValue.distanceOverall, Speed.ZERO);
output = new Data(Distance.ZERO, overallDistance, Speed.ZERO);
} }
@Override @Override
public void resetAggregated() { public void resetAggregated() {
if (output != null) { if (aggregatedValue != null) {
output = new Data(output.distance, Distance.ZERO, output.speed); aggregatedValue = new Data(aggregatedValue.distance, Distance.ZERO, aggregatedValue.speed);
} }
} }
@NonNull
@Override
protected Data getNoneValue() {
return new Data(Distance.ZERO, Distance.ZERO, Speed.ZERO);
}
public void setWheelCircumference(Distance wheelCircumference) { public void setWheelCircumference(Distance wheelCircumference) {
this.wheelCircumference = wheelCircumference; this.wheelCircumference = wheelCircumference;
} }
@@ -3,16 +3,31 @@ package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.Power; import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth; import de.dennisguse.opentracks.sensors.BluetoothHandlerCyclingPower;
public class AggregatorCyclingPower extends Aggregator<CyclingPowerBluetooth.Data, Power> { public class AggregatorCyclingPower extends Aggregator<BluetoothHandlerCyclingPower.Data, Power> {
public AggregatorCyclingPower(String name, String address) { public AggregatorCyclingPower(String name, String address) {
super(name, address); super(name, address);
} }
@Override @Override
public void computeValue(@NonNull Raw<CyclingPowerBluetooth.Data> current) { public void computeValue(Raw<BluetoothHandlerCyclingPower.Data> current) {
this.output = current.value().power(); this.aggregatedValue = current.value().power();
}
@Override
protected void resetImmediate() {
aggregatedValue = getNoneValue();
}
@Override
public void resetAggregated() {
}
@NonNull
@Override
protected Power getNoneValue() {
return Power.of(0f);
} }
} }
@@ -1,20 +1,26 @@
package de.dennisguse.opentracks.sensors.sensorData; package de.dennisguse.opentracks.sensors.sensorData;
import android.location.Location;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Position; import de.dennisguse.opentracks.data.models.Position;
public class AggregatorGPS extends Aggregator<Location, Position> { public class AggregatorGPS extends Aggregator<Position, Position> {
public AggregatorGPS(String sensorAddress, String sensorName) {
super(sensorAddress, sensorName); public AggregatorGPS(String sensorAddress) {
super(sensorAddress);
} }
@Override @Override
protected void computeValue(@NonNull Raw<Location> current) { protected void computeValue(Raw<Position> current) {
output = Position.of(current.value(), current.time()); aggregatedValue = current.value();
}
@Override
protected void resetImmediate() {
aggregatedValue = Position.empty();
} }
@Override @Override
@@ -22,7 +28,21 @@ public class AggregatorGPS extends Aggregator<Location, Position> {
/* /*
* GPS data is not an aggregated value, but for now we want to ensure to only save the data once. * GPS data is not an aggregated value, but for now we want to ensure to only save the data once.
* The data is too large to save it more often than needed (i.e., duplicated values). * The data is too large to save it more often than needed (i.e., duplicated values).
* TODO: this behavior can be changed if TrackRecordingManager.insertTrackPoint() would strip GPS data if it was already saved. This would simplify TrackPointCreator.createCurrentTrackPoint()
*/ */
resetOutdated(); aggregatedValue = Position.empty();
}
@NonNull
@Override
protected Position getNoneValue() {
return Position.empty();
}
@NonNull
@Override
public Position getAggregatedValue(Instant now) {
return super.getAggregatedValue(now)
.with(now);
} }
} }

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