Compare commits

..

1 Commits

Author SHA1 Message Date
Dennis Guse ae4d2da262 Heartrate: ignore "0" if sent from sensor as this is an invalid value.
On import "0" is ignored.
2026-02-10 18:42:14 +01:00
146 changed files with 757 additions and 3167 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -27,7 +27,7 @@ jobs:
image: 'ubuntu:android-24.04'
steps:
- name: Checkout sources
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
-13
View File
@@ -112,16 +112,3 @@ Here is a list of the smartphones tested:
| Google | Nexus 4 | LineageOS 18.1 | Ok |
| Samsung | S4 | Android 11 | Ok |
| Sony | Xperia XZ1 Compact | LineageOS 16.1 | Failure |
## Bosch Smart System eBike
OpenTracks also supports Bosch Smart System eBikes via Bosch's custom BLE service `00000010-eaa2-11e9-81b4-2a2ae2dbcce4`.
Tested with:
* Bosch Perfomance Line CX (SW-Version 16.9.0) / Kiox 300 (SW-Version 16.4.0)
Currently supported measurements:
* Human power
* Cadence
+15 -21
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
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 = { ->
return 'git describe --tags'.execute(null, project.rootDir).text.trim()
return 'git describe --tags'.execute().text.trim()
}
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 {
@@ -33,7 +33,7 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
compileSdk = 37
compileSdk = 36
buildFeatures {
viewBinding = true
@@ -47,13 +47,13 @@ android {
defaultConfig {
applicationId 'de.dennisguse.opentracks'
versionCode 6698
versionName "v4.28.0"
versionCode 6564
versionName "v4.25.2"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
minSdk = 26
targetSdk = 37
targetSdk = 36
testInstrumentationRunner 'de.dennisguse.opentracks.TestRunner'
testInstrumentationRunnerArguments clearPackageData: 'true'
@@ -107,6 +107,7 @@ android {
nightly {
dimension 'version'
applicationId 'de.dennisguse.opentracks.nightly'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.nightly'
versionCode getVersionCodeNightly()
versionName getVersionName()
@@ -118,6 +119,7 @@ android {
// Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/
dimension 'version'
applicationId 'de.dennisguse.opentracks'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks'
versionNameSuffix 'irreproducible'
}
reproducible {
@@ -126,6 +128,7 @@ android {
// PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore
dimension 'version'
applicationId 'de.dennisguse.opentracks.playstore'
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.playstore'
base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
@@ -149,29 +152,20 @@ dependencies {
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.gridlayout:gridlayout:1.1.0'
implementation 'com.google.android.material:material:1.14.0'
implementation 'com.google.android.material:material:1.13.0'
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.mediarouter:mediarouter:1.8.1'
implementation 'androidx.core:core-location-altitude:1.0.0'
implementation 'androidx.work:work-runtime:2.11.2'
implementation 'androidx.health.connect:connect-client:1.1.0'
implementation 'androidx.core:core-location-altitude:1.0.0-beta01'
implementation 'androidx.work:work-runtime:2.11.1'
androidTestImplementation 'androidx.test:core:1.7.0'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:rules:1.7.0'
androidTestImplementation 'androidx.test:runner:1.7.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'org.mockito:mockito-android:5.21.0'
androidTestUtil 'androidx.test:orchestrator:1.6.1'
}
androidComponents {
onVariants(selector().all()) { variant ->
variant.resValues.put(
new com.android.build.api.variant.impl.ResValueKeyImpl("string", "applicationId"),
new com.android.build.api.variant.ResValue(variant.applicationId.get(), null)
)
}
}
@@ -1,10 +0,0 @@
(versionName): OpenTracks
Ændringer:
-
Fejlrettelser:
-
Udvikler:
-
@@ -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,5 +0,0 @@
v4.27.2: OpenTracks
Bugfix:
- Not all valid timezone offsets could be set
- Fixed race condition for IDLE trackpoints
@@ -1,10 +0,0 @@
v4.28.0: OpenTracks
Changes:
- Support for exporting to Health Connect
Bugfix:
- Dashboard API: when sharing multiple tracks, the currently recorded one wasn't updated
- Configuration setting for announcement of lap power wasn't stored correctly (only some languages affected)
@@ -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,22 +1,11 @@
Um companheiro de rastreamento esportivo que respeita sua privacidade.
OpenTracks é um aplicativo de rastreamento de esportes que respeita totalmente a sua privacidade.
Esportes e atividades ao ar livre garantem felicidade, habilidades e confiança.
Valoriza sua saúde acompanhando os seus treinos.
Grava enquanto você corre ou caminha e te entrega um computador de bicicleta com uma tela maior para o ciclismo.
Marca locais interessantes durante o percurso com fotos.
Deixa estatísticas registradas em grande detalhe para analises.
Compartilha apenas os dados que você quer que outros tenham.
* Anúncios por voz.
* Suporte para sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medição de potência (ciclismo).
* Ganhos e perdas de altitude: via sensor barométrico.
* Altitudes exibidas em EGM2008 (acima do nível médio do mar); exportado como WGS84.
* Exporta os dados de rastreio como KMZ (inclusive fotos), KML ou GPX.
* Sem acesso à internet ou permissões extra.
* Tema claro e escuro, respeitando as configurações do sistema.
* Sem propagandas.
Software Livre / Código Aberto
Significa que você pode usar, estudar, mudar e compartilhar o código.
Licença Apache 2.0
Características:
* Rastreamento de esportes: monitore seu esporte e atividades ao ar livre
* Fotos e marcadores: marque locais interessantes durante o rastreamento
* Ganho e perda de altitude: via sensor barométrico
* Altitude mostrada no EGM2008 (acima do nível médio do mar); exportado como WGS84
* Anúncios de voz
* Suporta sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medidor de potência (ciclismo)
* Exportar dados: exporte trilhas como KMZ (incluindo fotos), KML, ou GPX
* Sem acesso à Internet: a Internet não é usada
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
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.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.renameStoppedTrack;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoDeleteTrackTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@Test
public void deleteTrackFromTrackStoppedActivity() {
recordTrackAndOpenTrackStoppedActivity();
public void espressoDeleteTrackTest() {
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
// TrackStoppedActivity: discard the just-recorded track directly
onView(withId(R.id.discard_button))
.perform(performClick());
onView(withText(android.R.string.ok))
.inRoot(isDialog())
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
// the recycler is empty again after discarding the only recorded track
onView(withId(R.id.track_list))
.check(matches(withRecyclerViewItemCount(0)));
}
@Test
public void deleteTrackFromSelectedTrackInTrackList() {
String uniqueTrackName = "DeleteFromTrackList-" + System.currentTimeMillis();
recordTrackAndOpenTrackStoppedActivity();
renameStoppedTrack(uniqueTrackName, UI_TIMEOUT);
// TrackRecordingActivity
onView(withId(R.id.track_recording_fab_action))
// wait; stay recording
.perform(waitFor(5000))
// stop;
.perform(longClick());
// TrackStoppedActivity
onView(withId(R.id.finish_button))
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1, UI_TIMEOUT);
.perform(click());
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
.perform(performLongClickOnAncestor());
// select track
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0)))
.perform(longClick());
clickDeleteSelectedTrackAction();
onView(withText(android.R.string.ok))
.inRoot(isDialog())
.perform(performClick());
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
// open menu and delete selected track
onView(
allOf(withContentDescription("More options"),
childAtPosition(
childAtPosition(
withId(androidx.appcompat.R.id.action_mode_bar),
1),
2),
isDisplayed()))
.perform(click());
onView(withId(R.id.track_list))
.check(matches(withRecyclerViewItemCount(0)));
onView(withText("Delete"))
.perform(click());
onView(withText("OK"))
.perform(click());
// tracklist is empty now
onView(allOf(withText("Start recording your next adventure here"), isDisplayed()));
}
private void recordTrackAndOpenTrackStoppedActivity() {
startRecordingFromTrackList(UI_TIMEOUT);
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.discard_button, R.id.finish_button);
}
private void clickDeleteSelectedTrackAction() {
try {
waitUntilDisplayed(withId(R.id.list_context_menu_delete), UI_TIMEOUT);
onView(withId(R.id.list_context_menu_delete))
.perform(performClick());
} catch (AssertionError ignored) {
onView(allOf(
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
isDescendantOfA(withId(androidx.appcompat.R.id.action_mode_bar))))
.perform(performClick());
onView(withText(R.string.menu_delete))
.inRoot(isPlatformPopup())
.perform(performClickOnAncestor());
}
}
}
@@ -2,43 +2,34 @@ package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.Locale;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoEditTrackRecordingTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
@Rule
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
@@ -48,27 +39,27 @@ public class EspressoEditTrackRecordingTest {
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
@LargeTest
@Test
public void espressoEditTrackRecordingTest() {
startRecordingFromTrackList(UI_TIMEOUT);
{
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{
// TrackRecordingActivity
// wait; stay recording
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(4000));
// open menu
onView(allOf(
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
isDescendantOfA(withId(R.id.bottom_app_bar))))
.perform(performClick());
openContextualActionModeOverflowMenu();
// Click the item.
onView(withText(R.string.menu_edit))
.inRoot(isPlatformPopup())
.perform(performClickOnAncestor());
waitUntilDisplayed(withId(R.id.track_edit_name), UI_TIMEOUT);
.perform(click());
// change name for "New Name"
onView(withId(R.id.track_edit_name))
@@ -79,11 +70,11 @@ public class EspressoEditTrackRecordingTest {
// save edition
onView(withId(R.id.track_edit_save))
.perform(performClick());
waitUntilDisplayed(withId(R.id.track_recording_fab_action), UI_TIMEOUT);
.perform(click());
// 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
onView(withId(R.id.resume_button))
@@ -1,142 +1,100 @@
package de.dennisguse.opentracks;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor;
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 androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
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.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.data.models.TrackPoint;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class EspressoUITest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
private final ActivityScenarioRule<TrackListActivity> activityRule = new ActivityScenarioRule<>(TrackListActivity.class);
private final GrantPermissionRule grantPermissionRule = TestUtil.createGrantPermissionRule();
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule
public RuleChain rules = RuleChain.outerRule(grantPermissionRule)
.around(activityRule);
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
}
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
@LargeTest
@Test
public void record_stop_resume_stop_finish() {
startRecordingFromTrackList(UI_TIMEOUT);
{
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button);
resumeStoppedTrack(UI_TIMEOUT);
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button);
finishStoppedTrack(UI_TIMEOUT);
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{
// TrackRecordingActivity: wait to record some time and then stop
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(5000))
.perform(longClick());
onView(withId(R.id.track_list))
.check(matches(isDisplayed()));
// TrackStoppedActivity: resume
onView(allOf(withId(R.id.resume_button), isClickable()))
.perform(click());
// TrackRecordingActivity: wait and then stop
onView(withId(R.id.track_recording_fab_action))
.perform(waitFor(5000))
.perform(longClick());
// TrackStoppedActivity
onView(withId(R.id.finish_button))
.perform(click());
}
}
@LargeTest
@Test
public void record_move_through_tabs() {
startRecordingFromTrackList(UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout), UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager), UI_TIMEOUT);
{
// TrackListActivity: start recording
onView(withId(R.id.track_list_fab_action))
.perform(click());
}
{
// TrackRecordingActivity
ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout));
tabLayout.perform(selectTabAtIndex(1));
waitUntilTabSelectionSettlesAt(1);
tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(2));
waitUntilTabSelectionSettlesAt(2);
tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(3));
waitUntilTabSelectionSettlesAt(3);
tabLayout.perform(waitFor(1000));
tabLayout.perform(selectTabAtIndex(0));
waitUntilTabSelectionSettlesAt(0);
tabLayout.perform(waitFor(1000));
// stop
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button);
onView(withId(R.id.resume_button))
.check(matches(isDisplayed()));
onView(withId(R.id.finish_button))
.check(matches(isDisplayed()));
onView(withId(R.id.track_recording_fab_action)).
perform(longClick());
}
}
@LargeTest
@Test
public void selectAndDeleteTrack() {
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 5);
String uniqueTrackName = "SelectAndDeleteTrack-" + System.currentTimeMillis();
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
trackBuilder.setName(uniqueTrackName);
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
activityRule.getScenario().recreate();
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
onView(withId(R.id.track_list)).check(matches(isDisplayed()));
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
.perform(performLongClickOnAncestor());
onView(withId(androidx.appcompat.R.id.action_mode_bar))
.check(matches(isDisplayed()));
}
private void waitUntilTabSelectionSettlesAt(int index) {
waitForResumedViewCondition(
withId(R.id.track_detail_activity_tablayout),
view -> withSelectedTab(index).matches(view),
"selected tab to be " + index,
UI_TIMEOUT);
waitForResumedViewCondition(
withId(R.id.track_detail_activity_view_pager),
view -> withViewPagerCurrentItem(index).matches(view),
"ViewPager current item to be " + index,
UI_TIMEOUT);
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))).perform(longClick());
}
}
@@ -20,19 +20,22 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.when;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.io.File;
import java.io.IOException;
@@ -75,6 +78,7 @@ import de.dennisguse.opentracks.util.FileUtils;
* @author Bartlomiej Niechwiej
* @author Youtao Liu
*/
@RunWith(MockitoJUnitRunner.class)
public class CustomContentProviderUtilsTest {
private static final String NAME_PREFIX = "test name";
@@ -86,6 +90,12 @@ public class CustomContentProviderUtilsTest {
private final Context context = ApplicationProvider.getApplicationContext();
private ContentProviderUtils contentProviderUtils;
@Mock
private Cursor cursorMock;
@Mock
private ContentResolver contentResolverMock;
@Before
public void setUp() {
contentProviderUtils = new ContentProviderUtils(context);
@@ -94,21 +104,21 @@ public class CustomContentProviderUtilsTest {
@Test
public void testLocationIterator_noPoints() {
assertEquals(0, testIterator(new Track.Id(1), 0).size());
testIterator(new Track.Id(1), 0);
}
@Test
public void testLocationIterator_noAscending() {
assertEquals(50, testIterator(new Track.Id(1), 50).size());
assertEquals(50, testIterator(new Track.Id(2), 50).size());
testIterator(new Track.Id(1), 50);
testIterator(new Track.Id(2), 50);
}
@Test
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);
List<TrackPoint> locations = new ArrayList<>(numPoints);
try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) {
@@ -121,7 +131,6 @@ public class CustomContentProviderUtilsTest {
}
assertEquals(numPoints, locations.size());
}
return locations;
}
private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) {
@@ -178,49 +187,27 @@ public class CustomContentProviderUtilsTest {
@Test
public void testCreateTrack() {
Track.Id trackId = new Track.Id(System.currentTimeMillis());
int columnIndex = 1;
// Id
when(cursorMock.getColumnIndexOrThrow(TracksColumns._ID)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getLong(columnIndex)).thenReturn(trackId.id());
//Uuid
columnIndex++;
when(cursorMock.getColumnIndexOrThrow(TracksColumns.UUID)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getBlob(columnIndex)).thenReturn(UUIDUtils.toBytes(UUID.randomUUID()));
// Name
columnIndex++;
String name = NAME_PREFIX + trackId.id();
when(cursorMock.getColumnIndexOrThrow(TracksColumns.NAME)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getString(columnIndex)).thenReturn(name);
MatrixCursor cursor = new MatrixCursor(new String[]{
TracksColumns._ID,
TracksColumns.UUID,
TracksColumns.NAME,
TracksColumns.DESCRIPTION,
TracksColumns.ACTIVITY_TYPE,
TracksColumns.ACTIVITY_TYPE_LOCALIZED,
TracksColumns.STARTTIME,
TracksColumns.STARTTIME_OFFSET,
TracksColumns.STOPTIME,
TracksColumns.TOTALDISTANCE,
TracksColumns.TOTALTIME,
TracksColumns.MOVINGTIME,
TracksColumns.MAXSPEED,
TracksColumns.MIN_ALTITUDE,
TracksColumns.MAX_ALTITUDE,
TracksColumns.ALTITUDE_GAIN,
TracksColumns.ALTITUDE_LOSS,
});
cursor.addRow(new Object[]{
trackId.id(),
UUIDUtils.toBytes(UUID.randomUUID()),
name,
"",
ActivityType.UNKNOWN.getId(),
"",
1_000L,
ZoneOffset.UTC.getTotalSeconds(),
2_000L,
123.45f,
1_000L,
500L,
12.34f,
null,
null,
null,
null,
});
assertTrue(cursor.moveToFirst());
Track track = ContentProviderUtils.createTrack(cursor);
Track track = ContentProviderUtils.createTrack(cursorMock);
assertEquals(trackId, track.id());
assertEquals(name, track.name());
}
@@ -462,6 +449,8 @@ public class CustomContentProviderUtilsTest {
marker.setDescription(TEST_DESC);
contentProviderUtils.insertMarker(marker.getMarker());
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(contentResolverMock);
Marker.Id markerId = new Marker.Id(System.currentTimeMillis());
marker.setId(markerId);
ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId);
@@ -1195,7 +1184,7 @@ public class CustomContentProviderUtilsTest {
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
Instant start = Instant.now();
TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil();
@@ -1218,27 +1207,22 @@ public class CustomContentProviderUtilsTest {
SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId);
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
public void testGetSensorStats_veryLongActivity12h() {
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(43200 / 6, false);
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
testGetSensorStats_randomData(43200 / 6, false);
}
@Test
public void testGetSensorStats_withSeveralRandomStartSegments() {
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(5000, true);
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
testGetSensorStats_randomData(5000, true);
}
private TrackPoint getLastValidTrackPoint(Track.Id trackId) {
@@ -89,12 +89,12 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
public class ExportImportTest {
@Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(50, TimeUnit.SECONDS); //TODO 5s
@Rule
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
public TimezoneRule timezoneRule = new TimezoneRule(TimeZone.getTimeZone("Europe/Berlin"));
@@ -199,6 +199,7 @@ public class ExportImportTest {
));
trackPointCreator.setClock("2020-02-02T02:02:18Z");
trackPointCreator.getSensorManager().clear();
service.endCurrentTrack();
trackPointCreator.setClock("2020-02-02T02:03:20Z");
@@ -213,6 +214,7 @@ public class ExportImportTest {
sendLocation(trackPointCreator, "2020-02-02T02:03:50Z", 3.1234567, 16.001, 10, 27, 15, 999.123, 0f);
trackPointCreator.getSensorManager().clear();
trackPointCreator.setClock("2020-02-02T02:04:00Z");
service.endCurrentTrack();
@@ -296,22 +298,7 @@ public class ExportImportTest {
Power.of(50),
new AltitudeGainLoss(0, 0)
),
new TrackPoint(
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_END_MANUAL, Instant.parse("2020-02-02T02:02:18Z")),
new TrackPoint(TrackPoint.Type.SEGMENT_START_MANUAL, Instant.parse("2020-02-02T02:03:20Z")),
new TrackPoint(
null,
@@ -369,16 +356,7 @@ public class ExportImportTest {
null,
null,
new AltitudeGainLoss(0, 0)),
new TrackPoint(
null,
TrackPoint.Type.SEGMENT_END_MANUAL,
Position.of(Instant.parse("2020-02-02T02:04:00Z")),
null,
null,
null,
null,
null,
new AltitudeGainLoss(0, 0))
new TrackPoint(TrackPoint.Type.SEGMENT_END_MANUAL, Instant.parse("2020-02-02T02:04:00Z"))
), actual);
assertEquals(new Statistics(
@@ -769,7 +747,7 @@ public class ExportImportTest {
sensorManager.getAltitudeChangeHandler().setAggregator(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.setLatitude(latitude);
location.setLongitude(longitude);
@@ -26,7 +26,6 @@ public class TrackPointAssert {
Assert.assertEquals("has altitude", expected.position().hasAltitude(), actual.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);
}
@@ -14,7 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.ContentProviderUtils;
@RunWith(AndroidJUnit4.class)
public class DataProviderTest {
@@ -24,7 +24,7 @@ public class DataProviderTest {
@Test
public void supportsVersion2_tracks() {
// 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[]{
"_id",
"name",
@@ -64,7 +64,7 @@ public class DataProviderTest {
@Test
public void supportsVersion2_trackpoints() {
// 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 = {
"_id",
"trackid",
@@ -94,7 +94,7 @@ public class DataProviderTest {
@Test
public void supportsVersion2_markers() {
// 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 = {
"_id",
"name",
@@ -126,7 +126,7 @@ public class DataProviderTest {
@Test
public void supportsVersion3_tracks() {
// 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 = {
"_id",
"name",
@@ -164,7 +164,7 @@ public class DataProviderTest {
@Test
public void supportsVersion3_trackpoints() {
//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 = {
"_id",
"trackid",
@@ -194,7 +194,7 @@ public class DataProviderTest {
@Test
public void supportsVersion3_markers() {
// 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 = {
"_id",
"name",
@@ -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,7 +1,6 @@
package de.dennisguse.opentracks.sensors.driver;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import android.bluetooth.BluetoothGattCharacteristic;
@@ -36,27 +35,4 @@ public class CyclingPowerBluetoothTest {
assertEquals(17125, powerCadence.crank().crankRevolutionsTime());
}
@Test
public void parsePayload_boschPower() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
// Bosch human power frame: message 0x985B with value 0x7B => 123 W.
characteristic.setValue(new byte[] { 0x30, 0x04, (byte) 0x98, 0x5B, 0x08, 0x7B });
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
assertEquals(123, power.power().getW(), 0.01);
assertNull(power.crank());
}
@Test
public void parsePayload_boschCadenceOnly_returnsNull() {
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
// Bosch cadence-only frame: message 0x985A with value 0x01B8 => 220 / 2 = 110 rpm, so power parsing should ignore it.
characteristic.setValue(new byte[] { 0x30, 0x05, (byte) 0x98, 0x5A, 0x08, (byte) 0xB8, 0x01 });
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
assertNull(power);
}
}
}
@@ -96,25 +96,6 @@ public class SensorDataCyclingTest {
assertNull(current.getAggregatedValue(Instant.MIN));
}
@Test
public void compute_cadence_directCadence_withoutPrevious() {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
}
@Test
public void compute_cadence_directCadence_winsOverCrankData() {
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1L, 1024));
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
}
@Test
public void compute_speed() {
AggregatorCyclingDistanceSpeed current = new AggregatorCyclingDistanceSpeed("", "");
@@ -1,23 +1,24 @@
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.content.Context;
import androidx.core.app.NotificationCompat;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
@@ -28,24 +29,32 @@ import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.settings.UnitSystem;
@RunWith(MockitoJUnitRunner.class)
public class TrackRecordingServiceNotificationManagerTest {
private static final String CHANNEL_ID = "TrackRecordingServiceNotificationManagerTest";
private final Context context = ApplicationProvider.getApplicationContext();
private final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
@After
public void tearDown() {
notificationManager.cancelAll();
}
@Mock
private NotificationCompat.Builder notificationCompatBuilder;
@Mock
private NotificationManager notificationManager;
@Test
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);
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationBuilder);
when(notificationCompatBuilder.setOnlyAlertOnce(anyBoolean()))
.thenReturn(notificationCompatBuilder);
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationCompatBuilder);
subject.setUnitSystem(UnitSystem.METRIC);
Track track = new Track(
@@ -59,42 +68,16 @@ public class TrackRecordingServiceNotificationManagerTest {
Statistics.DEFAULT
);
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(1000));
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
assertEquals(java.util.Arrays.asList(false, true, true, true, true, true, false, true), notificationBuilder.onlyAlertOnceValues);
RecordingData data = new RecordingData(track, trackPointMock, null, null);
// when
subject.updateTrackPoint(context, data, Distance.of(100));
subject.updateTrackPoint(context, data, Distance.of(100));
subject.updateTrackPoint(context, data, Distance.of(1000));
subject.updateTrackPoint(context, data, Distance.of(100));
// then
verify(notificationCompatBuilder, times(6)).setOnlyAlertOnce(true);
verify(notificationCompatBuilder, times(2)).setOnlyAlertOnce(false);
}
private RecordingData createRecordingData(Track track, Distance horizontalAccuracy) {
TrackPoint trackPoint = new TrackPoint(
TrackPoint.Type.TRACKPOINT,
new Position(
null,
null,
null,
horizontalAccuracy,
Altitude.WGS84.of(10),
null,
null,
Speed.ZERO
)
);
return new RecordingData(track, trackPoint, null, null);
}
private static final class TrackingNotificationBuilder extends NotificationCompat.Builder {
private final List<Boolean> onlyAlertOnceValues = new ArrayList<>();
private TrackingNotificationBuilder(Context context, String channelId) {
super(context, channelId);
setSmallIcon(R.drawable.ic_logo_color_24dp);
}
@Override
public NotificationCompat.Builder setOnlyAlertOnce(boolean onlyAlertOnce) {
onlyAlertOnceValues.add(onlyAlertOnce);
return super.setOnlyAlertOnce(onlyAlertOnce);
}
}
}
}
@@ -2,7 +2,6 @@ package de.dennisguse.opentracks.services;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
@@ -94,41 +93,6 @@ public class TrackRecordingServiceRecordingTest {
service = startService();
}
@MediumTest
@Test
public void reCreate_not_recording() {
// given
TrackIdStorage.unset(context);
assertFalse(service.isRecording());
// when
service.onStartCommand(null, 0, 1);
// then
assertFalse(service.isRecording());
}
@MediumTest
@Test
public void reCreate_recording() {
// given
assertFalse(service.isRecording());
Track.Id trackId = service.startNewTrack();
assertTrue(service.isRecording());
// when (crash and restart)
service.onDestroy();
service.onCreate();
service.onStartCommand(null, 0, 1);
// then
assertTrue(service.isRecording());
assertEquals(trackId, service.getRecordingStatusObservable().getValue().trackId());
service.endCurrentTrack();
}
@MediumTest
@Test
public void recording_startStop() {
@@ -15,7 +15,6 @@
*/
package de.dennisguse.opentracks.services;
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@@ -42,7 +41,6 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -58,8 +56,6 @@ import de.dennisguse.opentracks.sensors.GpsStatusValue;
@RunWith(AndroidJUnit4.class)
public class TrackRecordingServiceStateMachineTest {
private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(2);
@Rule
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
@@ -127,7 +123,6 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.tryStartSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
@@ -136,12 +131,11 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.stopSensors();
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().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
@@ -152,9 +146,6 @@ public class TrackRecordingServiceStateMachineTest {
// when
Track.Id trackId = service.startNewTrack();
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilRecordingDataIsNotDefault();
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -163,13 +154,9 @@ public class TrackRecordingServiceStateMachineTest {
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// when
service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -180,8 +167,6 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.endCurrentTrack();
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
// then
assertFalse(service.isRecording());
@@ -192,8 +177,6 @@ public class TrackRecordingServiceStateMachineTest {
// when
service.resumeTrack(trackId);
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
// then
assertTrue(service.isRecording());
@@ -271,25 +254,4 @@ public class TrackRecordingServiceStateMachineTest {
assertNotNull(trackId);
assertNull(newTrackId);
}
private void waitUntilRecordingDataIsNotDefault() {
waitForValue(service.getRecordingDataObservable(),
recordingData -> !RecordingData.NOT_RECORDING.equals(recordingData),
WAIT_TIMEOUT,
"recording data to leave NOT_RECORDING");
}
private void waitUntilGpsStatusIs(GpsStatusValue expected) {
waitForValue(service.getGpsStatusObservable(),
expected::equals,
WAIT_TIMEOUT,
"gps status to become " + expected);
}
private void waitUntilRecordingStatusIs(RecordingStatus expected) {
waitForValue(service.getRecordingStatusObservable(),
expected::equals,
WAIT_TIMEOUT,
"recording status to become " + expected);
}
}
@@ -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
public void setUp() {
PreferencesUtils.setVoiceAnnounceUnit(true);
PreferencesUtils.setVoiceAnnounceHeartRateCurrent(false);
PreferencesUtils.setVoiceAnnounceLapHeartRate(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);
}
@Test
public void getAnnouncement_metric_distance_without_unit() {
PreferencesUtils.setVoiceAnnounceUnit(false);
Track track = new Track(
null,
null,
"",
"",
"",
ActivityType.AIRPLANE,
ZoneOffset.UTC,
new Statistics(
Instant.EPOCH,
Instant.EPOCH,
Duration.ofHours(2).plusMinutes(5).plusSeconds(10),
Duration.ofHours(1),
Distance.of(19900),
Speed.of(100),
null,
new AltitudeGainLoss(6000, 6000),
null,
null
));
SensorDataSet dataSet = new SensorDataSet(
null,
null,
null,
new SensorData<>(HeartRate.of(60), "unused"),
null, null,
null,
null
);
// when
String announcement = VoiceAnnouncementUtils.createStatistics(context, track, dataSet, UnitSystem.METRIC, true, null, null).toString();
// then
assertEquals("12:00 AM. Total distance 19.9. 1 hour. Average moving speed 19.9.", announcement);
}
@Test
public void getAnnouncement_withInterval_metric_speed() {
// given
@@ -599,7 +556,6 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapHeartRate(true);
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -617,7 +573,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then
assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate " + 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
@@ -633,7 +589,6 @@ public class VoiceAnnouncementUtilsTest {
PreferencesUtils.setVoiceAnnounceLapSpeedPace(false);
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
@@ -651,7 +606,7 @@ public class VoiceAnnouncementUtilsTest {
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
// then
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement);
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
}
@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;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.stopTrackRecordingService;
import static org.hamcrest.Matchers.allOf;
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
import android.util.Pair;
import android.view.View;
import androidx.test.core.app.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.filters.LargeTest;
import androidx.test.rule.GrantPermissionRule;
import org.hamcrest.core.IsInstanceOf;
import org.hamcrest.Matcher;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.time.Duration;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TestUtil;
import de.dennisguse.opentracks.TrackListActivity;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
@@ -46,69 +41,53 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
@RunWith(AndroidJUnit4.class)
public class EspressoAggregatedFilterTest {
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
@Rule
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
@Rule
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
private ActivityScenario<AggregatedStatisticsActivity> scenario;
@Before
public void setUp() {
resetTrackRecordingServiceAndDeleteTracks();
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(androidx.test.core.app.ApplicationProvider.getApplicationContext());
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20);
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED);
TestDataUtil.insertTrackWithLocations(contentProviderUtils, trackBuilder.getTrack(), pair.second);
scenario = ActivityScenario.launch(AggregatedStatisticsActivity.class);
}
@After
public void tearDown() {
stopTrackRecordingService();
if (scenario != null) {
scenario.close();
}
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
}
@Test
public void espressoAggregatedFilterTest() {
waitUntilDisplayed(withId(R.id.bottom_app_bar), UI_TIMEOUT);
waitUntilDisplayed(withId(R.id.aggregated_statistics_filter), UI_TIMEOUT);
waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1, UI_TIMEOUT);
waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED)), UI_TIMEOUT);
// open AggregatedStatisticsActivity through toolbar's menu item
onView(ViewMatchers.withId(R.id.bottom_app_bar))
.check(matches(isDisplayed()));
onView(withId(R.id.bottom_app_bar))
.check(matches(hasDescendant(withId(R.id.aggregated_stats_button))));
onView(withId(R.id.aggregated_stats_button))
.perform(click());
// open FilterDialogFragment through the current bottom app bar action item
// open FilterDialogFragment through toolbar's menu item
onView(withId(R.id.bottom_app_bar))
.check(matches(isDisplayed()));
onView(withId(R.id.bottom_app_bar))
.check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter))));
onView(withId(R.id.aggregated_statistics_filter))
.perform(performClick());
.perform(click());
// 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(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))),
isDisplayed());
onView(activityTypeFilter)
.inRoot(isDialog())
.check(matches(isDisplayed()));
isDisplayed()))
.perform(waitFor(2000));
// check there's an edit text for "from date"
onView(withId(R.id.filter_date_edit_text_from))
.inRoot(isDialog())
.check(matches(isDisplayed()));
// check there's an edit text for "to date"
onView(withId(R.id.filter_date_edit_text_to))
.inRoot(isDialog())
.check(matches(isDisplayed()));
}
}
@@ -35,10 +35,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(50, distanceInterval);
assertIntervalStatisticsComputation(computation, 50, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(50, distanceInterval);
}
/**
@@ -52,10 +49,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(200, distanceInterval);
assertIntervalStatisticsComputation(computation, 200, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(200, distanceInterval);
}
/**
@@ -69,10 +63,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f;
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(3000, distanceInterval);
assertIntervalStatisticsComputation(computation, 3000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(3000, distanceInterval);
}
/**
@@ -86,10 +77,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 3000f;
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(1000, distanceInterval);
assertIntervalStatisticsComputation(computation, 1000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(1000, distanceInterval);
}
/**
@@ -103,10 +91,7 @@ public class IntervalStatisticsUpdaterTest {
float distanceInterval = 1000f;
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(10000, distanceInterval);
assertIntervalStatisticsComputation(computation, 10000, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(10000, distanceInterval);
}
@Test
@@ -137,18 +122,15 @@ public class IntervalStatisticsUpdaterTest {
Pair<Track, List<TrackPoint>> trackWithStats = new Pair<>(dummyTrack, trackPoints);
// when and then
IntervalStatisticsComputation computation = computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval);
assertIntervalStatisticsComputation(computation, numberOfPoints, distanceInterval);
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
}
private IntervalStatisticsComputation computeIntervalStatistics(int numberOfPoints, float distanceInterval) {
private void whenAndThen(int numberOfPoints, float distanceInterval) {
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));
intervalStatistics.addTrackPoints(trackPoints.iterator());
@@ -175,27 +157,23 @@ public class IntervalStatisticsUpdaterTest {
}
}
return new IntervalStatisticsComputation(track, intervalList, totalDistance, totalTime, totalGain, totalLoss);
}
private void assertIntervalStatisticsComputation(IntervalStatisticsComputation computation, int numberOfPoints, float distanceInterval) {
assertEquals(computation.track.statistics().totalDuration().toSeconds(), computation.totalTime, 0.01);
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
assertEquals(computation.intervalList.size(), (int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval));
if (computation.totalGain != null && computation.totalLoss != null) {
assertEquals(computation.totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
assertEquals(computation.totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
// then
assertEquals(track.statistics().totalDuration().toSeconds(), totalTime, 0.01);
assertEquals(track.statistics().totalDistance().toM(), totalDistance.toM(), 0.01);
assertEquals(intervalList.size(), (int) Math.ceil(track.statistics().totalDistance().toM() / distanceInterval));
if (totalGain != null && totalLoss != null) {
assertEquals(totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
assertEquals(totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
} 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 < computation.intervalList.size() - 1; i++) {
assertEquals(computation.intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
remainingDistance = remainingDistance.minus(computation.intervalList.get(i).totalDistance());
for (int i = 0; i < intervalList.size() - 1; i++) {
assertEquals(intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
totalDistance = totalDistance.minus(intervalList.get(i).totalDistance());
}
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) {
@@ -217,27 +195,4 @@ public class IntervalStatisticsUpdaterTest {
return new Pair<>(track, trackPoints);
}
private static final class IntervalStatisticsComputation {
private final Track track;
private final List<Statistics> intervalList;
private final Distance totalDistance;
private final float totalTime;
private final Float totalGain;
private final Float totalLoss;
private IntervalStatisticsComputation(Track track,
List<Statistics> intervalList,
Distance totalDistance,
float totalTime,
Float totalGain,
Float totalLoss) {
this.track = track;
this.intervalList = intervalList;
this.totalDistance = totalDistance;
this.totalTime = totalTime;
this.totalGain = totalGain;
this.totalLoss = totalLoss;
}
}
}
}
@@ -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;
import static org.hamcrest.Matchers.allOf;
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.ViewGroup;
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.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;
@@ -27,114 +16,61 @@ import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
public class EspressoUtils {
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<>() {
@Override
protected boolean matchesSafely(View view) {
if (!(view instanceof RecyclerView recyclerView)) {
return false;
}
RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
return adapter != null && adapter.getItemCount() == count;
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public void describeTo(Description description) {
description.appendText("RecyclerView should have " + count + " items");
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
public static Matcher<View> withSelectedTab(final int index) {
public static Matcher<View> withListSize (final int size) {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(View view) {
return view instanceof TabLayout tabLayout
&& tabLayout.getSelectedTabPosition() == index;
public boolean matchesSafely(final View view) {
return ((ListView) view).getCount() == size;
}
@Override
public void describeTo(Description description) {
description.appendText("TabLayout should have selected tab " + index);
public void describeTo(final Description description) {
description.appendText("ListView should have " + size + " items");
}
};
}
public static Matcher<View> withViewPagerCurrentItem(final int index) {
return new TypeSafeMatcher<>() {
public static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
@Override
protected boolean matchesSafely(View view) {
return view instanceof ViewPager2 viewPager2
&& viewPager2.getCurrentItem() == index
&& viewPager2.getScrollState() == ViewPager2.SCROLL_STATE_IDLE;
public String getDescription() {
return "Wait for milliseconds.";
}
@Override
public void describeTo(Description description) {
description.appendText("ViewPager2 should be idle on item " + index);
public Matcher<View> getConstraints() {
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) {
return new ViewAction() {
@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: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: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: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: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: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.
-->
<resources>
<string name="launcher_name" translatable="false">OpenTracks (Debug)</string>
<string name="app_name" translatable="false">OpenTracks (Debug)</string>
</resources>
+3 -30
View File
@@ -69,9 +69,6 @@ limitations under the License.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"
android:minSdkVersion="33" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
<application
android:name=".Startup"
android:allowBackup="true"
@@ -81,7 +78,7 @@ limitations under the License.
android:hardwareAccelerated="true"
android:hasFragileUserData="false"
android:icon="@drawable/ic_launcher"
android:label="@string/launcher_name"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/DayNightDynamicTheme"> <!-- Default for AppCompatActivities -->
@@ -129,32 +126,9 @@ limitations under the License.
<activity android:name=".AboutActivity" />
<!-- Permissions -->
<!-- until Android 13 -->
<activity
android:name=".HealthConnectPermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- for Android 14 -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".HealthConnectPermissionsRationaleActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<activity android:name=".settings.HealthConnect$Settings" />
<activity android:name=".settings.HealthConnect$ConfigurePermissions" />
<activity android:name=".settings.HealthConnect$ExportAll" />
<activity android:name=".HelpActivity" />
android:name=".HelpActivity"
android:label="@string/menu_help" />
<activity android:name=".io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity" />
@@ -436,6 +410,5 @@ limitations under the License.
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -1,28 +0,0 @@
package de.dennisguse.opentracks;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
setSupportActionBar(viewBinding.healthConnectActivityToolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setContentView(viewBinding.getRoot());
}
@Override
public boolean onSupportNavigateUp() {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
}
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.os.Build;
import android.os.StrictMode;
import android.util.Log;
import java.lang.reflect.Method;
@@ -28,11 +27,6 @@ public class Startup extends Application {
// Include version information into stack traces.
Log.i(TAG, BuildConfig.APPLICATION_ID + "; BuildType: " + BuildConfig.BUILD_TYPE + "; VersionName: " + BuildConfig.VERSION_NAME + "/" + BuildConfig.VERSION_NAME_FULL + " VersionCode: " + BuildConfig.VERSION_CODE);
// In debug builds: show thread and VM warnings.
if (BuildConfig.DEBUG) {
Log.d(TAG, "Enabling strict mode");
StrictMode.enableDefaults();
}
PreferencesUtils.initPreferences(this);
// Set default values of preferences on first start.
@@ -367,7 +367,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
if (itemId == R.id.list_context_menu_show_on_map) {
DataProvider.showTrackOnMap(this, recordingStatus.isRecording(), trackIds);
DataProvider.showTrackOnMap(this, false, trackIds);
return true;
}
@@ -18,7 +18,6 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.databinding.TrackStoppedBinding;
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
@@ -94,7 +93,6 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
viewBinding.finishButton.setOnClickListener(v -> {
storeTrackMetaData(contentProviderUtils, track);
ExportUtils.postWorkoutExport(this, trackId);
HealthConnectUtils.postExportTrack(this, trackId);
finish();
});
@@ -36,6 +36,7 @@ import java.util.Collections;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeExtremities;
@@ -68,10 +69,23 @@ public class ContentProviderUtils {
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 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) {
contentResolver = context.getContentResolver();
}
@@ -741,7 +755,7 @@ public class ContentProviderUtils {
}
@NonNull
public List<AggregatedStatistic> getAggregatedStatisticsForTracks(TrackSelection selection) {
public List<AggregatedStatistic> getAggregatedStatisticsForTracks(ContentProviderSelectionInterface selection) {
SelectionData data = selection.buildSelection();
try (Cursor cursor = contentResolver.query(TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS, null, data.selection(), data.getSelectionArgs(), null)) {
ArrayList<AggregatedStatistic> aggregatedStatistics = new ArrayList<>(cursor.getCount());
@@ -34,7 +34,6 @@ import androidx.annotation.NonNull;
import java.util.Arrays;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.data.tables.TrackPointsColumns;
@@ -51,11 +50,6 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
*/
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 SQL_LIST_DELIMITER = ",";
@@ -102,22 +96,22 @@ public class CustomContentProvider extends ContentProvider {
public CustomContentProvider() {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath(), UrlType.TRACKPOINTS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath() + "/#", UrlType.TRACKPOINTS_BY_ID.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_TRACKID.getPath() + "/*", UrlType.TRACKPOINTS_BY_TRACKID.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath(), UrlType.TRACKPOINTS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath() + "/#", UrlType.TRACKPOINTS_BY_ID.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(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS.getPath(), UrlType.TRACKS_AGGREGATED_STATISTICS.ordinal());
uriMatcher.addURI(AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath(), UrlType.TRACKS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_AGGREGATED_STATISTICS.getPath(), UrlType.TRACKS_AGGREGATED_STATISTICS.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(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.getPath(), UrlType.MARKERS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath() + "/#", UrlType.MARKERS_BY_ID.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(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_TRACKS_BY_IDS.getPath() + "*", UrlType.DASHBOARD_TRACKS_BY_IDS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, UrlType.DASHBOARD_TRACKPOINTS_BY_TRACKIDS.getPath() + "*", UrlType.DASHBOARD_TRACKPOINTS_BY_TRACKIDS.ordinal());
uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.getPath() + "*", UrlType.DASHBOARD_MARKERS_BY_TRACKIDS.ordinal());
}
@Override
@@ -153,7 +147,7 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
notifyChanges(url);
getContext().getContentResolver().notifyChange(url, null, false);
int totalChanges = getTotalChanges() - totalChangesBefore;
Log.i(TAG, "Deleted " + totalChanges + " total rows from database");
@@ -212,8 +206,7 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
notifyChanges(url);
getContext().getContentResolver().notifyChange(url, null, false);
return result;
}
@@ -236,8 +229,7 @@ public class CustomContentProvider extends ContentProvider {
} finally {
db.endTransaction();
}
notifyChanges(url);
getContext().getContentResolver().notifyChange(url, null, false);
return numInserted;
}
@@ -350,36 +342,10 @@ public class CustomContentProvider extends ContentProvider {
}
}
notifyChanges(url);
getContext().getContentResolver().notifyChange(url, null, false);
return count;
}
private void notifyChanges(@NonNull Uri uri) {
getContext().getContentResolver().notifyChange(uri, null, false);
// Also notify dashboard URIs for compatibility
Uri dashboardUri = getDashboardUri(uri);
if (dashboardUri != null) {
getContext().getContentResolver().notifyChange(dashboardUri, null, false);
}
}
private Uri getDashboardUri(Uri uri) {
String path = uri.getPath();
if (path == null) return null;
String dashboardPath;
if (path.startsWith("/tracks")) {
dashboardPath = path.replaceFirst("/tracks", "/dashboard/tracks");
} else if (path.startsWith("/markers")) {
dashboardPath = path.replaceFirst("/markers", "/dashboard/markers");
} else if (path.startsWith("/trackpoints")) {
dashboardPath = path.replaceFirst("/trackpoints", "/dashboard/trackpoints");
} else {
return null;
}
return Uri.parse(CONTENT_BASE_URI + dashboardPath);
}
@NonNull
private UrlType getUrlType(Uri url) {
UrlType[] urlTypes = UrlType.values();
@@ -450,9 +416,9 @@ public class CustomContentProvider extends ContentProvider {
@Deprecated //TODO Should not be used anymore
MARKERS_BY_TRACKID,
DASHBOARD_TRACKS_BY_IDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/tracks/"), TracksColumns.CONTENT_TYPE),
DASHBOARD_TRACKPOINTS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/trackpoints/"), TrackPointsColumns.CONTENT_TYPE),
DASHBOARD_MARKERS_BY_TRACKIDS(Uri.parse(CONTENT_BASE_URI + "/dashboard/markers/"), MarkerColumns.CONTENT_TYPE);
DASHBOARD_TRACKS_BY_IDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/tracks/"), TracksColumns.CONTENT_TYPE),
DASHBOARD_TRACKPOINTS_BY_TRACKIDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/trackpoints/"), TrackPointsColumns.CONTENT_TYPE),
DASHBOARD_MARKERS_BY_TRACKIDS(Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/dashboard/markers/"), MarkerColumns.CONTENT_TYPE);
private final Uri uri;
@@ -29,7 +29,7 @@ class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
private static final String TAG = CustomSQLiteOpenHelper.class.getSimpleName();
private static final int DATABASE_VERSION = 42;
private static final int DATABASE_VERSION = 43;
private final Context context;
@@ -86,6 +86,7 @@ class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
case 40 -> upgradeFrom39to40(db);
case 41 -> upgradeFrom40to41(db);
case 42 -> upgradeFrom41to42(db);
case 43 -> upgradeFrom42to43(db);
default -> throw new RuntimeException("Not implemented: upgrade to " + toVersion);
}
}
@@ -115,6 +116,7 @@ class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
case 39 -> downgradeFrom40to39(db);
case 40 -> downgradeFrom41to40(db);
case 41 -> downgradeFrom42to41(db);
case 42 -> downgradeFrom43to42(db);
default -> throw new RuntimeException("Not implemented: downgrade to " + toVersion);
}
}
@@ -794,4 +796,18 @@ class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
db.setTransactionSuccessful();
db.endTransaction();
}
private void upgradeFrom42to43(SQLiteDatabase db) {
db.beginTransaction();
db.execSQL("UPDATE trackpoints SET heartrate = null WHERE heartrate = 0.0");
db.setTransactionSuccessful();
db.endTransaction();
}
private void downgradeFrom43to42(SQLiteDatabase db) {
// Downgrade not possible: invalid data replaced with null.
}
}
@@ -59,12 +59,12 @@ public class ShareContentProvider extends CustomContentProvider {
private static final String TRACKID_DELIMITER = "_";
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(CustomContentProvider.AUTHORITY_PACKAGE, TracksColumns.TABLE_NAME + "/" + TrackFileFormat.CSV.getPreferenceId() + "/*/*", URI_CSV);
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(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.tables.TracksColumns;
public class TrackSelection {
public class TrackSelection implements ContentProviderUtils.ContentProviderSelectionInterface {
private final List<Track.Id> trackIds = new ArrayList<>();
private final List<String> categories = new ArrayList<>();
private Instant from;
@@ -40,6 +40,7 @@ public class TrackSelection {
return trackIds.isEmpty() && categories.isEmpty() && from == null && to == null;
}
@Override
public ContentProviderUtils.SelectionData buildSelection() {
String selection = "";
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),
FRISBEE("frisbee", R.string.activity_type_frisbee, ActivityIcon.ICON_UNKNOWN, true),
GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true),
GRAVEL_BIKING("gravel biking", R.string.activity_type_biking, R.drawable.ic_activity_bike_24dp, true),
HANG_GLIDING("hang gliding", R.string.activity_type_hang_gliding, ActivityIcon.ICON_UNKNOWN, true),
HELICOPTER("helicopter", R.string.activity_type_helicopter, ActivityIcon.ICON_UNKNOWN, true),
HIKING("hiking", R.string.activity_type_hiking, R.drawable.ic_activity_walk_24dp, false),
@@ -12,7 +12,10 @@ public record HeartRate(float value) {
@Nullable
public static HeartRate ofOrNull(Float value) {
if (value == null) return null;
return new HeartRate(value);
HeartRate rate = of(value);
if (rate.isValid()) return rate;
return null;
}
public float getBPM() {
@@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting;
* @author Leif Hendrik Wilden
* @author Rodrigo Damazio
*/
//TODO All data should be final; no default values.
public final class MarkerBuilder {
private Marker.Id id;
@@ -56,6 +57,14 @@ public final class MarkerBuilder {
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) {
this.id = marker.id();
this.trackId = marker.trackId();
@@ -8,7 +8,7 @@ import androidx.annotation.Nullable;
import java.time.Instant;
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 longitude,
@Nullable Distance horizontalAccuracy,
@@ -17,6 +17,10 @@ public record Position(
@Nullable Float bearing,
@Nullable Speed speed
) {
@Deprecated
public static Position empty() {
return of(null);
}
public static Position of(@NonNull Instant time) {
return new Position(
@@ -20,7 +20,6 @@ import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.time.Instant;
@@ -64,7 +63,7 @@ public record TrackPoint(
);
}
@VisibleForTesting
@Deprecated //TOOD Private?
public TrackPoint(Type type, Instant now) {
this(
null,
@@ -120,6 +119,19 @@ public record TrackPoint(
);
}
public TrackPoint removeHeartRate() {
return new TrackPoint(
id,
type,
position,
sensorDistance,
null,
cadence,
power,
altitudeGainLoss
);
}
public TrackPoint with(Position newPosition) {
return new TrackPoint(
id,
@@ -22,7 +22,7 @@ import android.provider.BaseColumns;
import android.widget.ArrayAdapter;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.ContentProviderUtils;
/**
* Constants for markers table.
@@ -32,8 +32,8 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface MarkerColumns extends BaseColumns {
String TABLE_NAME = "markers";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
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_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.waypoint";
String DEFAULT_SORT_ORDER = _ID;
@@ -19,7 +19,7 @@ package de.dennisguse.opentracks.data.tables;
import android.net.Uri;
import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.ContentProviderUtils;
/**
* Constants for the track points table.
@@ -29,8 +29,8 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TrackPointsColumns extends BaseColumns {
String TABLE_NAME = "trackpoints";
Uri CONTENT_URI_BY_ID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_BY_TRACKID = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/trackid");
Uri CONTENT_URI_BY_ID = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
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_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.trackpoint";
String DEFAULT_SORT_ORDER = _ID;
@@ -19,7 +19,7 @@ package de.dennisguse.opentracks.data.tables;
import android.net.Uri;
import android.provider.BaseColumns;
import de.dennisguse.opentracks.data.CustomContentProvider;
import de.dennisguse.opentracks.data.ContentProviderUtils;
/**
* Constants for the tracks table.
@@ -29,9 +29,9 @@ import de.dennisguse.opentracks.data.CustomContentProvider;
public interface TracksColumns extends BaseColumns {
String TABLE_NAME = "tracks";
Uri CONTENT_URI = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_SENSOR_STATS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats");
Uri CONTENT_URI_AGGREGATED_STATISTICS = Uri.parse(CustomContentProvider.CONTENT_BASE_URI + "/" + TABLE_NAME + "/aggregated/");
Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME);
Uri CONTENT_URI_SENSOR_STATS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats");
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_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track";
String DEFAULT_SORT_ORDER = _ID;
@@ -167,11 +167,8 @@ public class ExportActivity extends AbstractActivity {
new Thread(() -> {
directoryFiles = ExportUtils.getAllFiles(ExportActivity.this, documentFile.getUri());
runOnUiThread(() -> {
if (createExportTasks(allInOneFile)) {
nextExport(null);
} else {
onExportEnded();
}
createExportTasks(allInOneFile);
nextExport(null);
});
}).start();
} else {
@@ -220,12 +217,8 @@ public class ExportActivity extends AbstractActivity {
exportTasks.clear();
}
private boolean createExportTasks(boolean allInOneFile) {
private void createExportTasks(boolean allInOneFile) {
List<Track> tracks = contentProviderUtils.getTracks();
if (tracks.isEmpty()) {
return false;
}
exportTasks = new ArrayList<>();
if (allInOneFile) {
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());
}
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.
* 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.
*/
@@ -36,7 +36,7 @@ public class ExportUtils {
private static final String TAG = ExportUtils.class.getSimpleName();
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
if (PreferencesUtils.shouldInstantExportToFile()) {
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
@@ -297,17 +297,7 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
}
if (latitude == null || longitude == null) {
return new TrackPoint(
null,
TrackPoint.Type.TRACKPOINT,
Position.of(parsedTime.toInstant()),
null,
null,
null,
null,
null,
null
);
return new TrackPoint(TrackPoint.Type.TRACKPOINT, parsedTime.toInstant());
}
double latitudeParsed;
@@ -366,7 +356,7 @@ public class GPXTrackImporter extends DefaultHandler implements XMLImporter.Trac
HeartRate parsedHeartRate = null;
if (heartrate != null) {
try {
parsedHeartRate = HeartRate.of(Float.parseFloat(heartrate));
parsedHeartRate = HeartRate.ofOrNull(Float.parseFloat(heartrate));
} catch (NumberFormatException e) {
throw new ParsingException(createErrorMessage(String.format(Locale.US, "Unable to parse heart rate: %s", heartrate)), e);
}
@@ -190,6 +190,15 @@ public class TrackImporter {
}
trackPoints.set(i, newCurrent);
}
}
for (int i = 0; i < trackPoints.size(); i++) {
TrackPoint current = trackPoints.get(i);
if (!current.heartRate().isValid()) {
current = current.removeHeartRate();
trackPoints.set(i, current);
}
}
}
@@ -1,93 +0,0 @@
package de.dennisguse.opentracks.io.healthconnect.exporter;
import android.content.Context;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.PermissionController;
import androidx.health.connect.client.permission.HealthPermission;
import androidx.health.connect.client.records.ExerciseSessionRecord;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;
import java.util.Set;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import kotlin.coroutines.Continuation;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.jvm.functions.Function2;
import kotlinx.coroutines.BuildersKt;
/**
* Exports a single track as an ExerciseSession to Health Connect.
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
*/
public class HealthConnectUtils {
private static final String TAG = HealthConnectUtils.class.getSimpleName();
static final String WRITE_EXERCISE_PERMISSION = HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class));
static final String WRITE_EXERCISE_ROUTE_PERMISSION = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE;
//TODO Should be package private
public static final Set<String> PERMISSIONS = Set.of(WRITE_EXERCISE_PERMISSION, WRITE_EXERCISE_ROUTE_PERMISSION);
private HealthConnectUtils() {
}
public static boolean isAvailable(Context context) {
return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE;
}
public static Set<String> getGrantedPermissions(Context context) {
try {
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
PermissionController controller = client.getPermissionController();
return (Set<String>) BuildersKt.runBlocking(
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
(Function2) (scope, cont) -> controller.getGrantedPermissions((Continuation) cont));
} catch (InterruptedException e) {
return Set.of();
}
}
static boolean hasRequiredPermissions(Context context) {
return getGrantedPermissions(context)
.containsAll(PERMISSIONS);
}
public static void postExportTrack(Context context, Track.Id trackId) {
if (!PreferencesUtils.shouldInstantExportToHealthConnect()) {
return;
}
WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
.addTag(HealthConnectWorker.WORK_TAG)
.setInputData(new Data.Builder()
.putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id())
.build())
.build();
WorkManager.getInstance(context).enqueue(request);
}
public static void postExportAll(Context context) {
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
.addTag(HealthConnectWorker.WORK_TAG)
.build();
WorkManager.getInstance(context).enqueueUniqueWork(
HealthConnectWorker.WORK_TAG,
ExistingWorkPolicy.REPLACE,
request
);
}
public static void cancelHealthConnectExport(Context context) {
WorkManager workManager = WorkManager.getInstance(context);
workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG);
}
}
@@ -1,185 +0,0 @@
package de.dennisguse.opentracks.io.healthconnect.exporter;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.records.ExerciseRoute;
import androidx.health.connect.client.records.ExerciseSessionRecord;
import androidx.health.connect.client.records.metadata.Device;
import androidx.health.connect.client.records.metadata.Metadata;
import androidx.health.connect.client.units.Length;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
import kotlin.coroutines.Continuation;
import kotlin.jvm.functions.Function2;
import kotlinx.coroutines.BuildersKt;
/**
* Exports a single track as an ExerciseSession to Health Connect.
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
*/
//TODO add reasons for failures (if shown in the UI: must be localized)
//TODO Export sensor data (permissions needed)
//TODO Show progress: like ExportActivity? Or use a notification (aka handle it as a background process)
public class HealthConnectWorker extends Worker {
private static final String TAG = HealthConnectWorker.class.getSimpleName();
static final String WORK_TAG = "HealthConnectSync";
static final String TRACK_ID_KEY = "trackId";
public HealthConnectWorker(Context context, WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
if (!HealthConnectUtils.isAvailable(getApplicationContext())) {
Log.w(TAG, "Health Connect SDK not available");
return Result.failure();
}
//TODO If not all permissions are granted, export the data that was granted.
if (!HealthConnectUtils.hasRequiredPermissions(getApplicationContext())) {
Log.w(TAG, "Health Connect required permissions not granted");
return Result.failure();
}
Track.Id trackId = new Track.Id(getInputData().getLong(TRACK_ID_KEY, -1L));
if (trackId.id() != -1L) {
return exportTrack(trackId);
}
return exportAll();
}
private Result exportTrack(Track.Id trackId) {
try {
export(getApplicationContext(), trackId);
return Result.success();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.retry();
} catch (Exception e) {
Log.e(TAG, "Health Connect export failed for track " + trackId, e);
return Result.failure();
}
}
private Result exportAll() {
List<Track> allTracks = new ContentProviderUtils(getApplicationContext())
.getTracks();
for (Track track : allTracks) {
if (isStopped()) {
Log.i(TAG, "Initial Health Connect sync stopped by WorkManager");
return Result.success();
}
try {
export(getApplicationContext(), track.id());
} catch (InterruptedException e) {
return Result.retry();
} catch (Exception e) {
Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e);
return Result.failure();
}
}
return Result.success();
}
private static ExportResult export(Context context, Track.Id trackId) throws InterruptedException {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
throw new RuntimeException("Track not found: " + trackId);
}
ExerciseRoute exerciseRoute = createExerciseRoute(contentProviderUtils, trackId);
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
ExerciseSessionRecord record = new ExerciseSessionRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
Metadata.activelyRecorded(
new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL),
track.uuid().toString()
),
toExerciseType(track.activityType()),
track.name(),
track.description(),
Collections.emptyList(),
Collections.emptyList(),
exerciseRoute);
List<ExerciseSessionRecord> records = Collections.singletonList(record);
Object ignored = BuildersKt.runBlocking(
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
(Function2) (scope, cont) -> client.insertRecords(records, (Continuation) cont));
Log.i(TAG, "Exported track to Health Connect: " + track.name());
return new ExportResult(track.name());
}
private static ExerciseRoute createExerciseRoute(ContentProviderUtils contentProviderUtils, Track.Id trackId) {
List<ExerciseRoute.Location> route = new ArrayList<>();
try (TrackPointIterator iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) {
while (iterator.hasNext()) {
TrackPoint trackPoint = iterator.next();
if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue;
Position position = trackPoint.position();
route.add(new ExerciseRoute.Location(
trackPoint.getTime(),
position.latitude(),
position.longitude(),
position.horizontalAccuracy() != null ? Length.meters(position.horizontalAccuracy().toM()) : null,
position.verticalAccuracy() != null ? Length.meters(position.verticalAccuracy().toM()) : null,
position.altitude() != null ? Length.meters(position.altitude().toM()) : null)
);
}
}
return new ExerciseRoute(route);
}
private static int toExerciseType(ActivityType activityType) {
return switch (activityType) {
case RUNNING, STREET_RUNNING, TRAIL_RUNNING, TRACK_RUNNING -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING;
case BIKING, CYCLING, ROAD_BIKING, MOUNTAIN_BIKING, TRACK_CYCLING -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING;
case WALKING, SPEED_WALKING -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING;
case HIKING, OFF_TRAIL_HIKING, TRAIL_HIKING -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING;
case CROSS_COUNTRY_SKIING, SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING;
case SNOW_BOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING;
case KAYAKING -> ExerciseSessionRecord.EXERCISE_TYPE_PADDLING;
case ROWING -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE;
case SWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL;
case SWIMMING_OPEN -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER;
default -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT;
};
}
public record ExportResult(String trackName) {
}
}
@@ -9,7 +9,7 @@ import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
import de.dennisguse.opentracks.sensors.driver.Driver;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
public class CyclingCadenceHandler extends SensorHandler<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
public class CyclingCadenceHandler extends SensorHandler<CyclingCadenceBluetooth.CrankData, Cadence> {
protected CyclingCadenceHandler(SensorManager sensorManager) {
super(sensorManager);
@@ -110,7 +110,7 @@ public class GpsHandler extends SensorHandler<Location, Position> {
@Override
protected int getSensorPreferenceKey() {
return R.string.settings_sensor_gps_key;
return R.string.settings_sensor_bluetooth_gps_key;
}
@Override
@@ -14,6 +14,7 @@ import java.time.Instant;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Position;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.sensors.sensorData.AggregatorCyclingCadence;
@@ -125,7 +126,6 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
gpsHandler.resetAggregated();
}
@VisibleForTesting
public GpsHandler getGpsHandler() {
return gpsHandler;
}
@@ -164,7 +164,7 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
cyclingDistanceSpeedHandler.connect();
}
if (PreferencesUtils.isKey(cyclingPowerHandler.getSensorPreferenceKey(), key)) {
if (PreferencesUtils.isKey(cyclingCadenceHandler.getSensorPreferenceKey(), key)) {
cyclingPowerHandler.connect();
}
@@ -185,6 +185,17 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
return handler;
}
private SensorData<HeartRate> getHeartRate(Instant now) {
SensorData<HeartRate> value = heartRateHandler.getSensorData(now);
if (value != null) {
//TODO Move this check into HeartRateHandler
if (value.data().isValid()) return value;
}
return null;
}
private SensorData<Cadence> getCadence(Instant now) {
{
SensorData<Cadence> value = cyclingCadenceHandler.getSensorData(now);
@@ -239,19 +250,16 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
}
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), "");
}
var a = temperatureHandler.getSensorData(now);
var b = altitudeChangeHandler.getSensorData(now);
return new SensorDataSet(
gpsSensorData,
gpsSensorData != null ? gpsSensorData : new SensorData<>(Position.of(now), ""), //We always need a Position(now)
getSpeed(now),
getDistance(now),
heartRateHandler.getSensorData(now),
getHeartRate(now),
temperatureHandler.getSensorData(now),
getCadence(now),
cyclingPowerHandler.getSensorData(now),
@@ -259,6 +267,37 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
);
}
@VisibleForTesting
public void clear() {
Log.i(TAG, "Removing all aggregators");
//TODO This is to ExportImportTest alive; refactor test.
if (this.altitudeChangeHandler != null) {
this.altitudeChangeHandler.setAggregator(null);
}
if (this.heartRateHandler != null) {
this.heartRateHandler.setAggregator(null);
}
if (this.temperatureHandler != null) {
this.temperatureHandler.setAggregator(null);
}
if (this.cyclingCadenceHandler != null) {
this.cyclingCadenceHandler.setAggregator(null);
}
if (cyclingDistanceSpeedHandler != null) {
cyclingDistanceSpeedHandler.setAggregator(null);
}
if (cyclingPowerHandler != null) {
cyclingPowerHandler.setAggregator(null);
}
if (runningSpeedCadenceDistanceHandler != null) {
runningSpeedCadenceDistanceHandler.setAggregator(null);
}
if (this.gpsHandler != null) {
this.gpsHandler.setAggregator(null);
}
}
private boolean isStarted() {
return wakeLock != null;
}
@@ -9,8 +9,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
import de.dennisguse.opentracks.data.models.AtmosphericPressure;
@@ -19,14 +17,12 @@ public class BarometerInternal implements Driver {
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 android.hardware.SensorManager sensorService;
private Instant lastEventTime = Instant.EPOCH;
private final SensorEventListener sensorEventListener = new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
@@ -35,13 +31,6 @@ public class BarometerInternal implements Driver {
return;
}
Instant currentTime = Instant.now();
if (lastEventTime.plus(SAMPLING_PERIOD).isAfter(currentTime)) {
return;
}
lastEventTime = currentTime;
observer.onDataReceived(AtmosphericPressure.ofHPA(event.values[0]));
}
@@ -59,9 +48,8 @@ public class BarometerInternal implements Driver {
public void connect(Context context, Handler handler, String addressIgnored) {
sensorService = (android.hardware.SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
Sensor pressureSensor = sensorService.getDefaultSensor(Sensor.TYPE_PRESSURE);
int samplingPeriodUs = (int) TimeUnit.MICROSECONDS.convert(SAMPLING_PERIOD);
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, samplingPeriodUs, handler)) {
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, SAMPLING_PERIOD, handler)) {
observer.onConnected(null, null);
return;
}
@@ -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) {
}
}
@@ -6,25 +6,23 @@ 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> {
public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<CyclingCadenceBluetooth.CrankData> {
private static final String TAG = CyclingCadenceBluetooth.class.getSimpleName();
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
public static final List<ServiceMeasurementUUID> CYCLING_CADENCE = List.of(
CyclingPowerBluetooth.CYCLING_POWER,
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE,
BoschEbikeParser.BOSCH_EBIKE
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return SUPPORTED_SERVICES;
return CYCLING_CADENCE;
}
@Override
public CyclingCadenceMeasurement parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
public CrankData 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) {
@@ -40,28 +38,12 @@ public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<
}
}
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
if (boschData != null && boschData.cadence() != null) {
return new DirectCadenceData(boschData.cadence());
}
}
Log.e(TAG, "Don't know how to decode this payload.");
return null;
}
public sealed interface CyclingCadenceMeasurement permits CrankData, DirectCadenceData {
}
public record CrankData(
long crankRevolutionsCount, // UINT32
int crankRevolutionsTime // UINT16; 1/1024s
) implements CyclingCadenceMeasurement {
}
public record DirectCadenceData(
Cadence cadence
) implements CyclingCadenceMeasurement {
}
) {}
}
@@ -18,28 +18,14 @@ public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser<Cy
new UUID(0x2A6300001000L, 0x800000805f9b34fbL)
);
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
CYCLING_POWER,
BoschEbikeParser.BOSCH_EBIKE
);
@Override
public List<ServiceMeasurementUUID> getServices() {
return SUPPORTED_SERVICES;
return List.of(CYCLING_POWER);
}
@Override
public CyclingPowerBluetooth.Data parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
if (serviceMeasurementUUID.equals(CYCLING_POWER)) {
return parseCyclingPower(characteristic);
}
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
if (boschData != null && boschData.humanPower() != null) {
return new Data(boschData.humanPower(), null);
}
}
return null;
return parseCyclingPower(characteristic);
}
@@ -1,6 +1,7 @@
package de.dennisguse.opentracks.sensors.driver;
import android.bluetooth.BluetoothGattCharacteristic;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
@@ -13,6 +14,8 @@ import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
public class HeartRateBluetooth implements BluetoothDriver.BluetoothParser<HeartRate> {
private static final String TAG = BluetoothHandlerManagerHeartRate.class.getSimpleName();
public static final ServiceMeasurementUUID HEARTRATE = new ServiceMeasurementUUID(
new UUID(0x180D00001000L, 0x800000805f9b34fbL),
new UUID(0x2A3700001000L, 0x800000805f9b34fbL)
@@ -35,7 +38,16 @@ public class HeartRateBluetooth implements BluetoothDriver.BluetoothParser<Heart
@Override
public HeartRate parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
return parseHeartRate(characteristic);
HeartRate heartRate = parseHeartRate(characteristic);
if (heartRate == null) return null;
if (heartRate.value() == 0.0) {
Log.d(TAG, "Invalid heartrate received. Ignoring.");
return null;
}
return heartRate;
}
@VisibleForTesting
@@ -34,7 +34,7 @@ public abstract class Aggregator<InputType, OutputType> {
previous = next;
}
protected abstract void computeValue(@NonNull Raw<InputType> current);
protected abstract void computeValue(Raw<InputType> current);
/**
* @return did we process data from a sensor.
@@ -1,7 +1,6 @@
package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.AtmosphericPressure;
import de.dennisguse.opentracks.sensors.PressureSensorUtils;
@@ -15,7 +14,7 @@ public class AggregatorBarometer extends Aggregator<AtmosphericPressure, Altitud
}
@Override
protected void computeValue(@NonNull Raw<AtmosphericPressure> current) {
protected void computeValue(Raw<AtmosphericPressure> current) {
if (previous == null) {
lastAcceptedSensorValue = current.value();
output = new AltitudeGainLoss(0f, 0f);
@@ -41,4 +40,6 @@ public class AggregatorBarometer extends Aggregator<AtmosphericPressure, Altitud
public void resetAggregated() {
output = null;
}
public record Data(Altitude gain, Altitude loss) {}
}
@@ -2,15 +2,13 @@ package de.dennisguse.opentracks.sensors.sensorData;
import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import de.dennisguse.opentracks.data.models.Cadence;
import de.dennisguse.opentracks.sensors.UintUtils;
import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CrankData, Cadence> {
private final String TAG = AggregatorCyclingCadence.class.getSimpleName();
@@ -19,20 +17,12 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
}
@Override
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CyclingCadenceMeasurement> current) {
if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) {
output = directCadenceData.cadence();
protected void computeValue(Raw<CyclingCadenceBluetooth.CrankData> current) {
if (previous == null) {
return;
}
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value());
}
private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw) {
CyclingCadenceBluetooth.CrankData previousRaw = getPreviousRaw();
if (previousRaw == null) return;
float timeDiff_ms = UintUtils.diff(currentRaw.crankRevolutionsTime(), previousRaw.crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
float timeDiff_ms = UintUtils.diff(current.value().crankRevolutionsTime(), previous.value().crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
if (timeDiff.isZero()) {
@@ -45,21 +35,13 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
}
// TODO We have to treat with overflow according to the documentation: read https://codeberg.org/OpenTracksApp/OpenTracks/pulls/953#issuecomment-6466930
if (currentRaw.crankRevolutionsCount() < previousRaw.crankRevolutionsCount()) {
if (current.value().crankRevolutionsCount() < previous.value().crankRevolutionsCount()) {
Log.e(TAG, "Crank revolutions count difference is invalid: cannot compute cadence.");
output = null;
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);
}
private CyclingCadenceBluetooth.CrankData getPreviousRaw() {
if (previous == null) {
return null;
}
return previous.value() instanceof CyclingCadenceBluetooth.CrankData raw ? raw : null;
}
}
@@ -2,8 +2,6 @@ package de.dennisguse.opentracks.sensors.sensorData;
import android.util.Log;
import androidx.annotation.NonNull;
import java.time.Duration;
import de.dennisguse.opentracks.data.models.Distance;
@@ -22,7 +20,7 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator<CyclingDistanceSp
}
@Override
protected void computeValue(@NonNull Raw<CyclingDistanceSpeedBluetooth.WheelData> current) {
protected void computeValue(Raw<CyclingDistanceSpeedBluetooth.WheelData> current) {
if (previous == null) {
return;
}
@@ -1,7 +1,5 @@
package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
@@ -12,7 +10,7 @@ public class AggregatorCyclingPower extends Aggregator<CyclingPowerBluetooth.Dat
}
@Override
public void computeValue(@NonNull Raw<CyclingPowerBluetooth.Data> current) {
public void computeValue(Raw<CyclingPowerBluetooth.Data> current) {
this.output = current.value().power();
}
}
@@ -4,6 +4,8 @@ import android.location.Location;
import androidx.annotation.NonNull;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Position;
public class AggregatorGPS extends Aggregator<Location, Position> {
@@ -13,7 +15,7 @@ public class AggregatorGPS extends Aggregator<Location, Position> {
}
@Override
protected void computeValue(@NonNull Raw<Location> current) {
protected void computeValue(Raw<Location> current) {
output = Position.of(current.value(), current.time());
}
@@ -22,7 +24,17 @@ 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.
* 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();
output = null;
}
@NonNull
@Override
public Position getAggregatedValue(Instant now) {
Position value = super.getAggregatedValue(now);
if (value == null) value = Position.empty();
return value.with(now);
}
}
@@ -1,7 +1,5 @@
package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.data.models.HeartRate;
public class AggregatorHeartRate extends Aggregator<HeartRate, HeartRate> {
@@ -11,9 +9,7 @@ public class AggregatorHeartRate extends Aggregator<HeartRate, HeartRate> {
}
@Override
protected void computeValue(@NonNull Raw<HeartRate> current) {
if (current.value().isValid()) {
this.output = current.value();
}
protected void computeValue(Raw<HeartRate> current) {
this.output = current.value();
}
}
@@ -19,7 +19,7 @@ public final class AggregatorRunning extends Aggregator<RunningSpeedAndCadenceBl
}
@Override
public void computeValue(@NonNull Raw<RunningSpeedAndCadenceBluetooth.Data> current) {
public void computeValue(Raw<RunningSpeedAndCadenceBluetooth.Data> current) {
if (previous == null) {
return;
}
@@ -1,7 +1,5 @@
package de.dennisguse.opentracks.sensors.sensorData;
import androidx.annotation.NonNull;
import java.time.Duration;
import de.dennisguse.opentracks.data.models.Temperature;
@@ -12,7 +10,7 @@ public class AggregatorTemperature extends Aggregator<Temperature, Temperature>
super(sensorAddress, sensorName);
}
protected void computeValue(@NonNull Raw<Temperature> current) {
protected void computeValue(Raw<Temperature> current) {
this.output = current.value();
}
@@ -10,10 +10,6 @@ import de.dennisguse.opentracks.sensors.sensorData.SensorDataSet;
*/
public record RecordingData(
Track track,
/*
* The current view on the SensorData.
* May contain data from previous measurements that are not present in SensorDataSet.
*/
TrackPoint latestTrackPoint,
SensorDataSet sensorDataSet,
Statistics currentSegment) {
@@ -1,56 +0,0 @@
package de.dennisguse.opentracks.services;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import de.dennisguse.opentracks.data.models.Track;
/**
* For handling restarts of TrackRecordingService: while recording, stores the {@link de.dennisguse.opentracks.data.models.Track.Id}.
*/
class TrackIdStorage {
private final static String SHARED_PREFERENCE_NAME = "TrackRecordingService_pref";
private final static String KEY = "trackId_key";
private static SharedPreferences sharedPreferences;
private static void init(Context context) {
if (sharedPreferences == null) {
sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE);
}
}
@Nullable
public static Track.Id get(Context context) {
TrackIdStorage.init(context);
long value = sharedPreferences.getLong(KEY, -1);
if (value < 0) {
return null;
}
return new Track.Id(value);
}
public static void set(Context context, @NonNull Track.Id trackId) {
TrackIdStorage.init(context);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putLong(KEY, trackId.id());
editor.commit();
}
public static void unset(Context context) {
TrackIdStorage.init(context);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.remove(KEY);
editor.commit();
}
}
@@ -67,7 +67,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
contentProviderUtils = new ContentProviderUtils(context);
}
synchronized Track.Id startNewTrack() {
Track.Id startNewTrack() {
TrackPoint segmentStartTrackPoint = trackPointCreator.createSegmentStartManual();
ZoneOffset zoneOffset = ZoneOffset.systemDefault().getRules().getOffset(segmentStartTrackPoint.getTime());
@@ -103,7 +103,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
/**
* @return if the recording could be started.
*/
synchronized boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
trackId = resumeTrackId;
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
@@ -119,7 +119,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
return true;
}
synchronized void endCurrentTrack() {
void endCurrentTrack() {
TrackPoint segmentEnd = trackPointCreator.createSegmentEnd();
insertTrackPoint(segmentEnd, true);
@@ -131,10 +131,10 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
RecordingData getDataForUI() {
Pair<TrackPoint, SensorDataSet> current = trackPointCreator.createCurrentTrackPoint(lastTrackPointUIWithSpeed, lastTrackPointUIWithAltitude, lastStoredTrackPointWithLocation);
TrackPoint trackPoint = current.first;
TrackStatisticsUpdater tmpTrackStatisticsUpdater = new TrackStatisticsUpdater(trackStatisticsUpdater, trackPoint);
TrackPoint TrackPointWithEGM2008 = ALTITUDE_CORRECTION_MANAGER.correctAltitude(context, trackPoint);
TrackStatisticsUpdater tmpTrackStatisticsUpdater = new TrackStatisticsUpdater(trackStatisticsUpdater, current.first);
ALTITUDE_CORRECTION_MANAGER.correctAltitude(context, current.first);
Track track = contentProviderUtils.getTrack(trackId); //Get copy TODO: Avoid re-loading data from DB
if (track == null) {
@@ -145,11 +145,11 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
TrackBuilder trackBuilder = new TrackBuilder(track);
trackBuilder.setStatistics(tmpTrackStatisticsUpdater.getTrackStatistics());
return new RecordingData(trackBuilder.getTrack(), TrackPointWithEGM2008, current.second, tmpTrackStatisticsUpdater.getCurrentSegment());
return new RecordingData(trackBuilder.getTrack(), current.first, current.second, tmpTrackStatisticsUpdater.getCurrentSegment());
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public synchronized void onIdle() {
public void onIdle() {
Log.d(TAG, "Becoming idle");
onNewTrackPoint(trackPointCreator.createIdle());
@@ -48,8 +48,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
private static final String TAG = TrackRecordingService.class.getSimpleName();
@VisibleForTesting
public static final Duration RECORDING_DATA_UPDATE_INTERVAL = Duration.ofSeconds(1);
private static final Duration RECORDING_DATA_UPDATE_INTERVAL = Duration.ofSeconds(1);
public static final RecordingStatus STATUS_DEFAULT = RecordingStatus.notRecording();
public static final GpsStatusValue STATUS_GPS_DEFAULT = GpsStatusValue.GPS_NONE;
@@ -86,6 +85,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
private MutableLiveData<GpsStatusValue> gpsStatusObservable;
private MutableLiveData<RecordingData> recordingDataObservable;
// The following variables are set when recording:
private Handler handler;
private TrackPointCreator trackPointCreator;
@@ -118,6 +118,9 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
@Override
public void onDestroy() {
Log.d(TAG, "Destroying");
if (isRecording()) {
endCurrentTrack();
}
if (trackPointCreator != null && trackPointCreator.isStarted()) {
stopSensors();
}
@@ -146,16 +149,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
Log.i(TAG, "Service got killed and is restarting");
//Best guess: do we need to resume a recording?
Track.Id trackId = TrackIdStorage.get(this);
if (trackId != null) {
Log.i(TAG, "After restart: resuming recording for " + trackId);
resumeTrack(trackId);
}
}
return START_STICKY;
}
@@ -173,7 +166,9 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
// Set recording status
Track.Id trackId = trackRecordingManager.startNewTrack();
startRecording(trackId);
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
return trackId;
}
@@ -184,13 +179,12 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
}
Log.i(TAG, "resumeTrack");
startRecording(trackId);
updateRecordingStatus(RecordingStatus.record(trackId));
startRecording();
}
private void startRecording(Track.Id trackId) {
updateRecordingStatus(RecordingStatus.record(trackId));
TrackIdStorage.set(this, trackId);
private void startRecording() {
startSensors();
voiceAnnouncementManager.start(trackRecordingManager.getTrackStatistics());
@@ -231,7 +225,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
updateRecordingStatus(STATUS_DEFAULT);
trackRecordingManager.endCurrentTrack();
TrackIdStorage.unset(this);
stopUpdateRecordingData();
@@ -102,10 +102,10 @@ public class TrackPointCreator implements SharedPreferences.OnSharedPreferenceCh
}
if (lastTrackPointUISpeed != null)
position = position.with(lastTrackPointUISpeed.position().speed());
position.with(lastTrackPointUISpeed.position().speed());
if (lastTrackPointUIAltitude != null)
position = position.with(lastTrackPointUIAltitude.position().altitude());
position.with(lastTrackPointUIAltitude.position().altitude());
TrackPoint currentTrackPoint = new TrackPoint(
null,
@@ -1,54 +0,0 @@
package de.dennisguse.opentracks.settings;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.PermissionController;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
public class HealthConnect {
public static class Settings extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startActivity(new Intent(HealthConnectClient.getHealthConnectSettingsAction()));
finish();
}
}
public static class ConfigurePermissions extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!HealthConnectUtils.isAvailable(this)) {
finish();
}
registerForActivityResult(
PermissionController.createRequestPermissionResultContract(),
grantedPermissions -> {
// TODO User feedback via Toast.makeText()?
finish();
}
).launch(HealthConnectUtils.PERMISSIONS);
}
}
public static class ExportAll extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
HealthConnectUtils.postExportAll(this);
finish();
}
}
}
@@ -14,12 +14,12 @@ import java.util.Map;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.util.IntentUtils;
public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
private static final String TAG = ImportExportSettingsFragment.class.getSimpleName();
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.settings_import_export);
@@ -27,11 +27,6 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
setExportTrackFileFormatOptions();
setExportDirectorySummary();
setFilenameTemplate();
findPreference("healthConnectPermissions").setSummaryProvider(preference -> {
//TODO This should be localized and prefixes trimmed.
return String.join(",", HealthConnectUtils.getGrantedPermissions(getActivity()));
});
}
@Override
@@ -45,21 +40,9 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
super.onResume();
setExportDirectorySummary();
findPreference(getString(R.string.post_workout_export_enabled_key)).setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
setHealthConnectPreference();
}
private void setHealthConnectPreference() {
boolean healthConnectAvailable = HealthConnectUtils.isAvailable(requireContext());
findPreference("healthConnectCategory")
.setEnabled(healthConnectAvailable);
if (!healthConnectAvailable) {
findPreference(getString(R.string.post_workout_export_health_connect_enabled_key))
.setSummary(R.string.health_connect_not_available);
return;
}
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
}
private void setExportTrackFileFormatOptions() {
@@ -71,7 +71,6 @@ public class PreferencesUtils {
private static SharedPreferences sharedPreferences;
private static Resources resources;
private static final long LONG_UNSET = Long.MIN_VALUE;
/**
* Must be called during application startup.
@@ -169,36 +168,24 @@ public class PreferencesUtils {
}
}
private static long getLong(int keyId, long defaultValue) {
try {
return sharedPreferences.getLong(getKey(keyId), defaultValue);
} catch (ClassCastException e) {
// Ignore.
}
try {
String stringValue = sharedPreferences.getString(getKey(keyId), null);
return Long.parseLong(stringValue);
} catch (NumberFormatException e) {
return defaultValue;
}
}
public static String getString(int keyId, String defaultValue) {
return sharedPreferences.getString(getKey(keyId), defaultValue);
}
static void setString(int keyId, String value) {
@VisibleForTesting
public static void setString(int keyId, String value) {
Editor editor = sharedPreferences.edit();
editor.putString(getKey(keyId), value);
editor.apply();
}
static void setString(int keyId, int valueId) {
@VisibleForTesting
public static void setString(int keyId, int valueId) {
setString(keyId, resources.getString(valueId));
}
static void setBoolean(int keyId, Boolean value) {
@VisibleForTesting
public static void setBoolean(int keyId, Boolean value) {
Editor editor = sharedPreferences.edit();
editor.putBoolean(getKey(keyId), value);
editor.apply();
@@ -210,12 +197,6 @@ public class PreferencesUtils {
editor.apply();
}
private static void setLong(int keyId, long value) {
Editor editor = sharedPreferences.edit();
editor.putLong(getKey(keyId), value);
editor.apply();
}
public static boolean isPublicAPIenabled() {
return getBoolean(R.string.publicapi_enabled_key, resources.getBoolean(R.bool.publicapi_enabled_default));
}
@@ -347,11 +328,6 @@ public class PreferencesUtils {
return getBoolean(R.string.voice_announce_unit_key, true);
}
@VisibleForTesting
public static void setVoiceAnnounceUnit(boolean value) {
setBoolean(R.string.voice_announce_unit_key, value);
}
public static Duration getVoiceAnnouncementFrequency() {
final int DEFAULT = Integer.parseInt(resources.getString(R.string.voice_announcement_frequency_default));
int value = getInt(R.string.voice_announcement_frequency_key, DEFAULT);
@@ -446,7 +422,7 @@ public class PreferencesUtils {
}
public static boolean shouldVoiceAnnounceLapPower() {
return getBoolean(R.string.voice_announce_lap_power_key, getBoolean(R.bool.voice_announce_lap_power_default, false));
return getBoolean(R.string.voice_announce_lap_power_key, false);
}
public static boolean shouldVoiceAnnounceHeartRateCurrent() {
@@ -679,16 +655,12 @@ public class PreferencesUtils {
return entries;
}
public static boolean shouldInstantExportToFile() {
public static boolean shouldInstantExportAfterWorkout() {
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri();
}
public static boolean shouldInstantExportToHealthConnect() {
final boolean DEFAULT = resources.getBoolean(R.bool.post_workout_export_health_connect_enabled_default);
return getBoolean(R.string.post_workout_export_health_connect_enabled_key, DEFAULT);
}
public static TrackFilenameGenerator getTrackFileformatGenerator() {
String DEFAULT = resources.getString(R.string.export_filename_format_default);
TrackFilenameGenerator generator = new TrackFilenameGenerator(getString(R.string.export_filename_format_key, DEFAULT));
@@ -776,13 +748,11 @@ public class PreferencesUtils {
sharedPreferences.edit().clear().commit();
}
// Each setting file must be mentioned here: see xml/settings.xml
// Each setting file must be mentioned here.
PreferenceManager.setDefaultValues(context, R.xml.settings, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_defaults, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_user_interface, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_recording, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_sensors, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_announcements, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_import_export, readAgain);
PreferenceManager.setDefaultValues(context, R.xml.settings_public_api, readAgain);
}
@@ -27,6 +27,6 @@ public class BluetoothLeCyclingCadencePreference extends BluetoothLeSensorPrefer
@Override
public PreferenceDialogFragmentCompat createInstance() {
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.SUPPORTED_SERVICES);
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.CYCLING_CADENCE);
}
}
@@ -4,6 +4,9 @@ import android.content.Context;
import android.util.AttributeSet;
import androidx.preference.PreferenceDialogFragmentCompat;
import java.util.List;
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreference {
@@ -26,8 +29,6 @@ public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreferen
@Override
public PreferenceDialogFragmentCompat createInstance() {
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(
getKey(),
CyclingPowerBluetooth.SUPPORTED_SERVICES);
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(getKey(), List.of(CyclingPowerBluetooth.CYCLING_POWER));
}
}
}
@@ -0,0 +1,41 @@
package de.dennisguse.opentracks.settings.bluetooth;
import android.content.Context;
import android.location.LocationManager;
import android.util.AttributeSet;
import androidx.core.location.LocationManagerCompat;
import androidx.preference.PreferenceDialogFragmentCompat;
import java.util.List;
import de.dennisguse.opentracks.sensors.driver.GpsInternal;
public class BluetoothLeGpsPreference extends BluetoothLeSensorPreference {
public BluetoothLeGpsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public BluetoothLeGpsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public BluetoothLeGpsPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BluetoothLeGpsPreference(Context context) {
super(context);
}
@Override
public PreferenceDialogFragmentCompat createInstance() {
boolean hasInternalSensor = LocationManagerCompat.hasProvider((LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE), GpsInternal.LOCATION_PROVIDER);
return BluetoothLeSensorPreferenceDialog
.newInstance(getKey(), List.of(), hasInternalSensor);
}
}
@@ -191,6 +191,26 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
List<ParcelUuid> serviceUUIDs = getArguments().getParcelableArrayList(ARG_BLE_SERVICE_UUIDS);
boolean includeInternalSensor = getArguments().getBoolean(ARG_INCLUDE_INTERNAL);
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled.");
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
dismiss();
return;
}
if (bluetoothAdapter.isDiscovering()) {
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
bluetoothAdapter.cancelDiscovery();
}
scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner == null) {
Log.e(TAG, "BluetoothLeScanner is null.");
dismiss();
return;
}
listAdapter.add(SensorType.NONE.getPreferenceValue(), getContext().getString(DEVICE_NONE_RESOURCEID));
selectedEntryIndex = 0;
@@ -208,37 +228,8 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
selectedEntryIndex = !includeInternalSensor ? 1 : 2;
}
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled. Skipping BLE scan.");
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
if (!includeInternalSensor) {
dismiss();
}
return;
}
if (bluetoothAdapter.isDiscovering()) {
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
bluetoothAdapter.cancelDiscovery();
}
scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner == null) {
Log.e(TAG, "BluetoothLeScanner is null.");
dismiss();
return;
}
// Pre-populate with already-bonded BLE devices so that devices that are paired but not actively advertising (e.g. Bosch eBike) are still shown. In case we dont have any cached UUIDs for a device, we just add it to the list and let the user decide
boolean filterEnabled = PreferencesUtils.getBluetoothFilterEnabled();
bluetoothAdapter.getBondedDevices().stream()
.filter(bonded -> bonded.getType() == BluetoothDevice.DEVICE_TYPE_LE || bonded.getType() == BluetoothDevice.DEVICE_TYPE_DUAL)
.filter(bonded -> !filterEnabled || bonded.getUuids() == null || java.util.Arrays.stream(bonded.getUuids()).anyMatch(serviceUUIDs::contains))
.forEach(bonded -> listAdapter.add(bonded.getAddress(), bonded.getName()));
List<ScanFilter> scanFilter = null;
if (filterEnabled) {
if (PreferencesUtils.getBluetoothFilterEnabled()) {
scanFilter = serviceUUIDs.stream()
.map(it -> new ScanFilter.Builder().setServiceUuid(it).build())
.toList();
@@ -7,51 +7,13 @@ import android.widget.ArrayAdapter;
import androidx.annotation.NonNull;
import java.time.ZoneOffset;
import java.util.stream.IntStream;
/**
* <a href="Data source for UTC Offsets">https://en.wikipedia.org/wiki/List_of_UTC_offsets</a>
*/
public class ZoneOffsetAdapter extends ArrayAdapter<ZoneOffset> {
public ZoneOffsetAdapter(@NonNull Context context, int resource) {
super(context, resource);
addAll(
ZoneOffset.ofHoursMinutes(-12, 00),
ZoneOffset.ofHoursMinutes(-11, 00),
ZoneOffset.ofHoursMinutes(-10, 00),
ZoneOffset.ofHoursMinutes(-9, -30),
ZoneOffset.ofHoursMinutes(-8, 00),
ZoneOffset.ofHoursMinutes(-7, 00),
ZoneOffset.ofHoursMinutes(-6, 00),
ZoneOffset.ofHoursMinutes(-5, 00),
ZoneOffset.ofHoursMinutes(-4, 00),
ZoneOffset.ofHoursMinutes(-3, -30),
ZoneOffset.ofHoursMinutes(-3, 00),
ZoneOffset.ofHoursMinutes(-2, 00),
ZoneOffset.ofHoursMinutes(-1, 00),
ZoneOffset.ofHoursMinutes(0, 00),
ZoneOffset.ofHoursMinutes(1, 00),
ZoneOffset.ofHoursMinutes(2, 00),
ZoneOffset.ofHoursMinutes(3, 00),
ZoneOffset.ofHoursMinutes(3, 30),
ZoneOffset.ofHoursMinutes(4, 00),
ZoneOffset.ofHoursMinutes(4, 30),
ZoneOffset.ofHoursMinutes(5, 30),
ZoneOffset.ofHoursMinutes(5, 45),
ZoneOffset.ofHoursMinutes(6, 00),
ZoneOffset.ofHoursMinutes(6, 30),
ZoneOffset.ofHoursMinutes(7, 00),
ZoneOffset.ofHoursMinutes(8, 00),
ZoneOffset.ofHoursMinutes(8, 45),
ZoneOffset.ofHoursMinutes(9, 00),
ZoneOffset.ofHoursMinutes(9, 30),
ZoneOffset.ofHoursMinutes(10, 00),
ZoneOffset.ofHoursMinutes(10, 30),
ZoneOffset.ofHoursMinutes(11, 00),
ZoneOffset.ofHoursMinutes(12, 00),
ZoneOffset.ofHoursMinutes(12, 45),
ZoneOffset.ofHoursMinutes(13, 00),
ZoneOffset.ofHoursMinutes(14, 00)
);
super(context, resource, IntStream.range(-12, +12)
.boxed()
.map(ZoneOffset::ofHours)
.toList());
}
}
@@ -0,0 +1,16 @@
package de.dennisguse.opentracks.ui.customRecordingLayout;
public enum CustomLayoutFieldType {
GENERIC(1),
CLOCK(2);
private final int value;
CustomLayoutFieldType(int value) {
this.value = value;
}
public int value() {
return this.value;
}
}

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