forked from upstream-mirrors/OpenTracks
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b09d640844 | |||
| a039f1a00c | |||
| 3df5c06c20 | |||
| 2904c65a01 | |||
| 4977789004 | |||
| 95e6448cba | |||
| a66c1b9407 | |||
| 9d55ac261f | |||
| dbd3e2fc5c | |||
| 713e454e44 | |||
| f0616fc043 | |||
| 75ed3120bd | |||
| b22e41cf66 | |||
| d1f003c0ce | |||
| 6ef80ade98 | |||
| e49d9fbd83 | |||
| c7738b1cb2 | |||
| 713f5c7708 | |||
| b9a36d1b3c | |||
| 34e89ea3f9 | |||
| 50a0e37086 | |||
| 11ac6b7279 | |||
| aadb79bf7d | |||
| da6b26644e | |||
| b264655d75 | |||
| 1ea9c0b216 | |||
| 8040edac7a | |||
| 037c79a1c5 | |||
| bc46f7447e | |||
| 6e88a448b1 | |||
| 2dac8d3ea6 | |||
| 0844c48d25 | |||
| 9607fda962 | |||
| 77ed28a8e2 | |||
| 4283e6d316 | |||
| ee030f32f5 | |||
| fb25055f3e | |||
| 9cdf87b998 | |||
| e9217b5327 | |||
| b4cb606499 | |||
| d1975685bc | |||
| 8cd9dda1f5 | |||
| c072967dd1 | |||
| 94e91d2063 | |||
| 796fa216f6 | |||
| f143aabfbf | |||
| d58342233c | |||
| 91c254ba7f | |||
| 19edb47554 | |||
| 1129c96562 | |||
| 7224efa8a9 | |||
| 149f8f809e | |||
| c2f59c11bf | |||
| 8b7d4fa813 | |||
| 81081b0ee5 | |||
| def4f3a1b9 | |||
| b679f00cc5 | |||
| 40494557d4 | |||
| a94f023a17 | |||
| ee105a1610 | |||
| 3bec73e074 | |||
| 1155a56e49 | |||
| 9b7e858d1d | |||
| d1490fc343 | |||
| 3f6a4975fa | |||
| b5408eabcd | |||
| e1e34452ae | |||
| df121b40d7 | |||
| 4e737616cb | |||
| fb963781af | |||
| 96339b6a36 |
@@ -13,7 +13,7 @@ jobs:
|
||||
image: 'ubuntu:android-24.04'
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
image: 'ubuntu:android-24.04'
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -112,3 +112,16 @@ Here is a list of the smartphones tested:
|
||||
| Google | Nexus 4 | LineageOS 18.1 | Ok |
|
||||
| Samsung | S4 | Android 11 | Ok |
|
||||
| Sony | Xperia XZ1 Compact | LineageOS 16.1 | Failure |
|
||||
|
||||
## Bosch Smart System eBike
|
||||
|
||||
OpenTracks also supports Bosch Smart System eBikes via Bosch's custom BLE service `00000010-eaa2-11e9-81b4-2a2ae2dbcce4`.
|
||||
|
||||
Tested with:
|
||||
|
||||
* Bosch Perfomance Line CX (SW-Version 16.9.0) / Kiox 300 (SW-Version 16.4.0)
|
||||
|
||||
Currently supported measurements:
|
||||
|
||||
* Human power
|
||||
* Cadence
|
||||
|
||||
+21
-15
@@ -6,7 +6,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:9.1.0'
|
||||
classpath 'com.android.tools.build:gradle:9.2.1'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ allprojects {
|
||||
}
|
||||
|
||||
def getVersionName = { ->
|
||||
return 'git describe --tags'.execute().text.trim()
|
||||
return 'git describe --tags'.execute(null, project.rootDir).text.trim()
|
||||
}
|
||||
|
||||
def getVersionCodeNightly = { ->
|
||||
return Integer.valueOf('git rev-list HEAD --count'.execute().text.trim())
|
||||
return Integer.valueOf('git rev-list HEAD --count'.execute(null, project.rootDir).text.trim())
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -33,7 +33,7 @@ android {
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
@@ -47,13 +47,13 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'de.dennisguse.opentracks'
|
||||
versionCode 6627
|
||||
versionName "v4.26.2"
|
||||
versionCode 6698
|
||||
versionName "v4.28.0"
|
||||
|
||||
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
|
||||
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
targetSdk = 37
|
||||
|
||||
testInstrumentationRunner 'de.dennisguse.opentracks.TestRunner'
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
@@ -107,7 +107,6 @@ android {
|
||||
nightly {
|
||||
dimension 'version'
|
||||
applicationId 'de.dennisguse.opentracks.nightly'
|
||||
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.nightly'
|
||||
|
||||
versionCode getVersionCodeNightly()
|
||||
versionName getVersionName()
|
||||
@@ -119,7 +118,6 @@ android {
|
||||
// Non-reproducible: https://f-droid.org/de/packages/de.dennisguse.opentracks/
|
||||
dimension 'version'
|
||||
applicationId 'de.dennisguse.opentracks'
|
||||
resValue 'string', 'applicationId', 'de.dennisguse.opentracks'
|
||||
versionNameSuffix 'irreproducible'
|
||||
}
|
||||
reproducible {
|
||||
@@ -128,7 +126,6 @@ android {
|
||||
// PlayStore: https://play.google.com/store/apps/details?id=de.dennisguse.opentracks.playstore
|
||||
dimension 'version'
|
||||
applicationId 'de.dennisguse.opentracks.playstore'
|
||||
resValue 'string', 'applicationId', 'de.dennisguse.opentracks.playstore'
|
||||
|
||||
base.archivesName = "de.dennisguse.opentracks.playstore_${getVersionName()}_${android.defaultConfig.versionCode}"
|
||||
|
||||
@@ -152,20 +149,29 @@ dependencies {
|
||||
implementation 'androidx.preference:preference:1.2.1'
|
||||
implementation 'androidx.documentfile:documentfile:1.1.0'
|
||||
implementation 'androidx.gridlayout:gridlayout:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'com.google.android.material:material:1.14.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation 'androidx.core:core:1.18.0'
|
||||
implementation 'androidx.core:core:1.19.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.2.0'
|
||||
implementation 'androidx.mediarouter:mediarouter:1.8.1'
|
||||
implementation 'androidx.core:core-location-altitude:1.0.0-rc01'
|
||||
implementation 'androidx.work:work-runtime:2.11.1'
|
||||
implementation 'androidx.core:core-location-altitude:1.0.0'
|
||||
implementation 'androidx.work:work-runtime:2.11.2'
|
||||
implementation 'androidx.health.connect:connect-client:1.1.0'
|
||||
|
||||
androidTestImplementation 'androidx.test:core:1.7.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.7.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||
androidTestImplementation 'org.mockito:mockito-android:5.23.0'
|
||||
|
||||
androidTestUtil 'androidx.test:orchestrator:1.6.1'
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
variant.resValues.put(
|
||||
new com.android.build.api.variant.impl.ResValueKeyImpl("string", "applicationId"),
|
||||
new com.android.build.api.variant.ResValue(variant.applicationId.get(), null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
v4.27.0: OpenTracks
|
||||
|
||||
Changes:
|
||||
- Sensor: power from Bosch eBike
|
||||
- Sensors: include already bonded Bluetooth devices (like Bosch eBike)
|
||||
- Barometer: enforce sampling frequency
|
||||
|
||||
Bugfix:
|
||||
- Dashboard API: did not provide update
|
||||
- GPS could not be enabled with Bluetooth disabled
|
||||
|
||||
Developer:
|
||||
- Removed Mockito
|
||||
- UI tests: less flacky
|
||||
@@ -0,0 +1,11 @@
|
||||
v4.27.1: OpenTracks
|
||||
|
||||
Changes:
|
||||
- Support for Bosch Smart System eBikes (cadence, human power)
|
||||
- Add activity "gravel biking"
|
||||
|
||||
Bugfix:
|
||||
- While recording: TrackRecordingService would not restart correctly if killed by Android
|
||||
|
||||
Developer:
|
||||
- TargetSDK 37
|
||||
@@ -0,0 +1,5 @@
|
||||
v4.27.2: OpenTracks
|
||||
|
||||
Bugfix:
|
||||
- Not all valid timezone offsets could be set
|
||||
- Fixed race condition for IDLE trackpoints
|
||||
@@ -0,0 +1,10 @@
|
||||
v4.28.0: OpenTracks
|
||||
|
||||
Changes:
|
||||
- Support for exporting to Health Connect
|
||||
|
||||
Bugfix:
|
||||
- Dashboard API: when sharing multiple tracks, the currently recorded one wasn't updated
|
||||
- Configuration setting for announcement of lap power wasn't stored correctly (only some languages affected)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
v3.0.1: OpenTracks
|
||||
|
||||
Cambios:
|
||||
- Compartir marcadores
|
||||
- Configuración reestructurada
|
||||
- Se ha eliminado el botón «Atrás» de la barra de acciones
|
||||
- Exportación: se ha eliminado la opción de configuración del tamaño de las fotos
|
||||
- Exportación: ahora se puede seleccionar el formato KML
|
||||
|
||||
Correcciones de errores:
|
||||
- El directorio de exportación es ahora /OpenTracks/
|
||||
- El modo horizontal ya no provoca fallos
|
||||
- Se ha corregido la exportación a KML/KMZ
|
||||
|
||||
Desarrollador:
|
||||
- Las pruebas son ejecutables (pero muchas fallan)
|
||||
- Se ha habilitado la integración continua con TravisCI (sin pruebas)
|
||||
@@ -0,0 +1,14 @@
|
||||
v4.27.0: OpenTracks
|
||||
|
||||
Cambios:
|
||||
- Sensor: potencia de la bicicleta eléctrica Bosch
|
||||
- Sensores: se incluyen los dispositivos Bluetooth ya emparejados (como la bicicleta eléctrica Bosch)
|
||||
- Barómetro: se aplica la frecuencia de muestreo
|
||||
|
||||
Corrección de errores:
|
||||
- API del panel de control: no se proporcionaba la actualización
|
||||
- No se podía activar el GPS con el Bluetooth desactivado
|
||||
|
||||
Desarrollador:
|
||||
- Se ha eliminado Mockito
|
||||
- Pruebas de la interfaz de usuario: menos inestables
|
||||
@@ -0,0 +1,5 @@
|
||||
v4.26.2: OpenTracks
|
||||
|
||||
Arranxos:
|
||||
- (aínda) non hai compatibilidade co GPS vía Bluetooth
|
||||
- non se mostraba a altitude tipo (EGM2008)
|
||||
@@ -0,0 +1,5 @@
|
||||
v4.26.2: OpenTracks
|
||||
|
||||
Correzioni:
|
||||
- Il GPS via Bluetooth non è (ancora) supportato
|
||||
- Il tipo di altitudine (EGM2008) non viene mostrato
|
||||
@@ -1,11 +1,22 @@
|
||||
OpenTracks é um aplicativo de rastreamento de esportes que respeita totalmente a sua privacidade.
|
||||
Um companheiro de rastreamento esportivo que respeita sua privacidade.
|
||||
|
||||
Características:
|
||||
* Rastreamento de esportes: monitore seu esporte e atividades ao ar livre
|
||||
* Fotos e marcadores: marque locais interessantes durante o rastreamento
|
||||
* Ganho e perda de altitude: via sensor barométrico
|
||||
* Altitude mostrada no EGM2008 (acima do nível médio do mar); exportado como WGS84
|
||||
* Anúncios de voz
|
||||
* Suporta sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medidor de potência (ciclismo)
|
||||
* Exportar dados: exporte trilhas como KMZ (incluindo fotos), KML, ou GPX
|
||||
* Sem acesso à Internet: a Internet não é usada
|
||||
Esportes e atividades ao ar livre garantem felicidade, habilidades e confiança.
|
||||
|
||||
Valoriza sua saúde acompanhando os seus treinos.
|
||||
Grava enquanto você corre ou caminha e te entrega um computador de bicicleta com uma tela maior para o ciclismo.
|
||||
Marca locais interessantes durante o percurso com fotos.
|
||||
Deixa estatísticas registradas em grande detalhe para analises.
|
||||
Compartilha apenas os dados que você quer que outros tenham.
|
||||
|
||||
* Anúncios por voz.
|
||||
* Suporte para sensores Bluetooth LE: frequência cardíaca, velocidade e distância (ciclismo), cadência (ciclismo) e medição de potência (ciclismo).
|
||||
* Ganhos e perdas de altitude: via sensor barométrico.
|
||||
* Altitudes exibidas em EGM2008 (acima do nível médio do mar); exportado como WGS84.
|
||||
* Exporta os dados de rastreio como KMZ (inclusive fotos), KML ou GPX.
|
||||
* Sem acesso à internet ou permissões extra.
|
||||
* Tema claro e escuro, respeitando as configurações do sistema.
|
||||
* Sem propagandas.
|
||||
|
||||
Software Livre / Código Aberto
|
||||
Significa que você pode usar, estudar, mudar e compartilhar o código.
|
||||
Licença Apache 2.0
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<html>
|
||||
<body>
|
||||
<h1>OpenTracks: Privacy Policy</h1>
|
||||
|
||||
<p>
|
||||
OpenTracks does only store data on the local device that is relevant for tracking your sport exercise.
|
||||
Stored data is not transmitted from the app itself to a third party.
|
||||
|
||||
Data is stored by OpenTracks until:
|
||||
1. the user deletes the recorded track or
|
||||
2. the user uninstalls OpenTracks
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,76 +2,117 @@ package de.dennisguse.opentracks;
|
||||
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
import static androidx.test.espresso.action.ViewActions.longClick;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
|
||||
import static androidx.test.espresso.assertion.ViewAssertions.matches;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withId;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withText;
|
||||
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
|
||||
import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount;
|
||||
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.renameStoppedTrack;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
|
||||
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.GrantPermissionRule;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@LargeTest
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class EspressoDeleteTrackTest {
|
||||
|
||||
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
@Rule
|
||||
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
|
||||
|
||||
@Rule
|
||||
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
|
||||
|
||||
@Test
|
||||
public void espressoDeleteTrackTest() {
|
||||
// TrackListActivity: start recording
|
||||
onView(withId(R.id.track_list_fab_action))
|
||||
.perform(click());
|
||||
|
||||
// TrackRecordingActivity
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
// wait; stay recording
|
||||
.perform(waitFor(5000))
|
||||
// stop;
|
||||
.perform(longClick());
|
||||
|
||||
// TrackStoppedActivity
|
||||
onView(withId(R.id.finish_button))
|
||||
.perform(click());
|
||||
|
||||
// select track
|
||||
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0)))
|
||||
.perform(longClick());
|
||||
|
||||
// open menu and delete selected track
|
||||
onView(
|
||||
allOf(withContentDescription("More options"),
|
||||
childAtPosition(
|
||||
childAtPosition(
|
||||
withId(androidx.appcompat.R.id.action_mode_bar),
|
||||
1),
|
||||
2),
|
||||
isDisplayed()))
|
||||
.perform(click());
|
||||
|
||||
onView(withText("Delete"))
|
||||
.perform(click());
|
||||
|
||||
onView(withText("OK"))
|
||||
.perform(click());
|
||||
|
||||
// tracklist is empty now
|
||||
onView(allOf(withText("Start recording your next adventure here"), isDisplayed()));
|
||||
@Before
|
||||
public void setUp() {
|
||||
resetTrackRecordingServiceAndDeleteTracks();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deleteTrackFromTrackStoppedActivity() {
|
||||
recordTrackAndOpenTrackStoppedActivity();
|
||||
|
||||
// TrackStoppedActivity: discard the just-recorded track directly
|
||||
onView(withId(R.id.discard_button))
|
||||
.perform(performClick());
|
||||
onView(withText(android.R.string.ok))
|
||||
.inRoot(isDialog())
|
||||
.perform(performClick());
|
||||
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
|
||||
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
|
||||
|
||||
// the recycler is empty again after discarding the only recorded track
|
||||
onView(withId(R.id.track_list))
|
||||
.check(matches(withRecyclerViewItemCount(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deleteTrackFromSelectedTrackInTrackList() {
|
||||
String uniqueTrackName = "DeleteFromTrackList-" + System.currentTimeMillis();
|
||||
|
||||
recordTrackAndOpenTrackStoppedActivity();
|
||||
renameStoppedTrack(uniqueTrackName, UI_TIMEOUT);
|
||||
|
||||
onView(withId(R.id.finish_button))
|
||||
.perform(performClick());
|
||||
waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT);
|
||||
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
|
||||
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1, UI_TIMEOUT);
|
||||
|
||||
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
|
||||
.perform(performLongClickOnAncestor());
|
||||
|
||||
clickDeleteSelectedTrackAction();
|
||||
onView(withText(android.R.string.ok))
|
||||
.inRoot(isDialog())
|
||||
.perform(performClick());
|
||||
waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT);
|
||||
|
||||
onView(withId(R.id.track_list))
|
||||
.check(matches(withRecyclerViewItemCount(0)));
|
||||
}
|
||||
|
||||
private void recordTrackAndOpenTrackStoppedActivity() {
|
||||
startRecordingFromTrackList(UI_TIMEOUT);
|
||||
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.discard_button, R.id.finish_button);
|
||||
}
|
||||
|
||||
private void clickDeleteSelectedTrackAction() {
|
||||
try {
|
||||
waitUntilDisplayed(withId(R.id.list_context_menu_delete), UI_TIMEOUT);
|
||||
onView(withId(R.id.list_context_menu_delete))
|
||||
.perform(performClick());
|
||||
} catch (AssertionError ignored) {
|
||||
onView(allOf(
|
||||
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
|
||||
isDescendantOfA(withId(androidx.appcompat.R.id.action_mode_bar))))
|
||||
.perform(performClick());
|
||||
onView(withText(R.string.menu_delete))
|
||||
.inRoot(isPlatformPopup())
|
||||
.perform(performClickOnAncestor());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,34 +2,43 @@ package de.dennisguse.opentracks;
|
||||
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
|
||||
import static androidx.test.espresso.action.ViewActions.longClick;
|
||||
import static androidx.test.espresso.action.ViewActions.replaceText;
|
||||
import static androidx.test.espresso.action.ViewActions.scrollTo;
|
||||
import static androidx.test.espresso.assertion.ViewAssertions.matches;
|
||||
import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withId;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withText;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
|
||||
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
|
||||
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.GrantPermissionRule;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
|
||||
@LargeTest
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class EspressoEditTrackRecordingTest {
|
||||
|
||||
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
@Rule
|
||||
public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH);
|
||||
|
||||
@@ -39,27 +48,27 @@ public class EspressoEditTrackRecordingTest {
|
||||
@Rule
|
||||
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
resetTrackRecordingServiceAndDeleteTracks();
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
@Test
|
||||
public void espressoEditTrackRecordingTest() {
|
||||
{
|
||||
// TrackListActivity: start recording
|
||||
onView(withId(R.id.track_list_fab_action))
|
||||
.perform(click());
|
||||
}
|
||||
startRecordingFromTrackList(UI_TIMEOUT);
|
||||
{
|
||||
// TrackRecordingActivity
|
||||
|
||||
// wait; stay recording
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
.perform(waitFor(4000));
|
||||
|
||||
// open menu
|
||||
openContextualActionModeOverflowMenu();
|
||||
|
||||
// Click the item.
|
||||
onView(allOf(
|
||||
withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description),
|
||||
isDescendantOfA(withId(R.id.bottom_app_bar))))
|
||||
.perform(performClick());
|
||||
onView(withText(R.string.menu_edit))
|
||||
.perform(click());
|
||||
.inRoot(isPlatformPopup())
|
||||
.perform(performClickOnAncestor());
|
||||
waitUntilDisplayed(withId(R.id.track_edit_name), UI_TIMEOUT);
|
||||
|
||||
// change name for "New Name"
|
||||
onView(withId(R.id.track_edit_name))
|
||||
@@ -70,11 +79,11 @@ public class EspressoEditTrackRecordingTest {
|
||||
|
||||
// save edition
|
||||
onView(withId(R.id.track_edit_save))
|
||||
.perform(click());
|
||||
.perform(performClick());
|
||||
waitUntilDisplayed(withId(R.id.track_recording_fab_action), UI_TIMEOUT);
|
||||
|
||||
// stop;
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
.perform(longClick());
|
||||
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button);
|
||||
|
||||
// it's on track stopped activity and there are two buttons
|
||||
onView(withId(R.id.resume_button))
|
||||
|
||||
@@ -1,100 +1,142 @@
|
||||
package de.dennisguse.opentracks;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
import static androidx.test.espresso.action.ViewActions.longClick;
|
||||
import static androidx.test.espresso.assertion.ViewAssertions.matches;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withId;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
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.waitFor;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.withSelectedTab;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.withViewPagerCurrentItem;
|
||||
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.finishStoppedTrack;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.resumeStoppedTrack;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList;
|
||||
import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
|
||||
import 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 {
|
||||
|
||||
@Rule
|
||||
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
|
||||
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final ActivityScenarioRule<TrackListActivity> activityRule = new ActivityScenarioRule<>(TrackListActivity.class);
|
||||
private final GrantPermissionRule grantPermissionRule = TestUtil.createGrantPermissionRule();
|
||||
|
||||
@Rule
|
||||
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
|
||||
public RuleChain rules = RuleChain.outerRule(grantPermissionRule)
|
||||
.around(activityRule);
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
resetTrackRecordingServiceAndDeleteTracks();
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
@Test
|
||||
public void record_stop_resume_stop_finish() {
|
||||
startRecordingFromTrackList(UI_TIMEOUT);
|
||||
{
|
||||
// TrackListActivity: start recording
|
||||
onView(withId(R.id.track_list_fab_action))
|
||||
.perform(click());
|
||||
}
|
||||
{
|
||||
// TrackRecordingActivity: wait to record some time and then stop
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
.perform(waitFor(5000))
|
||||
.perform(longClick());
|
||||
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button);
|
||||
resumeStoppedTrack(UI_TIMEOUT);
|
||||
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button);
|
||||
finishStoppedTrack(UI_TIMEOUT);
|
||||
|
||||
// TrackStoppedActivity: resume
|
||||
onView(allOf(withId(R.id.resume_button), isClickable()))
|
||||
.perform(click());
|
||||
|
||||
// TrackRecordingActivity: wait and then stop
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
.perform(waitFor(5000))
|
||||
.perform(longClick());
|
||||
|
||||
// TrackStoppedActivity
|
||||
onView(withId(R.id.finish_button))
|
||||
.perform(click());
|
||||
onView(withId(R.id.track_list))
|
||||
.check(matches(isDisplayed()));
|
||||
}
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
@Test
|
||||
public void record_move_through_tabs() {
|
||||
{
|
||||
// TrackListActivity: start recording
|
||||
onView(withId(R.id.track_list_fab_action))
|
||||
.perform(click());
|
||||
}
|
||||
startRecordingFromTrackList(UI_TIMEOUT);
|
||||
waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout), UI_TIMEOUT);
|
||||
waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager), UI_TIMEOUT);
|
||||
{
|
||||
// TrackRecordingActivity
|
||||
ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout));
|
||||
|
||||
tabLayout.perform(selectTabAtIndex(1));
|
||||
tabLayout.perform(waitFor(1000));
|
||||
waitUntilTabSelectionSettlesAt(1);
|
||||
|
||||
tabLayout.perform(selectTabAtIndex(2));
|
||||
tabLayout.perform(waitFor(1000));
|
||||
waitUntilTabSelectionSettlesAt(2);
|
||||
|
||||
tabLayout.perform(selectTabAtIndex(3));
|
||||
tabLayout.perform(waitFor(1000));
|
||||
waitUntilTabSelectionSettlesAt(3);
|
||||
|
||||
tabLayout.perform(selectTabAtIndex(0));
|
||||
tabLayout.perform(waitFor(1000));
|
||||
waitUntilTabSelectionSettlesAt(0);
|
||||
|
||||
// stop
|
||||
onView(withId(R.id.track_recording_fab_action)).
|
||||
perform(longClick());
|
||||
stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button);
|
||||
|
||||
onView(withId(R.id.resume_button))
|
||||
.check(matches(isDisplayed()));
|
||||
onView(withId(R.id.finish_button))
|
||||
.check(matches(isDisplayed()));
|
||||
}
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
@Test
|
||||
public void selectAndDeleteTrack() {
|
||||
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 5);
|
||||
String uniqueTrackName = "SelectAndDeleteTrack-" + System.currentTimeMillis();
|
||||
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
|
||||
trackBuilder.setName(uniqueTrackName);
|
||||
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
|
||||
activityRule.getScenario().recreate();
|
||||
waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT);
|
||||
|
||||
onView(withId(R.id.track_list)).check(matches(isDisplayed()));
|
||||
onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))).perform(longClick());
|
||||
onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)))
|
||||
.perform(performLongClickOnAncestor());
|
||||
|
||||
onView(withId(androidx.appcompat.R.id.action_mode_bar))
|
||||
.check(matches(isDisplayed()));
|
||||
}
|
||||
|
||||
private void waitUntilTabSelectionSettlesAt(int index) {
|
||||
waitForResumedViewCondition(
|
||||
withId(R.id.track_detail_activity_tablayout),
|
||||
view -> withSelectedTab(index).matches(view),
|
||||
"selected tab to be " + index,
|
||||
UI_TIMEOUT);
|
||||
waitForResumedViewCondition(
|
||||
withId(R.id.track_detail_activity_view_pager),
|
||||
view -> withViewPagerCurrentItem(index).matches(view),
|
||||
"ViewPager current item to be " + index,
|
||||
UI_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
+62
-46
@@ -20,22 +20,19 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -78,7 +75,6 @@ import de.dennisguse.opentracks.util.FileUtils;
|
||||
* @author Bartlomiej Niechwiej
|
||||
* @author Youtao Liu
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class CustomContentProviderUtilsTest {
|
||||
|
||||
private static final String NAME_PREFIX = "test name";
|
||||
@@ -90,12 +86,6 @@ public class CustomContentProviderUtilsTest {
|
||||
private final Context context = ApplicationProvider.getApplicationContext();
|
||||
private ContentProviderUtils contentProviderUtils;
|
||||
|
||||
@Mock
|
||||
private Cursor cursorMock;
|
||||
|
||||
@Mock
|
||||
private ContentResolver contentResolverMock;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
contentProviderUtils = new ContentProviderUtils(context);
|
||||
@@ -104,21 +94,21 @@ public class CustomContentProviderUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testLocationIterator_noPoints() {
|
||||
testIterator(new Track.Id(1), 0);
|
||||
assertEquals(0, testIterator(new Track.Id(1), 0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLocationIterator_noAscending() {
|
||||
testIterator(new Track.Id(1), 50);
|
||||
testIterator(new Track.Id(2), 50);
|
||||
assertEquals(50, testIterator(new Track.Id(1), 50).size());
|
||||
assertEquals(50, testIterator(new Track.Id(2), 50).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLocationIterator_largeTrack() {
|
||||
testIterator(new Track.Id(1), 20000 / 2);
|
||||
assertEquals(20000 / 2, testIterator(new Track.Id(1), 20000 / 2).size());
|
||||
}
|
||||
|
||||
private void testIterator(Track.Id trackId, int numPoints) {
|
||||
private List<TrackPoint> testIterator(Track.Id trackId, int numPoints) {
|
||||
TrackPoint.Id lastPointId = initializeTrack(trackId, numPoints);
|
||||
List<TrackPoint> locations = new ArrayList<>(numPoints);
|
||||
try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) {
|
||||
@@ -131,6 +121,7 @@ public class CustomContentProviderUtilsTest {
|
||||
}
|
||||
assertEquals(numPoints, locations.size());
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) {
|
||||
@@ -187,27 +178,49 @@ public class CustomContentProviderUtilsTest {
|
||||
@Test
|
||||
public void testCreateTrack() {
|
||||
Track.Id trackId = new Track.Id(System.currentTimeMillis());
|
||||
|
||||
int columnIndex = 1;
|
||||
// Id
|
||||
when(cursorMock.getColumnIndexOrThrow(TracksColumns._ID)).thenReturn(columnIndex);
|
||||
when(cursorMock.isNull(columnIndex)).thenReturn(false);
|
||||
when(cursorMock.getLong(columnIndex)).thenReturn(trackId.id());
|
||||
|
||||
//Uuid
|
||||
columnIndex++;
|
||||
when(cursorMock.getColumnIndexOrThrow(TracksColumns.UUID)).thenReturn(columnIndex);
|
||||
when(cursorMock.isNull(columnIndex)).thenReturn(false);
|
||||
when(cursorMock.getBlob(columnIndex)).thenReturn(UUIDUtils.toBytes(UUID.randomUUID()));
|
||||
|
||||
// Name
|
||||
columnIndex++;
|
||||
String name = NAME_PREFIX + trackId.id();
|
||||
when(cursorMock.getColumnIndexOrThrow(TracksColumns.NAME)).thenReturn(columnIndex);
|
||||
when(cursorMock.isNull(columnIndex)).thenReturn(false);
|
||||
when(cursorMock.getString(columnIndex)).thenReturn(name);
|
||||
|
||||
Track track = ContentProviderUtils.createTrack(cursorMock);
|
||||
MatrixCursor cursor = new MatrixCursor(new String[]{
|
||||
TracksColumns._ID,
|
||||
TracksColumns.UUID,
|
||||
TracksColumns.NAME,
|
||||
TracksColumns.DESCRIPTION,
|
||||
TracksColumns.ACTIVITY_TYPE,
|
||||
TracksColumns.ACTIVITY_TYPE_LOCALIZED,
|
||||
TracksColumns.STARTTIME,
|
||||
TracksColumns.STARTTIME_OFFSET,
|
||||
TracksColumns.STOPTIME,
|
||||
TracksColumns.TOTALDISTANCE,
|
||||
TracksColumns.TOTALTIME,
|
||||
TracksColumns.MOVINGTIME,
|
||||
TracksColumns.MAXSPEED,
|
||||
TracksColumns.MIN_ALTITUDE,
|
||||
TracksColumns.MAX_ALTITUDE,
|
||||
TracksColumns.ALTITUDE_GAIN,
|
||||
TracksColumns.ALTITUDE_LOSS,
|
||||
});
|
||||
cursor.addRow(new Object[]{
|
||||
trackId.id(),
|
||||
UUIDUtils.toBytes(UUID.randomUUID()),
|
||||
name,
|
||||
"",
|
||||
ActivityType.UNKNOWN.getId(),
|
||||
"",
|
||||
1_000L,
|
||||
ZoneOffset.UTC.getTotalSeconds(),
|
||||
2_000L,
|
||||
123.45f,
|
||||
1_000L,
|
||||
500L,
|
||||
12.34f,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
});
|
||||
assertTrue(cursor.moveToFirst());
|
||||
|
||||
Track track = ContentProviderUtils.createTrack(cursor);
|
||||
assertEquals(trackId, track.id());
|
||||
assertEquals(name, track.name());
|
||||
}
|
||||
@@ -449,8 +462,6 @@ public class CustomContentProviderUtilsTest {
|
||||
marker.setDescription(TEST_DESC);
|
||||
contentProviderUtils.insertMarker(marker.getMarker());
|
||||
|
||||
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(contentResolverMock);
|
||||
|
||||
Marker.Id markerId = new Marker.Id(System.currentTimeMillis());
|
||||
marker.setId(markerId);
|
||||
ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId);
|
||||
@@ -1184,7 +1195,7 @@ public class CustomContentProviderUtilsTest {
|
||||
assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0f);
|
||||
}
|
||||
|
||||
private void testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) {
|
||||
private Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) {
|
||||
// given
|
||||
Instant start = Instant.now();
|
||||
TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil();
|
||||
@@ -1207,22 +1218,27 @@ public class CustomContentProviderUtilsTest {
|
||||
SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId);
|
||||
TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats();
|
||||
|
||||
// then
|
||||
assertEquals(sensorStatistics.avgHeartRate().getBPM(), stats.avgHr, 0.01f);
|
||||
assertEquals(sensorStatistics.maxHeartRate().getBPM(), stats.maxHr, 0.01f);
|
||||
assertEquals(sensorStatistics.avgCadence().getRPM(), stats.avgCadence, 0.01f);
|
||||
assertEquals(sensorStatistics.maxCadence().getRPM(), stats.maxCadence, 0.01f);
|
||||
assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0.01f);
|
||||
return Pair.create(sensorStatistics, stats);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSensorStats_veryLongActivity12h() {
|
||||
testGetSensorStats_randomData(43200 / 6, false);
|
||||
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(43200 / 6, false);
|
||||
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
|
||||
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
|
||||
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
|
||||
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
|
||||
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSensorStats_withSeveralRandomStartSegments() {
|
||||
testGetSensorStats_randomData(5000, true);
|
||||
Pair<SensorStatistics, TestSensorDataUtil.SensorDataStats> result = testGetSensorStats_randomData(5000, true);
|
||||
assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f);
|
||||
assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f);
|
||||
assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f);
|
||||
assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f);
|
||||
assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f);
|
||||
}
|
||||
|
||||
private TrackPoint getLastValidTrackPoint(Track.Id trackId) {
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package de.dennisguse.opentracks.sensors.driver;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class BoschEbikeParserTest {
|
||||
|
||||
@Test
|
||||
public void parse_multipleFrames_returnsCombinedMeasurements() {
|
||||
byte[] raw = new byte[] {
|
||||
// Bosch cadence frame
|
||||
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76,
|
||||
// Bosch human power frame
|
||||
0x30, 0x05, (byte) 0x98, 0x5B, 0x08, (byte) 0x83, 0x01,
|
||||
// Bosch speed frame
|
||||
0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0x94, 0x0B, 0x10, 0x01
|
||||
};
|
||||
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(raw);
|
||||
|
||||
assertNotNull(data);
|
||||
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
|
||||
assertEquals(131.0f, data.humanPower().getW(), 0.01f);
|
||||
assertEquals(14.28 / 3.6, data.speed().toMPS(), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_multiByteVarint_returnsScaledSpeed() {
|
||||
// Bosch speed frame using a multi-byte varint
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0xE1, 0x0E, 0x10, 0x01
|
||||
});
|
||||
|
||||
assertNotNull(data);
|
||||
assertEquals(18.89 / 3.6, data.speed().toMPS(), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_zeroValueFrame_returnsZeroCadence() {
|
||||
// Bosch zero-value cadence frame
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x02, (byte) 0x98, 0x5A
|
||||
});
|
||||
|
||||
assertNotNull(data);
|
||||
assertEquals(0.0f, data.cadence().getRPM(), 0.01f);
|
||||
assertNull(data.speed());
|
||||
assertNull(data.humanPower());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_unsupportedMeasurementsOnly_returnsNull() {
|
||||
// Bosch frame for message 0x9865, which the parser does not map to a measurement.
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x02, (byte) 0x98, 0x65
|
||||
});
|
||||
|
||||
assertNull(data);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_unimplementedMeasurementsOnly_returnsNull() {
|
||||
// Bosch motor power frame: message 0x985D with value 0x42, currently ignored.
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x04, (byte) 0x98, 0x5D, 0x08, 0x42
|
||||
});
|
||||
|
||||
assertNull(data);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_malformedVarint_returnsNull() {
|
||||
// Truncated Bosch speed frame: starts like a real 0x982D frame but the varint is incomplete.
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94
|
||||
});
|
||||
|
||||
assertNull(data);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_varintLargerThan32Bit_returnsNull() {
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
0x30, 0x08, (byte) 0x98, 0x2D, 0x08,
|
||||
(byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, 0x01
|
||||
});
|
||||
|
||||
assertNull(data);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_validMeasurementBeforeMalformedFrame_returnsValidMeasurement() {
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
// Bosch cadence frame: message 0x985A with value 0x76
|
||||
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76,
|
||||
// Truncated Bosch speed frame: malformed trailing frame should not discard the valid cadence above.
|
||||
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94
|
||||
});
|
||||
|
||||
assertNotNull(data);
|
||||
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
|
||||
assertNull(data.speed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_malformedVarintDoesNotConsumeNextFrameStartByte() {
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] {
|
||||
// Truncated Bosch speed frame: the varint continuation bit is set, but the frame ends
|
||||
// before the next varint byte. The following frame start byte must not be consumed.
|
||||
0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94,
|
||||
// Bosch cadence frame: message 0x985A with value 0x76
|
||||
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76
|
||||
});
|
||||
|
||||
assertNotNull(data);
|
||||
assertNull(data.speed());
|
||||
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_leadingNoise_ignored() {
|
||||
byte[] raw = new byte[] {
|
||||
// Noise bytes to verify the parser resynchronizes on the Bosch frame start byte 0x30.
|
||||
0x01, 0x02, 0x03,
|
||||
// Bosch cadence frame: message 0x985A with value 0x76
|
||||
0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76
|
||||
};
|
||||
|
||||
BoschEbikeParser.Data data = BoschEbikeParser.parse(raw);
|
||||
|
||||
assertNotNull(data);
|
||||
assertEquals(59.0f, data.cadence().getRPM(), 0.01f);
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -1,6 +1,7 @@
|
||||
package de.dennisguse.opentracks.sensors.driver;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
|
||||
@@ -35,4 +36,27 @@ public class CyclingPowerBluetoothTest {
|
||||
assertEquals(17125, powerCadence.crank().crankRevolutionsTime());
|
||||
}
|
||||
|
||||
}
|
||||
@Test
|
||||
public void parsePayload_boschPower() {
|
||||
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
|
||||
// Bosch human power frame: message 0x985B with value 0x7B => 123 W.
|
||||
characteristic.setValue(new byte[] { 0x30, 0x04, (byte) 0x98, 0x5B, 0x08, 0x7B });
|
||||
|
||||
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
|
||||
|
||||
assertEquals(123, power.power().getW(), 0.01);
|
||||
assertNull(power.crank());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsePayload_boschCadenceOnly_returnsNull() {
|
||||
BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0);
|
||||
// Bosch cadence-only frame: message 0x985A with value 0x01B8 => 220 / 2 = 110 rpm, so power parsing should ignore it.
|
||||
characteristic.setValue(new byte[] { 0x30, 0x05, (byte) 0x98, 0x5A, 0x08, (byte) 0xB8, 0x01 });
|
||||
|
||||
CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic);
|
||||
|
||||
assertNull(power);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+19
@@ -96,6 +96,25 @@ public class SensorDataCyclingTest {
|
||||
assertNull(current.getAggregatedValue(Instant.MIN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compute_cadence_directCadence_withoutPrevious() {
|
||||
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
|
||||
|
||||
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
|
||||
|
||||
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compute_cadence_directCadence_winsOverCrankData() {
|
||||
AggregatorCyclingCadence current = new AggregatorCyclingCadence("", "");
|
||||
|
||||
current.add(Instant.MIN, new CyclingCadenceBluetooth.CrankData(1L, 1024));
|
||||
current.add(Instant.MIN, new CyclingCadenceBluetooth.DirectCadenceData(Cadence.of(92)));
|
||||
|
||||
assertEquals(92, current.getAggregatedValue(Instant.MIN).getRPM(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compute_speed() {
|
||||
AggregatorCyclingDistanceSpeed current = new AggregatorCyclingDistanceSpeed("", "");
|
||||
|
||||
+54
-37
@@ -1,24 +1,23 @@
|
||||
package de.dennisguse.opentracks.services;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.data.models.ActivityType;
|
||||
import de.dennisguse.opentracks.data.models.Altitude;
|
||||
import de.dennisguse.opentracks.data.models.Distance;
|
||||
@@ -29,32 +28,24 @@ import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.data.models.TrackPoint;
|
||||
import de.dennisguse.opentracks.settings.UnitSystem;
|
||||
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class TrackRecordingServiceNotificationManagerTest {
|
||||
|
||||
private static final String CHANNEL_ID = "TrackRecordingServiceNotificationManagerTest";
|
||||
|
||||
private final Context context = ApplicationProvider.getApplicationContext();
|
||||
private final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
|
||||
|
||||
@Mock
|
||||
private NotificationCompat.Builder notificationCompatBuilder;
|
||||
|
||||
@Mock
|
||||
private NotificationManager notificationManager;
|
||||
@After
|
||||
public void tearDown() {
|
||||
notificationManager.cancelAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateLocation_triggersAlertOnlyOnFirstInaccurateLocation() {
|
||||
TrackPoint trackPointMock = new TrackPoint(TrackPoint.Type.TRACKPOINT,
|
||||
new Position(
|
||||
null,
|
||||
null, null, Distance.of(999f),
|
||||
Altitude.WGS84.of(10), null,
|
||||
null,
|
||||
Speed.ZERO)
|
||||
);
|
||||
notificationManager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, CHANNEL_ID, NotificationManager.IMPORTANCE_DEFAULT));
|
||||
|
||||
when(notificationCompatBuilder.setOnlyAlertOnce(anyBoolean()))
|
||||
.thenReturn(notificationCompatBuilder);
|
||||
|
||||
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationCompatBuilder);
|
||||
TrackingNotificationBuilder notificationBuilder = new TrackingNotificationBuilder(context, CHANNEL_ID);
|
||||
TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationBuilder);
|
||||
subject.setUnitSystem(UnitSystem.METRIC);
|
||||
|
||||
Track track = new Track(
|
||||
@@ -68,16 +59,42 @@ public class TrackRecordingServiceNotificationManagerTest {
|
||||
Statistics.DEFAULT
|
||||
);
|
||||
|
||||
RecordingData data = new RecordingData(track, trackPointMock, null, null);
|
||||
|
||||
// when
|
||||
subject.updateTrackPoint(context, data, Distance.of(100));
|
||||
subject.updateTrackPoint(context, data, Distance.of(100));
|
||||
subject.updateTrackPoint(context, data, Distance.of(1000));
|
||||
subject.updateTrackPoint(context, data, Distance.of(100));
|
||||
|
||||
// then
|
||||
verify(notificationCompatBuilder, times(6)).setOnlyAlertOnce(true);
|
||||
verify(notificationCompatBuilder, times(2)).setOnlyAlertOnce(false);
|
||||
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
|
||||
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
|
||||
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(1000));
|
||||
subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100));
|
||||
assertEquals(java.util.Arrays.asList(false, true, true, true, true, true, false, true), notificationBuilder.onlyAlertOnceValues);
|
||||
}
|
||||
}
|
||||
|
||||
private RecordingData createRecordingData(Track track, Distance horizontalAccuracy) {
|
||||
TrackPoint trackPoint = new TrackPoint(
|
||||
TrackPoint.Type.TRACKPOINT,
|
||||
new Position(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
horizontalAccuracy,
|
||||
Altitude.WGS84.of(10),
|
||||
null,
|
||||
null,
|
||||
Speed.ZERO
|
||||
)
|
||||
);
|
||||
return new RecordingData(track, trackPoint, null, null);
|
||||
}
|
||||
|
||||
private static final class TrackingNotificationBuilder extends NotificationCompat.Builder {
|
||||
private final List<Boolean> onlyAlertOnceValues = new ArrayList<>();
|
||||
|
||||
private TrackingNotificationBuilder(Context context, String channelId) {
|
||||
super(context, channelId);
|
||||
setSmallIcon(R.drawable.ic_logo_color_24dp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationCompat.Builder setOnlyAlertOnce(boolean onlyAlertOnce) {
|
||||
onlyAlertOnceValues.add(onlyAlertOnce);
|
||||
return super.setOnlyAlertOnce(onlyAlertOnce);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -2,6 +2,7 @@ package de.dennisguse.opentracks.services;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -93,6 +94,41 @@ public class TrackRecordingServiceRecordingTest {
|
||||
service = startService();
|
||||
}
|
||||
|
||||
@MediumTest
|
||||
@Test
|
||||
public void reCreate_not_recording() {
|
||||
// given
|
||||
TrackIdStorage.unset(context);
|
||||
|
||||
assertFalse(service.isRecording());
|
||||
|
||||
// when
|
||||
service.onStartCommand(null, 0, 1);
|
||||
|
||||
// then
|
||||
assertFalse(service.isRecording());
|
||||
}
|
||||
|
||||
@MediumTest
|
||||
@Test
|
||||
public void reCreate_recording() {
|
||||
// given
|
||||
assertFalse(service.isRecording());
|
||||
Track.Id trackId = service.startNewTrack();
|
||||
assertTrue(service.isRecording());
|
||||
|
||||
// when (crash and restart)
|
||||
service.onDestroy();
|
||||
service.onCreate();
|
||||
service.onStartCommand(null, 0, 1);
|
||||
|
||||
// then
|
||||
assertTrue(service.isRecording());
|
||||
assertEquals(trackId, service.getRecordingStatusObservable().getValue().trackId());
|
||||
|
||||
service.endCurrentTrack();
|
||||
}
|
||||
|
||||
@MediumTest
|
||||
@Test
|
||||
public void recording_startStop() {
|
||||
|
||||
+38
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package de.dennisguse.opentracks.services;
|
||||
|
||||
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
@@ -41,6 +42,7 @@ import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -56,6 +58,8 @@ import de.dennisguse.opentracks.sensors.GpsStatusValue;
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
@Rule
|
||||
public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS);
|
||||
|
||||
@@ -123,6 +127,7 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
// when
|
||||
service.tryStartSensors();
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
|
||||
|
||||
// then
|
||||
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
|
||||
@@ -131,6 +136,7 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
// when
|
||||
service.stopSensors();
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
|
||||
|
||||
// then
|
||||
assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue());
|
||||
@@ -146,6 +152,9 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
// when
|
||||
Track.Id trackId = service.startNewTrack();
|
||||
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
|
||||
waitUntilRecordingDataIsNotDefault();
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
|
||||
|
||||
// then
|
||||
assertTrue(service.isRecording());
|
||||
@@ -154,9 +163,13 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue());
|
||||
|
||||
service.endCurrentTrack();
|
||||
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
|
||||
|
||||
// when
|
||||
service.resumeTrack(trackId);
|
||||
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
|
||||
|
||||
// then
|
||||
assertTrue(service.isRecording());
|
||||
@@ -167,6 +180,8 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
// when
|
||||
service.endCurrentTrack();
|
||||
waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT);
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE);
|
||||
|
||||
// then
|
||||
assertFalse(service.isRecording());
|
||||
@@ -177,6 +192,8 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
|
||||
// when
|
||||
service.resumeTrack(trackId);
|
||||
waitUntilRecordingStatusIs(new RecordingStatus(trackId));
|
||||
waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED);
|
||||
|
||||
// then
|
||||
assertTrue(service.isRecording());
|
||||
@@ -254,4 +271,25 @@ public class TrackRecordingServiceStateMachineTest {
|
||||
assertNotNull(trackId);
|
||||
assertNull(newTrackId);
|
||||
}
|
||||
|
||||
private void waitUntilRecordingDataIsNotDefault() {
|
||||
waitForValue(service.getRecordingDataObservable(),
|
||||
recordingData -> !RecordingData.NOT_RECORDING.equals(recordingData),
|
||||
WAIT_TIMEOUT,
|
||||
"recording data to leave NOT_RECORDING");
|
||||
}
|
||||
|
||||
private void waitUntilGpsStatusIs(GpsStatusValue expected) {
|
||||
waitForValue(service.getGpsStatusObservable(),
|
||||
expected::equals,
|
||||
WAIT_TIMEOUT,
|
||||
"gps status to become " + expected);
|
||||
}
|
||||
|
||||
private void waitUntilRecordingStatusIs(RecordingStatus expected) {
|
||||
waitForValue(service.getRecordingStatusObservable(),
|
||||
expected::equals,
|
||||
WAIT_TIMEOUT,
|
||||
"recording status to become " + expected);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-21
@@ -2,12 +2,12 @@ package de.dennisguse.opentracks.services;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static de.dennisguse.opentracks.services.TrackRecordingService.RECORDING_DATA_UPDATE_INTERVAL;
|
||||
import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.core.location.LocationManagerCompat;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.rule.GrantPermissionRule;
|
||||
@@ -30,6 +30,7 @@ import de.dennisguse.opentracks.data.models.Speed;
|
||||
import de.dennisguse.opentracks.data.models.TrackPoint;
|
||||
import de.dennisguse.opentracks.io.file.importer.ExportImportTest;
|
||||
import de.dennisguse.opentracks.io.file.importer.TrackPointAssert;
|
||||
import de.dennisguse.opentracks.sensors.GpsHandler;
|
||||
import de.dennisguse.opentracks.sensors.driver.GpsInternal;
|
||||
import de.dennisguse.opentracks.services.handlers.TrackPointCreator;
|
||||
|
||||
@@ -51,7 +52,7 @@ public class TrackRecordingServiceUITest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getDataForUI_gps() throws TimeoutException, InterruptedException {
|
||||
public void getDataForUI_gps() throws TimeoutException {
|
||||
TrackRecordingService service = ((TrackRecordingService.Binder) mServiceRule.bindService(new Intent(context, TrackRecordingService.class)))
|
||||
.getService();
|
||||
RecordingData recordingData;
|
||||
@@ -63,34 +64,23 @@ public class TrackRecordingServiceUITest {
|
||||
|
||||
// when
|
||||
service.startNewTrack();
|
||||
//We do not want the real GPS to interfere.
|
||||
GpsInternal gpsInternal = (GpsInternal)trackPointCreator.getSensorManager().getGpsHandler().driver;
|
||||
LocationManagerCompat.removeUpdates(gpsInternal.locationManager, gpsInternal.locationListenerCompat);
|
||||
Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis());
|
||||
disconnectRealGps(trackPointCreator.getSensorManager().getGpsHandler());
|
||||
trackPointCreator.getSensorManager().reset();
|
||||
RecordingData initialRecordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:02Z"), false);
|
||||
|
||||
// then
|
||||
assertEquals(new TrackPoint(
|
||||
null,
|
||||
TrackPoint.Type.TRACKPOINT,
|
||||
Position.of( Instant.parse("2020-02-02T02:02:02Z")),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
),
|
||||
service.getRecordingDataObservable().getValue().latestTrackPoint());
|
||||
TrackPoint initialTrackPoint = initialRecordingData.latestTrackPoint();
|
||||
assertEquals(TrackPoint.Type.TRACKPOINT, initialTrackPoint.type());
|
||||
assertEquals(Instant.parse("2020-02-02T02:02:02Z"), initialTrackPoint.getTime());
|
||||
|
||||
|
||||
// when
|
||||
// In this step, the data is saved (and Aggregator.resetAggregated called), but data should still be shown in the UI.
|
||||
trackPointCreator.setClock("2020-02-02T02:02:03Z");
|
||||
ExportImportTest.sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f);
|
||||
recordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:03Z"), true);
|
||||
|
||||
// then
|
||||
Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis());
|
||||
recordingData = service.getRecordingDataObservable().getValue();
|
||||
new TrackPointAssert().assertEquals(new TrackPoint(
|
||||
null,
|
||||
TrackPoint.Type.TRACKPOINT,
|
||||
@@ -109,4 +99,23 @@ public class TrackRecordingServiceUITest {
|
||||
),
|
||||
recordingData.latestTrackPoint());
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnectRealGps(GpsHandler gpsHandler) {
|
||||
if (gpsHandler.driver != null) {
|
||||
gpsHandler.driver.disconnect();
|
||||
}
|
||||
gpsHandler.onConnected(null, GpsInternal.LOCATION_PROVIDER);
|
||||
}
|
||||
|
||||
private RecordingData waitUntilLatestTrackPointMatches(TrackRecordingService service, Instant expectedTime, boolean requiresLocation) {
|
||||
return waitForValue(service.getRecordingDataObservable(),
|
||||
recordingData -> {
|
||||
TrackPoint latestTrackPoint = recordingData != null ? recordingData.latestTrackPoint() : null;
|
||||
return latestTrackPoint != null
|
||||
&& expectedTime.equals(latestTrackPoint.getTime())
|
||||
&& (!requiresLocation || latestTrackPoint.position().hasLocation());
|
||||
},
|
||||
RECORDING_DATA_UPDATE_INTERVAL.multipliedBy(3),
|
||||
"latest track point to reach " + expectedTime + (requiresLocation ? " with location" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
+47
-2
@@ -52,6 +52,7 @@ public class VoiceAnnouncementUtilsTest {
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
PreferencesUtils.setVoiceAnnounceUnit(true);
|
||||
PreferencesUtils.setVoiceAnnounceHeartRateCurrent(false);
|
||||
PreferencesUtils.setVoiceAnnounceLapHeartRate(false);
|
||||
PreferencesUtils.setVoiceAnnounceAverageHeartRate(false);
|
||||
@@ -221,6 +222,48 @@ public class VoiceAnnouncementUtilsTest {
|
||||
assertEquals("12:00 AM. Total distance 19.9 kilometers. 1 hour. Average moving speed 19.9 kilometers per hour.", announcement);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAnnouncement_metric_distance_without_unit() {
|
||||
PreferencesUtils.setVoiceAnnounceUnit(false);
|
||||
|
||||
Track track = new Track(
|
||||
null,
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
ActivityType.AIRPLANE,
|
||||
ZoneOffset.UTC,
|
||||
new Statistics(
|
||||
Instant.EPOCH,
|
||||
Instant.EPOCH,
|
||||
Duration.ofHours(2).plusMinutes(5).plusSeconds(10),
|
||||
Duration.ofHours(1),
|
||||
Distance.of(19900),
|
||||
Speed.of(100),
|
||||
null,
|
||||
new AltitudeGainLoss(6000, 6000),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
SensorDataSet dataSet = new SensorDataSet(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new SensorData<>(HeartRate.of(60), "unused"),
|
||||
null, null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// when
|
||||
String announcement = VoiceAnnouncementUtils.createStatistics(context, track, dataSet, UnitSystem.METRIC, true, null, null).toString();
|
||||
|
||||
// then
|
||||
assertEquals("12:00 AM. Total distance 19.9. 1 hour. Average moving speed 19.9.", announcement);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAnnouncement_withInterval_metric_speed() {
|
||||
// given
|
||||
@@ -556,6 +599,7 @@ public class VoiceAnnouncementUtilsTest {
|
||||
PreferencesUtils.setVoiceAnnounceLapHeartRate(true);
|
||||
|
||||
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
|
||||
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
|
||||
|
||||
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
|
||||
|
||||
@@ -573,7 +617,7 @@ public class VoiceAnnouncementUtilsTest {
|
||||
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
|
||||
|
||||
// then
|
||||
assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
|
||||
assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -589,6 +633,7 @@ public class VoiceAnnouncementUtilsTest {
|
||||
PreferencesUtils.setVoiceAnnounceLapSpeedPace(false);
|
||||
|
||||
Pair<Track, Statistics> data = buildTrackWithTrackPoints();
|
||||
int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM());
|
||||
|
||||
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null);
|
||||
|
||||
@@ -606,7 +651,7 @@ public class VoiceAnnouncementUtilsTest {
|
||||
String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString();
|
||||
|
||||
// then
|
||||
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement);
|
||||
assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.dennisguse.opentracks.settings;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import de.dennisguse.opentracks.BuildConfig;
|
||||
import de.dennisguse.opentracks.R;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ImportExportSettingsIntentTest {
|
||||
|
||||
private final Context context = ApplicationProvider.getApplicationContext();
|
||||
|
||||
@Test
|
||||
public void applicationIdResource_matchesInstalledPackage() {
|
||||
assertEquals(BuildConfig.APPLICATION_ID, context.getString(R.string.applicationId));
|
||||
assertEquals(context.getPackageName(), context.getString(R.string.applicationId));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void importExportPreferenceTargets_areResolvableInCurrentPackage() throws PackageManager.NameNotFoundException {
|
||||
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity");
|
||||
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserActivity");
|
||||
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$ExportDirectoryChooserOneFileActivity");
|
||||
assertResolvable("de.dennisguse.opentracks.io.file.importer.DirectoryChooserActivity$DefaultTrackExportDirectoryChooserActivity");
|
||||
}
|
||||
|
||||
private void assertResolvable(String activityClassName) throws PackageManager.NameNotFoundException {
|
||||
String applicationId = context.getString(R.string.applicationId);
|
||||
ComponentName componentName = new ComponentName(applicationId, activityClassName);
|
||||
|
||||
ActivityInfo activityInfo = context.getPackageManager().getActivityInfo(componentName, 0);
|
||||
assertEquals(applicationId, activityInfo.packageName);
|
||||
|
||||
ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(new Intent().setComponent(componentName), 0);
|
||||
assertNotNull(resolveInfo);
|
||||
}
|
||||
}
|
||||
+43
-22
@@ -1,36 +1,41 @@
|
||||
package de.dennisguse.opentracks.ui.aggregatedStatistics;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.action.ViewActions.click;
|
||||
import static androidx.test.espresso.assertion.ViewAssertions.matches;
|
||||
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withId;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withText;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount;
|
||||
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks;
|
||||
import static de.dennisguse.opentracks.util.TestEnvironmentUtils.stopTrackRecordingService;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitFor;
|
||||
|
||||
import android.util.Pair;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.espresso.matcher.ViewMatchers;
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.GrantPermissionRule;
|
||||
|
||||
import org.hamcrest.core.IsInstanceOf;
|
||||
import org.hamcrest.Matcher;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.TestUtil;
|
||||
import de.dennisguse.opentracks.TrackListActivity;
|
||||
import de.dennisguse.opentracks.content.data.TestDataUtil;
|
||||
import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
@@ -41,53 +46,69 @@ import de.dennisguse.opentracks.data.models.TrackPoint;
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class EspressoAggregatedFilterTest {
|
||||
|
||||
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
|
||||
private static final Duration UI_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
@Rule
|
||||
public ActivityScenarioRule<TrackListActivity> mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class);
|
||||
private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized";
|
||||
|
||||
@Rule
|
||||
public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule();
|
||||
|
||||
private ActivityScenario<AggregatedStatisticsActivity> scenario;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
resetTrackRecordingServiceAndDeleteTracks();
|
||||
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(androidx.test.core.app.ApplicationProvider.getApplicationContext());
|
||||
|
||||
Pair<Track, List<TrackPoint>> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20);
|
||||
TrackBuilder trackBuilder = new TrackBuilder(pair.first);
|
||||
trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED);
|
||||
TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second);
|
||||
TestDataUtil.insertTrackWithLocations(contentProviderUtils, trackBuilder.getTrack(), pair.second);
|
||||
|
||||
scenario = ActivityScenario.launch(AggregatedStatisticsActivity.class);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
stopTrackRecordingService();
|
||||
if (scenario != null) {
|
||||
scenario.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void espressoAggregatedFilterTest() {
|
||||
// open AggregatedStatisticsActivity through toolbar's menu item
|
||||
onView(ViewMatchers.withId(R.id.bottom_app_bar))
|
||||
.check(matches(isDisplayed()));
|
||||
onView(withId(R.id.bottom_app_bar))
|
||||
.check(matches(hasDescendant(withId(R.id.aggregated_stats_button))));
|
||||
onView(withId(R.id.aggregated_stats_button))
|
||||
.perform(click());
|
||||
waitUntilDisplayed(withId(R.id.bottom_app_bar), UI_TIMEOUT);
|
||||
waitUntilDisplayed(withId(R.id.aggregated_statistics_filter), UI_TIMEOUT);
|
||||
waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1, UI_TIMEOUT);
|
||||
waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED)), UI_TIMEOUT);
|
||||
|
||||
// open FilterDialogFragment through toolbar's menu item
|
||||
// open FilterDialogFragment through the current bottom app bar action item
|
||||
onView(withId(R.id.bottom_app_bar))
|
||||
.check(matches(isDisplayed()));
|
||||
onView(withId(R.id.bottom_app_bar))
|
||||
.check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter))));
|
||||
onView(withId(R.id.aggregated_statistics_filter))
|
||||
.perform(click());
|
||||
.perform(performClick());
|
||||
|
||||
// check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text
|
||||
onView(allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED),
|
||||
Matcher<View> activityTypeFilter = allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED),
|
||||
withParent(allOf(withId(R.id.filter_items),
|
||||
withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))),
|
||||
isDisplayed()))
|
||||
.perform(waitFor(2000));
|
||||
isDisplayed());
|
||||
onView(activityTypeFilter)
|
||||
.inRoot(isDialog())
|
||||
.check(matches(isDisplayed()));
|
||||
|
||||
// check there's an edit text for "from date"
|
||||
onView(withId(R.id.filter_date_edit_text_from))
|
||||
.inRoot(isDialog())
|
||||
.check(matches(isDisplayed()));
|
||||
|
||||
// check there's an edit text for "to date"
|
||||
onView(withId(R.id.filter_date_edit_text_to))
|
||||
.inRoot(isDialog())
|
||||
.check(matches(isDisplayed()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+67
-22
@@ -35,7 +35,10 @@ public class IntervalStatisticsUpdaterTest {
|
||||
float distanceInterval = 1000f;
|
||||
|
||||
// when and then
|
||||
whenAndThen(50, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(50, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, 50, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +52,10 @@ public class IntervalStatisticsUpdaterTest {
|
||||
float distanceInterval = 1000f;
|
||||
|
||||
// when and then
|
||||
whenAndThen(200, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(200, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, 200, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +69,10 @@ public class IntervalStatisticsUpdaterTest {
|
||||
float distanceInterval = 3000f;
|
||||
|
||||
// when and then
|
||||
whenAndThen(3000, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(3000, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, 3000, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +86,10 @@ public class IntervalStatisticsUpdaterTest {
|
||||
float distanceInterval = 3000f;
|
||||
|
||||
// when and then
|
||||
whenAndThen(1000, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(1000, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, 1000, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +103,10 @@ public class IntervalStatisticsUpdaterTest {
|
||||
float distanceInterval = 1000f;
|
||||
|
||||
// when and then
|
||||
whenAndThen(10000, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(10000, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, 10000, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,15 +137,18 @@ public class IntervalStatisticsUpdaterTest {
|
||||
Pair<Track, List<TrackPoint>> trackWithStats = new Pair<>(dummyTrack, trackPoints);
|
||||
|
||||
// when and then
|
||||
whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
|
||||
IntervalStatisticsComputation computation = computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval);
|
||||
assertIntervalStatisticsComputation(computation, numberOfPoints, distanceInterval);
|
||||
assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size());
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
private void whenAndThen(int numberOfPoints, float distanceInterval) {
|
||||
private IntervalStatisticsComputation computeIntervalStatistics(int numberOfPoints, float distanceInterval) {
|
||||
Pair<Track, List<TrackPoint>> trackWithStats = buildTrackWithTrackPoints(numberOfPoints);
|
||||
whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval);
|
||||
return computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval);
|
||||
}
|
||||
|
||||
private void whenAndThen(Track track, List<TrackPoint> trackPoints, int numberOfPoints, float distanceInterval) {
|
||||
private IntervalStatisticsComputation computeIntervalStatistics(Track track, List<TrackPoint> trackPoints, float distanceInterval) {
|
||||
IntervalStatisticsUpdater intervalStatistics = new IntervalStatisticsUpdater(Distance.of(distanceInterval));
|
||||
|
||||
intervalStatistics.addTrackPoints(trackPoints.iterator());
|
||||
@@ -157,23 +175,27 @@ public class IntervalStatisticsUpdaterTest {
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
assertEquals(track.statistics().totalDuration().toSeconds(), totalTime, 0.01);
|
||||
assertEquals(track.statistics().totalDistance().toM(), totalDistance.toM(), 0.01);
|
||||
assertEquals(intervalList.size(), (int) Math.ceil(track.statistics().totalDistance().toM() / distanceInterval));
|
||||
if (totalGain != null && totalLoss != null) {
|
||||
assertEquals(totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
|
||||
assertEquals(totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
|
||||
return new IntervalStatisticsComputation(track, intervalList, totalDistance, totalTime, totalGain, totalLoss);
|
||||
}
|
||||
|
||||
private void assertIntervalStatisticsComputation(IntervalStatisticsComputation computation, int numberOfPoints, float distanceInterval) {
|
||||
assertEquals(computation.track.statistics().totalDuration().toSeconds(), computation.totalTime, 0.01);
|
||||
assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01);
|
||||
assertEquals(computation.intervalList.size(), (int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval));
|
||||
if (computation.totalGain != null && computation.totalLoss != null) {
|
||||
assertEquals(computation.totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1);
|
||||
assertEquals(computation.totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1);
|
||||
|
||||
} else {
|
||||
assertTrue(intervalStatistics.getIntervalList().stream().noneMatch(i -> i.altitudeGainLoss() != null));
|
||||
assertTrue(computation.intervalList.stream().noneMatch(i -> i.altitudeGainLoss() != null));
|
||||
}
|
||||
|
||||
for (int i = 0; i < intervalList.size() - 1; i++) {
|
||||
assertEquals(intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
|
||||
totalDistance = totalDistance.minus(intervalList.get(i).totalDistance());
|
||||
Distance remainingDistance = computation.totalDistance;
|
||||
for (int i = 0; i < computation.intervalList.size() - 1; i++) {
|
||||
assertEquals(computation.intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001);
|
||||
remainingDistance = remainingDistance.minus(computation.intervalList.get(i).totalDistance());
|
||||
}
|
||||
assertEquals(intervalList.get(intervalList.size() - 1).totalDistance().toM(), totalDistance.toM(), 0.01);
|
||||
assertEquals(computation.intervalList.get(computation.intervalList.size() - 1).totalDistance().toM(), remainingDistance.toM(), 0.01);
|
||||
}
|
||||
|
||||
private static Pair<Track, List<TrackPoint>> buildTrackWithTrackPoints(int numberOfPoints) {
|
||||
@@ -195,4 +217,27 @@ public class IntervalStatisticsUpdaterTest {
|
||||
|
||||
return new Pair<>(track, trackPoints);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IntervalStatisticsComputation {
|
||||
private final Track track;
|
||||
private final List<Statistics> intervalList;
|
||||
private final Distance totalDistance;
|
||||
private final float totalTime;
|
||||
private final Float totalGain;
|
||||
private final Float totalLoss;
|
||||
|
||||
private IntervalStatisticsComputation(Track track,
|
||||
List<Statistics> intervalList,
|
||||
Distance totalDistance,
|
||||
float totalTime,
|
||||
Float totalGain,
|
||||
Float totalLoss) {
|
||||
this.track = track;
|
||||
this.intervalList = intervalList;
|
||||
this.totalDistance = totalDistance;
|
||||
this.totalTime = totalTime;
|
||||
this.totalGain = totalGain;
|
||||
this.totalLoss = totalLoss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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,14 +1,25 @@
|
||||
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.ListView;
|
||||
import android.widget.AdapterView;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.test.espresso.PerformException;
|
||||
import androidx.test.espresso.UiController;
|
||||
import androidx.test.espresso.ViewAction;
|
||||
import androidx.test.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;
|
||||
|
||||
@@ -16,61 +27,114 @@ import org.hamcrest.Description;
|
||||
import org.hamcrest.Matcher;
|
||||
import org.hamcrest.TypeSafeMatcher;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class EspressoUtils {
|
||||
|
||||
public static Matcher<View> childAtPosition(
|
||||
final Matcher<View> parentMatcher, final int position) {
|
||||
private static final long VIEW_POLLING_INTERVAL_MS = 50;
|
||||
|
||||
public static Matcher<View> withRecyclerViewItemCount(final int count) {
|
||||
return new TypeSafeMatcher<>() {
|
||||
@Override
|
||||
protected boolean matchesSafely(View view) {
|
||||
if (!(view instanceof RecyclerView recyclerView)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
|
||||
return adapter != null && adapter.getItemCount() == count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void describeTo(Description description) {
|
||||
description.appendText("Child at position " + position + " in parent ");
|
||||
parentMatcher.describeTo(description);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matchesSafely(View view) {
|
||||
ViewParent parent = view.getParent();
|
||||
return parent instanceof ViewGroup && parentMatcher.matches(parent)
|
||||
&& view.equals(((ViewGroup) parent).getChildAt(position));
|
||||
description.appendText("RecyclerView should have " + count + " items");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static Matcher<View> withListSize (final int size) {
|
||||
public static Matcher<View> withSelectedTab(final int index) {
|
||||
return new TypeSafeMatcher<>() {
|
||||
@Override
|
||||
public boolean matchesSafely(final View view) {
|
||||
return ((ListView) view).getCount() == size;
|
||||
protected boolean matchesSafely(View view) {
|
||||
return view instanceof TabLayout tabLayout
|
||||
&& tabLayout.getSelectedTabPosition() == index;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void describeTo(final Description description) {
|
||||
description.appendText("ListView should have " + size + " items");
|
||||
public void describeTo(Description description) {
|
||||
description.appendText("TabLayout should have selected tab " + index);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ViewAction waitFor(final long duration_ms) {
|
||||
return new ViewAction() {
|
||||
|
||||
public static Matcher<View> withViewPagerCurrentItem(final int index) {
|
||||
return new TypeSafeMatcher<>() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Wait for milliseconds.";
|
||||
protected boolean matchesSafely(View view) {
|
||||
return view instanceof ViewPager2 viewPager2
|
||||
&& viewPager2.getCurrentItem() == index
|
||||
&& viewPager2.getScrollState() == ViewPager2.SCROLL_STATE_IDLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matcher<View> getConstraints() {
|
||||
return isDisplayed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void perform(UiController uiController, final View view) {
|
||||
uiController.loopMainThreadForAtLeast(duration_ms);
|
||||
public void describeTo(Description description) {
|
||||
description.appendText("ViewPager2 should be idle on item " + index);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static void waitForResumedView(final Matcher<View> viewMatcher, final Duration timeout) {
|
||||
waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout);
|
||||
}
|
||||
|
||||
public static void waitUntilDisplayed(final Matcher<View> viewMatcher, final Duration timeout) {
|
||||
waitForResumedView(allOf(viewMatcher, isDisplayed()), timeout);
|
||||
}
|
||||
|
||||
public static void waitUntilRecyclerViewItemCount(final Matcher<View> viewMatcher, final int expectedCount, final Duration timeout) {
|
||||
waitForResumedViewCondition(
|
||||
allOf(viewMatcher, isDisplayed()),
|
||||
view -> withRecyclerViewItemCount(expectedCount).matches(view),
|
||||
"RecyclerView item count to be " + expectedCount,
|
||||
timeout);
|
||||
}
|
||||
|
||||
public static void waitForResumedViewCondition(final Matcher<View> viewMatcher,
|
||||
final Predicate<View> condition,
|
||||
final String conditionDescription,
|
||||
final Duration timeout) {
|
||||
long deadline = SystemClock.uptimeMillis() + timeout.toMillis();
|
||||
|
||||
do {
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
|
||||
|
||||
AtomicBoolean matched = new AtomicBoolean(false);
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||
for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) {
|
||||
View decorView = activity.getWindow() != null ? activity.getWindow().getDecorView() : null;
|
||||
if (decorView == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (View child : TreeIterables.breadthFirstViewTraversal(decorView)) {
|
||||
if (viewMatcher.matches(child) && condition.test(child)) {
|
||||
matched.set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matched.get()) {
|
||||
return;
|
||||
}
|
||||
} while (SystemClock.uptimeMillis() < deadline);
|
||||
|
||||
throw new AssertionError("Timed out waiting for " + conditionDescription);
|
||||
}
|
||||
|
||||
public static ViewAction selectTabAtIndex(final int index) {
|
||||
return new ViewAction() {
|
||||
@Override
|
||||
@@ -91,4 +155,135 @@ public class EspressoUtils {
|
||||
};
|
||||
}
|
||||
|
||||
public static ViewAction performClick() {
|
||||
return new ViewAction() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "invoke View.performClick()";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matcher<View> getConstraints() {
|
||||
return isDisplayed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void perform(UiController uiController, View view) {
|
||||
if (!view.performClick()) {
|
||||
throw new PerformException.Builder()
|
||||
.withActionDescription(getDescription())
|
||||
.withViewDescription(HumanReadables.describe(view))
|
||||
.build();
|
||||
}
|
||||
uiController.loopMainThreadUntilIdle();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ViewAction performClickOnAncestor() {
|
||||
return new ViewAction() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "invoke performClick() on the matched view or its ancestor";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matcher<View> getConstraints() {
|
||||
return isAssignableFrom(View.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void perform(UiController uiController, View view) {
|
||||
View current = view;
|
||||
while (current != null) {
|
||||
if (current.performClick()) {
|
||||
uiController.loopMainThreadUntilIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
ViewParent parent = current.getParent();
|
||||
current = parent instanceof View ? (View) parent : null;
|
||||
}
|
||||
|
||||
current = view;
|
||||
while (current != null) {
|
||||
ViewParent parent = current.getParent();
|
||||
if (parent instanceof AdapterView<?> adapterView) {
|
||||
int position = adapterView.getPositionForView(current);
|
||||
if (position != AdapterView.INVALID_POSITION
|
||||
&& adapterView.performItemClick(current, position, adapterView.getAdapter().getItemId(position))) {
|
||||
uiController.loopMainThreadUntilIdle();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
current = parent instanceof View ? (View) parent : null;
|
||||
}
|
||||
|
||||
throw new PerformException.Builder()
|
||||
.withActionDescription(getDescription())
|
||||
.withViewDescription(HumanReadables.describe(view))
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ViewAction performLongClick() {
|
||||
return new ViewAction() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "invoke View.performLongClick()";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matcher<View> getConstraints() {
|
||||
return isDisplayed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void perform(UiController uiController, View view) {
|
||||
if (!view.performLongClick()) {
|
||||
throw new PerformException.Builder()
|
||||
.withActionDescription(getDescription())
|
||||
.withViewDescription(HumanReadables.describe(view))
|
||||
.build();
|
||||
}
|
||||
uiController.loopMainThreadUntilIdle();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ViewAction performLongClickOnAncestor() {
|
||||
return new ViewAction() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "invoke performLongClick() on the matched view or its ancestor";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Matcher<View> getConstraints() {
|
||||
return isDisplayed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void perform(UiController uiController, View view) {
|
||||
View current = view;
|
||||
while (current != null) {
|
||||
if (current.performLongClick()) {
|
||||
uiController.loopMainThreadUntilIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
ViewParent parent = current.getParent();
|
||||
current = parent instanceof View ? (View) parent : null;
|
||||
}
|
||||
|
||||
throw new PerformException.Builder()
|
||||
.withActionDescription(getDescription())
|
||||
.withViewDescription(HumanReadables.describe(view))
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.dennisguse.opentracks.util;
|
||||
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class LiveDataTestUtils {
|
||||
|
||||
private LiveDataTestUtils() {
|
||||
}
|
||||
|
||||
public static <T> T waitForValue(LiveData<T> liveData,
|
||||
Predicate<T> predicate,
|
||||
Duration timeout,
|
||||
String description) {
|
||||
AtomicReference<T> lastValue = new AtomicReference<>(liveData.getValue());
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
Observer<T> observer = value -> {
|
||||
lastValue.set(value);
|
||||
if (predicate.test(value)) {
|
||||
latch.countDown();
|
||||
}
|
||||
};
|
||||
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||
liveData.observeForever(observer);
|
||||
|
||||
T currentValue = liveData.getValue();
|
||||
lastValue.set(currentValue);
|
||||
if (predicate.test(currentValue)) {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
fail("Timed out waiting for " + description + ". Last value: " + lastValue.get());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
fail("Interrupted while waiting for " + description);
|
||||
} finally {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> liveData.removeObserver(observer));
|
||||
}
|
||||
|
||||
return lastValue.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.dennisguse.opentracks.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.services.TrackRecordingService;
|
||||
|
||||
public final class TestEnvironmentUtils {
|
||||
|
||||
private TestEnvironmentUtils() {
|
||||
}
|
||||
|
||||
public static void stopTrackRecordingService() {
|
||||
Context context = ApplicationProvider.getApplicationContext();
|
||||
context.stopService(new Intent(context, TrackRecordingService.class));
|
||||
}
|
||||
|
||||
public static void deleteAllTracks() {
|
||||
Context context = ApplicationProvider.getApplicationContext();
|
||||
new ContentProviderUtils(context).deleteAllTracks(context);
|
||||
}
|
||||
|
||||
public static void resetTrackRecordingServiceAndDeleteTracks() {
|
||||
stopTrackRecordingService();
|
||||
deleteAllTracks();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package de.dennisguse.opentracks.util;
|
||||
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.dennisguse.opentracks.services.TrackRecordingService;
|
||||
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
|
||||
|
||||
public final class TrackRecordingServiceTestUtils {
|
||||
|
||||
private TrackRecordingServiceTestUtils() {
|
||||
}
|
||||
|
||||
public static void waitForRecordingState(boolean isRecording, Duration timeout) {
|
||||
Context context = ApplicationProvider.getApplicationContext();
|
||||
CountDownLatch connectedLatch = new CountDownLatch(1);
|
||||
AtomicReference<TrackRecordingService> serviceRef = new AtomicReference<>();
|
||||
TrackRecordingServiceConnection connection = new TrackRecordingServiceConnection((service, self) -> {
|
||||
serviceRef.set(service);
|
||||
connectedLatch.countDown();
|
||||
});
|
||||
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.bind(context));
|
||||
|
||||
try {
|
||||
if (!connectedLatch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
fail("Timed out waiting to bind TrackRecordingService");
|
||||
}
|
||||
|
||||
LiveDataTestUtils.waitForValue(serviceRef.get().getRecordingStatusObservable(),
|
||||
status -> status != null && status.isRecording() == isRecording,
|
||||
timeout,
|
||||
"recording state to become " + isRecording);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
fail("Interrupted while waiting for recording state " + isRecording);
|
||||
} finally {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.unbind(context));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.dennisguse.opentracks.util;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
|
||||
import static androidx.test.espresso.action.ViewActions.replaceText;
|
||||
import static androidx.test.espresso.action.ViewActions.scrollTo;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withId;
|
||||
import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performClick;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick;
|
||||
import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed;
|
||||
import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState;
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.TrackListActivity;
|
||||
import de.dennisguse.opentracks.TrackRecordingActivity;
|
||||
import de.dennisguse.opentracks.TrackStoppedActivity;
|
||||
|
||||
public final class TrackUiTestUtils {
|
||||
|
||||
private TrackUiTestUtils() {
|
||||
}
|
||||
|
||||
public static void startRecordingFromTrackList(Duration timeout) {
|
||||
onView(withId(R.id.track_list_fab_action))
|
||||
.perform(performClick());
|
||||
waitForResumedActivity(TrackRecordingActivity.class, timeout);
|
||||
waitForRecordingState(true, timeout);
|
||||
waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout);
|
||||
}
|
||||
|
||||
public static void stopRecordingToTrackStopped(Duration timeout, @IdRes int... expectedVisibleViewIds) {
|
||||
onView(withId(R.id.track_recording_fab_action))
|
||||
.perform(performLongClick());
|
||||
waitForResumedActivity(TrackStoppedActivity.class, timeout);
|
||||
waitUntilDisplayed(withId(R.id.track_edit_name), timeout);
|
||||
for (int viewId : expectedVisibleViewIds) {
|
||||
waitUntilDisplayed(withId(viewId), timeout);
|
||||
}
|
||||
onView(withId(R.id.track_edit_name))
|
||||
.perform(closeSoftKeyboard());
|
||||
}
|
||||
|
||||
public static void resumeStoppedTrack(Duration timeout) {
|
||||
onView(allOf(withId(R.id.resume_button), isClickable()))
|
||||
.perform(performClick());
|
||||
waitForResumedActivity(TrackRecordingActivity.class, timeout);
|
||||
waitForRecordingState(true, timeout);
|
||||
waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout);
|
||||
}
|
||||
|
||||
public static void finishStoppedTrack(Duration timeout) {
|
||||
onView(withId(R.id.finish_button))
|
||||
.perform(performClick());
|
||||
waitForResumedActivity(TrackListActivity.class, timeout);
|
||||
waitUntilDisplayed(withId(R.id.track_list), timeout);
|
||||
}
|
||||
|
||||
public static void renameStoppedTrack(String name, Duration timeout) {
|
||||
waitUntilDisplayed(withId(R.id.track_edit_name), timeout);
|
||||
onView(withId(R.id.track_edit_name))
|
||||
.perform(scrollTo(), replaceText(name), closeSoftKeyboard());
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,5 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">OpenTracks (Debug)</string>
|
||||
<string name="launcher_name" translatable="false">OpenTracks (Debug)</string>
|
||||
</resources>
|
||||
|
||||
@@ -69,6 +69,9 @@ 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"
|
||||
@@ -78,7 +81,7 @@ limitations under the License.
|
||||
android:hardwareAccelerated="true"
|
||||
android:hasFragileUserData="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:label="@string/launcher_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/DayNightDynamicTheme"> <!-- Default for AppCompatActivities -->
|
||||
|
||||
@@ -126,9 +129,32 @@ limitations under the License.
|
||||
|
||||
<activity android:name=".AboutActivity" />
|
||||
|
||||
<!-- Permissions -->
|
||||
<!-- until Android 13 -->
|
||||
<activity
|
||||
android:name=".HelpActivity"
|
||||
android:label="@string/menu_help" />
|
||||
android:name=".HealthConnectPermissionsRationaleActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- for Android 14 -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
|
||||
android:targetActivity=".HealthConnectPermissionsRationaleActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".settings.HealthConnect$Settings" />
|
||||
<activity android:name=".settings.HealthConnect$ConfigurePermissions" />
|
||||
<activity android:name=".settings.HealthConnect$ExportAll" />
|
||||
|
||||
<activity android:name=".HelpActivity" />
|
||||
|
||||
<activity android:name=".io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity" />
|
||||
|
||||
@@ -410,5 +436,6 @@ limitations under the License.
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.dennisguse.opentracks;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
|
||||
|
||||
public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
|
||||
setSupportActionBar(viewBinding.healthConnectActivityToolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
setContentView(viewBinding.getRoot());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSupportNavigateUp() {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.StrictMode;
|
||||
import android.util.Log;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -27,6 +28,11 @@ public class Startup extends Application {
|
||||
|
||||
// Include version information into stack traces.
|
||||
Log.i(TAG, BuildConfig.APPLICATION_ID + "; BuildType: " + BuildConfig.BUILD_TYPE + "; VersionName: " + BuildConfig.VERSION_NAME + "/" + BuildConfig.VERSION_NAME_FULL + " VersionCode: " + BuildConfig.VERSION_CODE);
|
||||
// In debug builds: show thread and VM warnings.
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Enabling strict mode");
|
||||
StrictMode.enableDefaults();
|
||||
}
|
||||
|
||||
PreferencesUtils.initPreferences(this);
|
||||
// Set default values of preferences on first start.
|
||||
|
||||
@@ -367,7 +367,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
|
||||
}
|
||||
|
||||
if (itemId == R.id.list_context_menu_show_on_map) {
|
||||
DataProvider.showTrackOnMap(this, false, trackIds);
|
||||
DataProvider.showTrackOnMap(this, recordingStatus.isRecording(), trackIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
|
||||
import de.dennisguse.opentracks.databinding.TrackStoppedBinding;
|
||||
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
|
||||
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
|
||||
@@ -93,6 +94,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
|
||||
viewBinding.finishButton.setOnClickListener(v -> {
|
||||
storeTrackMetaData(contentProviderUtils, track);
|
||||
ExportUtils.postWorkoutExport(this, trackId);
|
||||
HealthConnectUtils.postExportTrack(this, trackId);
|
||||
finish();
|
||||
});
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ public class CustomContentProvider extends ContentProvider {
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(url, null, false);
|
||||
notifyChanges(url);
|
||||
|
||||
int totalChanges = getTotalChanges() - totalChangesBefore;
|
||||
Log.i(TAG, "Deleted " + totalChanges + " total rows from database");
|
||||
@@ -212,7 +212,8 @@ public class CustomContentProvider extends ContentProvider {
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(url, null, false);
|
||||
notifyChanges(url);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -235,7 +236,8 @@ public class CustomContentProvider extends ContentProvider {
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(url, null, false);
|
||||
notifyChanges(url);
|
||||
|
||||
return numInserted;
|
||||
}
|
||||
|
||||
@@ -348,10 +350,36 @@ public class CustomContentProvider extends ContentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
getContext().getContentResolver().notifyChange(url, null, false);
|
||||
notifyChanges(url);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private void notifyChanges(@NonNull Uri uri) {
|
||||
getContext().getContentResolver().notifyChange(uri, null, false);
|
||||
// Also notify dashboard URIs for compatibility
|
||||
Uri dashboardUri = getDashboardUri(uri);
|
||||
if (dashboardUri != null) {
|
||||
getContext().getContentResolver().notifyChange(dashboardUri, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri getDashboardUri(Uri uri) {
|
||||
String path = uri.getPath();
|
||||
if (path == null) return null;
|
||||
String dashboardPath;
|
||||
if (path.startsWith("/tracks")) {
|
||||
dashboardPath = path.replaceFirst("/tracks", "/dashboard/tracks");
|
||||
} else if (path.startsWith("/markers")) {
|
||||
dashboardPath = path.replaceFirst("/markers", "/dashboard/markers");
|
||||
} else if (path.startsWith("/trackpoints")) {
|
||||
dashboardPath = path.replaceFirst("/trackpoints", "/dashboard/trackpoints");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Uri.parse(CONTENT_BASE_URI + dashboardPath);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private UrlType getUrlType(Uri url) {
|
||||
UrlType[] urlTypes = UrlType.values();
|
||||
|
||||
@@ -30,6 +30,7 @@ public enum ActivityType {
|
||||
FERRY("ferry", R.string.activity_type_ferry, R.drawable.ic_activity_boat_24dp, true),
|
||||
FRISBEE("frisbee", R.string.activity_type_frisbee, ActivityIcon.ICON_UNKNOWN, true),
|
||||
GLIDING("gliding", R.string.activity_type_gliding, ActivityIcon.ICON_UNKNOWN, true),
|
||||
GRAVEL_BIKING("gravel biking", R.string.activity_type_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),
|
||||
|
||||
@@ -36,7 +36,7 @@ public class ExportUtils {
|
||||
private static final String TAG = ExportUtils.class.getSimpleName();
|
||||
|
||||
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
|
||||
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
|
||||
if (PreferencesUtils.shouldInstantExportToFile()) {
|
||||
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
|
||||
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
|
||||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
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.CrankData, Cadence> {
|
||||
public class CyclingCadenceHandler extends SensorHandler<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
|
||||
|
||||
protected CyclingCadenceHandler(SensorManager sensorManager) {
|
||||
super(sensorManager);
|
||||
|
||||
@@ -164,7 +164,7 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange
|
||||
cyclingDistanceSpeedHandler.connect();
|
||||
}
|
||||
|
||||
if (PreferencesUtils.isKey(cyclingCadenceHandler.getSensorPreferenceKey(), key)) {
|
||||
if (PreferencesUtils.isKey(cyclingPowerHandler.getSensorPreferenceKey(), key)) {
|
||||
cyclingPowerHandler.connect();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import de.dennisguse.opentracks.data.models.AtmosphericPressure;
|
||||
@@ -17,12 +19,14 @@ public class BarometerInternal implements Driver {
|
||||
|
||||
private static final String TAG = BarometerInternal.class.getSimpleName();
|
||||
|
||||
private static final int SAMPLING_PERIOD = (int) TimeUnit.SECONDS.toMicros(5);
|
||||
private static final Duration SAMPLING_PERIOD = Duration.ofSeconds(5);
|
||||
|
||||
private final DriverObserver<AtmosphericPressure> observer;
|
||||
|
||||
private android.hardware.SensorManager sensorService;
|
||||
|
||||
private Instant lastEventTime = Instant.EPOCH;
|
||||
|
||||
private final SensorEventListener sensorEventListener = new SensorEventListener() {
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
@@ -31,6 +35,13 @@ public class BarometerInternal implements Driver {
|
||||
return;
|
||||
}
|
||||
|
||||
Instant currentTime = Instant.now();
|
||||
|
||||
if (lastEventTime.plus(SAMPLING_PERIOD).isAfter(currentTime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastEventTime = currentTime;
|
||||
observer.onDataReceived(AtmosphericPressure.ofHPA(event.values[0]));
|
||||
}
|
||||
|
||||
@@ -48,8 +59,9 @@ public class BarometerInternal implements Driver {
|
||||
public void connect(Context context, Handler handler, String addressIgnored) {
|
||||
sensorService = (android.hardware.SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
|
||||
Sensor pressureSensor = sensorService.getDefaultSensor(Sensor.TYPE_PRESSURE);
|
||||
int samplingPeriodUs = (int) TimeUnit.MICROSECONDS.convert(SAMPLING_PERIOD);
|
||||
|
||||
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, SAMPLING_PERIOD, handler)) {
|
||||
if (pressureSensor != null && sensorService.registerListener(sensorEventListener, pressureSensor, samplingPeriodUs, handler)) {
|
||||
observer.onConnected(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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,23 +6,25 @@ import android.util.Pair;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import de.dennisguse.opentracks.data.models.Cadence;
|
||||
import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID;
|
||||
|
||||
public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<CyclingCadenceBluetooth.CrankData> {
|
||||
public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<CyclingCadenceBluetooth.CyclingCadenceMeasurement> {
|
||||
private static final String TAG = CyclingCadenceBluetooth.class.getSimpleName();
|
||||
|
||||
public static final List<ServiceMeasurementUUID> CYCLING_CADENCE = List.of(
|
||||
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
|
||||
CyclingPowerBluetooth.CYCLING_POWER,
|
||||
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE
|
||||
CyclingDistanceSpeedBluetooth.CYCLING_SPEED_CADENCE,
|
||||
BoschEbikeParser.BOSCH_EBIKE
|
||||
);
|
||||
|
||||
@Override
|
||||
public List<ServiceMeasurementUUID> getServices() {
|
||||
return CYCLING_CADENCE;
|
||||
return SUPPORTED_SERVICES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrankData parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
|
||||
public CyclingCadenceMeasurement parsePayload(ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
|
||||
if (serviceMeasurementUUID.equals(CyclingPowerBluetooth.CYCLING_POWER)) {
|
||||
CyclingPowerBluetooth.Data data = CyclingPowerBluetooth.parseCyclingPower(characteristic);
|
||||
if (data != null && data.crank() != null) {
|
||||
@@ -38,12 +40,28 @@ public class CyclingCadenceBluetooth implements BluetoothDriver.BluetoothParser<
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
|
||||
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
|
||||
if (boschData != null && boschData.cadence() != null) {
|
||||
return new DirectCadenceData(boschData.cadence());
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(TAG, "Don't know how to decode this payload.");
|
||||
return null;
|
||||
}
|
||||
|
||||
public sealed interface CyclingCadenceMeasurement permits CrankData, DirectCadenceData {
|
||||
}
|
||||
|
||||
public record CrankData(
|
||||
long crankRevolutionsCount, // UINT32
|
||||
int crankRevolutionsTime // UINT16; 1/1024s
|
||||
) {}
|
||||
) implements CyclingCadenceMeasurement {
|
||||
}
|
||||
|
||||
public record DirectCadenceData(
|
||||
Cadence cadence
|
||||
) implements CyclingCadenceMeasurement {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,28 @@ public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser<Cy
|
||||
new UUID(0x2A6300001000L, 0x800000805f9b34fbL)
|
||||
);
|
||||
|
||||
public static final List<ServiceMeasurementUUID> SUPPORTED_SERVICES = List.of(
|
||||
CYCLING_POWER,
|
||||
BoschEbikeParser.BOSCH_EBIKE
|
||||
);
|
||||
|
||||
@Override
|
||||
public List<ServiceMeasurementUUID> getServices() {
|
||||
return List.of(CYCLING_POWER);
|
||||
return SUPPORTED_SERVICES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CyclingPowerBluetooth.Data parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) {
|
||||
return parseCyclingPower(characteristic);
|
||||
if (serviceMeasurementUUID.equals(CYCLING_POWER)) {
|
||||
return parseCyclingPower(characteristic);
|
||||
}
|
||||
if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) {
|
||||
BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue());
|
||||
if (boschData != null && boschData.humanPower() != null) {
|
||||
return new Data(boschData.humanPower(), null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+22
-6
@@ -10,7 +10,7 @@ import de.dennisguse.opentracks.data.models.Cadence;
|
||||
import de.dennisguse.opentracks.sensors.UintUtils;
|
||||
import de.dennisguse.opentracks.sensors.driver.CyclingCadenceBluetooth;
|
||||
|
||||
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CrankData, Cadence> {
|
||||
public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth.CyclingCadenceMeasurement, Cadence> {
|
||||
|
||||
private final String TAG = AggregatorCyclingCadence.class.getSimpleName();
|
||||
|
||||
@@ -19,12 +19,20 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CrankData> current) {
|
||||
if (previous == null) {
|
||||
protected void computeValue(@NonNull Raw<CyclingCadenceBluetooth.CyclingCadenceMeasurement> current) {
|
||||
if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) {
|
||||
output = directCadenceData.cadence();
|
||||
return;
|
||||
}
|
||||
|
||||
float timeDiff_ms = UintUtils.diff(current.value().crankRevolutionsTime(), previous.value().crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
|
||||
computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value());
|
||||
}
|
||||
|
||||
private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw) {
|
||||
CyclingCadenceBluetooth.CrankData previousRaw = getPreviousRaw();
|
||||
if (previousRaw == null) return;
|
||||
|
||||
float timeDiff_ms = UintUtils.diff(currentRaw.crankRevolutionsTime(), previousRaw.crankRevolutionsTime(), UintUtils.UINT16_MAX) / 1024f * 1000;
|
||||
Duration timeDiff = Duration.ofMillis((long) timeDiff_ms);
|
||||
|
||||
if (timeDiff.isZero()) {
|
||||
@@ -37,13 +45,21 @@ public class AggregatorCyclingCadence extends Aggregator<CyclingCadenceBluetooth
|
||||
}
|
||||
|
||||
// TODO We have to treat with overflow according to the documentation: read https://codeberg.org/OpenTracksApp/OpenTracks/pulls/953#issuecomment-6466930
|
||||
if (current.value().crankRevolutionsCount() < previous.value().crankRevolutionsCount()) {
|
||||
if (currentRaw.crankRevolutionsCount() < previousRaw.crankRevolutionsCount()) {
|
||||
Log.e(TAG, "Crank revolutions count difference is invalid: cannot compute cadence.");
|
||||
output = null;
|
||||
return;
|
||||
}
|
||||
|
||||
long crankDiff = UintUtils.diff(current.value().crankRevolutionsCount(), previous.value().crankRevolutionsCount(), UintUtils.UINT32_MAX);
|
||||
long crankDiff = UintUtils.diff(currentRaw.crankRevolutionsCount(), previousRaw.crankRevolutionsCount(), UintUtils.UINT32_MAX);
|
||||
output = Cadence.of(crankDiff, timeDiff);
|
||||
}
|
||||
|
||||
private CyclingCadenceBluetooth.CrankData getPreviousRaw() {
|
||||
if (previous == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return previous.value() instanceof CyclingCadenceBluetooth.CrankData raw ? raw : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.dennisguse.opentracks.services;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
|
||||
/**
|
||||
* For handling restarts of TrackRecordingService: while recording, stores the {@link de.dennisguse.opentracks.data.models.Track.Id}.
|
||||
*/
|
||||
class TrackIdStorage {
|
||||
|
||||
private final static String SHARED_PREFERENCE_NAME = "TrackRecordingService_pref";
|
||||
|
||||
private final static String KEY = "trackId_key";
|
||||
|
||||
private static SharedPreferences sharedPreferences;
|
||||
|
||||
private static void init(Context context) {
|
||||
if (sharedPreferences == null) {
|
||||
sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static Track.Id get(Context context) {
|
||||
TrackIdStorage.init(context);
|
||||
|
||||
long value = sharedPreferences.getLong(KEY, -1);
|
||||
if (value < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Track.Id(value);
|
||||
}
|
||||
|
||||
public static void set(Context context, @NonNull Track.Id trackId) {
|
||||
TrackIdStorage.init(context);
|
||||
|
||||
SharedPreferences.Editor editor = sharedPreferences.edit();
|
||||
editor.putLong(KEY, trackId.id());
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
public static void unset(Context context) {
|
||||
TrackIdStorage.init(context);
|
||||
|
||||
SharedPreferences.Editor editor = sharedPreferences.edit();
|
||||
editor.remove(KEY);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -67,7 +67,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
|
||||
contentProviderUtils = new ContentProviderUtils(context);
|
||||
}
|
||||
|
||||
Track.Id startNewTrack() {
|
||||
synchronized Track.Id startNewTrack() {
|
||||
TrackPoint segmentStartTrackPoint = trackPointCreator.createSegmentStartManual();
|
||||
|
||||
ZoneOffset zoneOffset = ZoneOffset.systemDefault().getRules().getOffset(segmentStartTrackPoint.getTime());
|
||||
@@ -103,7 +103,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
|
||||
/**
|
||||
* @return if the recording could be started.
|
||||
*/
|
||||
boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
|
||||
synchronized boolean resumeExistingTrack(@NonNull Track.Id resumeTrackId) {
|
||||
trackId = resumeTrackId;
|
||||
Track track = contentProviderUtils.getTrack(trackId);
|
||||
if (track == null) {
|
||||
@@ -119,7 +119,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
|
||||
return true;
|
||||
}
|
||||
|
||||
void endCurrentTrack() {
|
||||
synchronized void endCurrentTrack() {
|
||||
TrackPoint segmentEnd = trackPointCreator.createSegmentEnd();
|
||||
insertTrackPoint(segmentEnd, true);
|
||||
|
||||
@@ -149,7 +149,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
|
||||
public void onIdle() {
|
||||
public synchronized void onIdle() {
|
||||
Log.d(TAG, "Becoming idle");
|
||||
onNewTrackPoint(trackPointCreator.createIdle());
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
private MutableLiveData<GpsStatusValue> gpsStatusObservable;
|
||||
private MutableLiveData<RecordingData> recordingDataObservable;
|
||||
|
||||
// The following variables are set when recording:
|
||||
private Handler handler;
|
||||
|
||||
private TrackPointCreator trackPointCreator;
|
||||
@@ -119,9 +118,6 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.d(TAG, "Destroying");
|
||||
if (isRecording()) {
|
||||
endCurrentTrack();
|
||||
}
|
||||
if (trackPointCreator != null && trackPointCreator.isStarted()) {
|
||||
stopSensors();
|
||||
}
|
||||
@@ -150,6 +146,16 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent == null) {
|
||||
Log.i(TAG, "Service got killed and is restarting");
|
||||
|
||||
//Best guess: do we need to resume a recording?
|
||||
Track.Id trackId = TrackIdStorage.get(this);
|
||||
if (trackId != null) {
|
||||
Log.i(TAG, "After restart: resuming recording for " + trackId);
|
||||
resumeTrack(trackId);
|
||||
}
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@@ -167,9 +173,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
|
||||
// Set recording status
|
||||
Track.Id trackId = trackRecordingManager.startNewTrack();
|
||||
updateRecordingStatus(RecordingStatus.record(trackId));
|
||||
|
||||
startRecording();
|
||||
startRecording(trackId);
|
||||
return trackId;
|
||||
}
|
||||
|
||||
@@ -180,12 +184,13 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
}
|
||||
Log.i(TAG, "resumeTrack");
|
||||
|
||||
updateRecordingStatus(RecordingStatus.record(trackId));
|
||||
|
||||
startRecording();
|
||||
startRecording(trackId);
|
||||
}
|
||||
|
||||
private void startRecording() {
|
||||
private void startRecording(Track.Id trackId) {
|
||||
updateRecordingStatus(RecordingStatus.record(trackId));
|
||||
TrackIdStorage.set(this, trackId);
|
||||
|
||||
startSensors();
|
||||
|
||||
voiceAnnouncementManager.start(trackRecordingManager.getTrackStatistics());
|
||||
@@ -226,6 +231,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
|
||||
updateRecordingStatus(STATUS_DEFAULT);
|
||||
|
||||
trackRecordingManager.endCurrentTrack();
|
||||
TrackIdStorage.unset(this);
|
||||
|
||||
stopUpdateRecordingData();
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.dennisguse.opentracks.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.PermissionController;
|
||||
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
|
||||
public class HealthConnect {
|
||||
|
||||
public static class Settings extends AppCompatActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
startActivity(new Intent(HealthConnectClient.getHealthConnectSettingsAction()));
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConfigurePermissions extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!HealthConnectUtils.isAvailable(this)) {
|
||||
finish();
|
||||
}
|
||||
|
||||
registerForActivityResult(
|
||||
PermissionController.createRequestPermissionResultContract(),
|
||||
grantedPermissions -> {
|
||||
// TODO User feedback via Toast.makeText()?
|
||||
finish();
|
||||
}
|
||||
).launch(HealthConnectUtils.PERMISSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExportAll extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
HealthConnectUtils.postExportAll(this);
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import java.util.Map;
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.io.file.TrackFileFormat;
|
||||
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
import de.dennisguse.opentracks.util.IntentUtils;
|
||||
|
||||
public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
private static final String TAG = ImportExportSettingsFragment.class.getSimpleName();
|
||||
public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
|
||||
@@ -27,6 +27,11 @@ 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
|
||||
@@ -40,9 +45,21 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
|
||||
super.onResume();
|
||||
|
||||
setExportDirectorySummary();
|
||||
findPreference(getString(R.string.post_workout_export_enabled_key)).setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
|
||||
|
||||
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
|
||||
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
|
||||
setHealthConnectPreference();
|
||||
}
|
||||
|
||||
private void setHealthConnectPreference() {
|
||||
boolean healthConnectAvailable = HealthConnectUtils.isAvailable(requireContext());
|
||||
findPreference("healthConnectCategory")
|
||||
.setEnabled(healthConnectAvailable);
|
||||
|
||||
if (!healthConnectAvailable) {
|
||||
findPreference(getString(R.string.post_workout_export_health_connect_enabled_key))
|
||||
.setSummary(R.string.health_connect_not_available);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void setExportTrackFileFormatOptions() {
|
||||
|
||||
@@ -71,6 +71,7 @@ public class PreferencesUtils {
|
||||
private static SharedPreferences sharedPreferences;
|
||||
|
||||
private static Resources resources;
|
||||
private static final long LONG_UNSET = Long.MIN_VALUE;
|
||||
|
||||
/**
|
||||
* Must be called during application startup.
|
||||
@@ -168,24 +169,36 @@ public class PreferencesUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static long getLong(int keyId, long defaultValue) {
|
||||
try {
|
||||
return sharedPreferences.getLong(getKey(keyId), defaultValue);
|
||||
} catch (ClassCastException e) {
|
||||
// Ignore.
|
||||
}
|
||||
|
||||
try {
|
||||
String stringValue = sharedPreferences.getString(getKey(keyId), null);
|
||||
return Long.parseLong(stringValue);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getString(int keyId, String defaultValue) {
|
||||
return sharedPreferences.getString(getKey(keyId), defaultValue);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static void setString(int keyId, String value) {
|
||||
static void setString(int keyId, String value) {
|
||||
Editor editor = sharedPreferences.edit();
|
||||
editor.putString(getKey(keyId), value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static void setString(int keyId, int valueId) {
|
||||
static void setString(int keyId, int valueId) {
|
||||
setString(keyId, resources.getString(valueId));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static void setBoolean(int keyId, Boolean value) {
|
||||
static void setBoolean(int keyId, Boolean value) {
|
||||
Editor editor = sharedPreferences.edit();
|
||||
editor.putBoolean(getKey(keyId), value);
|
||||
editor.apply();
|
||||
@@ -197,6 +210,12 @@ public class PreferencesUtils {
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
private static void setLong(int keyId, long value) {
|
||||
Editor editor = sharedPreferences.edit();
|
||||
editor.putLong(getKey(keyId), value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static boolean isPublicAPIenabled() {
|
||||
return getBoolean(R.string.publicapi_enabled_key, resources.getBoolean(R.bool.publicapi_enabled_default));
|
||||
}
|
||||
@@ -328,6 +347,11 @@ public class PreferencesUtils {
|
||||
return getBoolean(R.string.voice_announce_unit_key, true);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static void setVoiceAnnounceUnit(boolean value) {
|
||||
setBoolean(R.string.voice_announce_unit_key, value);
|
||||
}
|
||||
|
||||
public static Duration getVoiceAnnouncementFrequency() {
|
||||
final int DEFAULT = Integer.parseInt(resources.getString(R.string.voice_announcement_frequency_default));
|
||||
int value = getInt(R.string.voice_announcement_frequency_key, DEFAULT);
|
||||
@@ -422,7 +446,7 @@ public class PreferencesUtils {
|
||||
}
|
||||
|
||||
public static boolean shouldVoiceAnnounceLapPower() {
|
||||
return getBoolean(R.string.voice_announce_lap_power_key, false);
|
||||
return getBoolean(R.string.voice_announce_lap_power_key, getBoolean(R.bool.voice_announce_lap_power_default, false));
|
||||
}
|
||||
|
||||
public static boolean shouldVoiceAnnounceHeartRateCurrent() {
|
||||
@@ -655,12 +679,16 @@ public class PreferencesUtils {
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
public static boolean shouldInstantExportAfterWorkout() {
|
||||
public static boolean shouldInstantExportToFile() {
|
||||
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
|
||||
return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri();
|
||||
}
|
||||
|
||||
public static boolean shouldInstantExportToHealthConnect() {
|
||||
final boolean DEFAULT = resources.getBoolean(R.bool.post_workout_export_health_connect_enabled_default);
|
||||
return getBoolean(R.string.post_workout_export_health_connect_enabled_key, DEFAULT);
|
||||
}
|
||||
|
||||
public static TrackFilenameGenerator getTrackFileformatGenerator() {
|
||||
String DEFAULT = resources.getString(R.string.export_filename_format_default);
|
||||
TrackFilenameGenerator generator = new TrackFilenameGenerator(getString(R.string.export_filename_format_key, DEFAULT));
|
||||
@@ -748,11 +776,13 @@ public class PreferencesUtils {
|
||||
sharedPreferences.edit().clear().commit();
|
||||
}
|
||||
|
||||
// Each setting file must be mentioned here.
|
||||
// Each setting file must be mentioned here: see xml/settings.xml
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_defaults, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_user_interface, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_recording, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_sensors, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_announcements, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_import_export, readAgain);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.settings_public_api, readAgain);
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,6 +27,6 @@ public class BluetoothLeCyclingCadencePreference extends BluetoothLeSensorPrefer
|
||||
|
||||
@Override
|
||||
public PreferenceDialogFragmentCompat createInstance() {
|
||||
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.CYCLING_CADENCE);
|
||||
return BluetoothLeSensorPreferenceDialog.newInstance(getKey(), CyclingCadenceBluetooth.SUPPORTED_SERVICES);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -4,9 +4,6 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.preference.PreferenceDialogFragmentCompat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth;
|
||||
|
||||
public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreference {
|
||||
@@ -29,6 +26,8 @@ public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreferen
|
||||
|
||||
@Override
|
||||
public PreferenceDialogFragmentCompat createInstance() {
|
||||
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(getKey(), List.of(CyclingPowerBluetooth.CYCLING_POWER));
|
||||
return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(
|
||||
getKey(),
|
||||
CyclingPowerBluetooth.SUPPORTED_SERVICES);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-21
@@ -191,26 +191,6 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
|
||||
List<ParcelUuid> serviceUUIDs = getArguments().getParcelableArrayList(ARG_BLE_SERVICE_UUIDS);
|
||||
boolean includeInternalSensor = getArguments().getBoolean(ARG_INCLUDE_INTERNAL);
|
||||
|
||||
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
|
||||
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
|
||||
Log.w(TAG, "Bluetooth adapter is present or not enabled.");
|
||||
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
if (bluetoothAdapter.isDiscovering()) {
|
||||
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
|
||||
bluetoothAdapter.cancelDiscovery();
|
||||
}
|
||||
|
||||
scanner = bluetoothAdapter.getBluetoothLeScanner();
|
||||
if (scanner == null) {
|
||||
Log.e(TAG, "BluetoothLeScanner is null.");
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
listAdapter.add(SensorType.NONE.getPreferenceValue(), getContext().getString(DEVICE_NONE_RESOURCEID));
|
||||
selectedEntryIndex = 0;
|
||||
|
||||
@@ -228,8 +208,37 @@ public abstract class BluetoothLeSensorPreference extends DialogPreference {
|
||||
selectedEntryIndex = !includeInternalSensor ? 1 : 2;
|
||||
}
|
||||
|
||||
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getAdapter(getContext());
|
||||
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
|
||||
Log.w(TAG, "Bluetooth adapter is present or not enabled. Skipping BLE scan.");
|
||||
Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show();
|
||||
if (!includeInternalSensor) {
|
||||
dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bluetoothAdapter.isDiscovering()) {
|
||||
Log.i(TAG, "Cancelling ongoing Bluetooth discovery.");
|
||||
bluetoothAdapter.cancelDiscovery();
|
||||
}
|
||||
|
||||
scanner = bluetoothAdapter.getBluetoothLeScanner();
|
||||
if (scanner == null) {
|
||||
Log.e(TAG, "BluetoothLeScanner is null.");
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-populate with already-bonded BLE devices so that devices that are paired but not actively advertising (e.g. Bosch eBike) are still shown. In case we dont have any cached UUIDs for a device, we just add it to the list and let the user decide
|
||||
boolean filterEnabled = PreferencesUtils.getBluetoothFilterEnabled();
|
||||
bluetoothAdapter.getBondedDevices().stream()
|
||||
.filter(bonded -> bonded.getType() == BluetoothDevice.DEVICE_TYPE_LE || bonded.getType() == BluetoothDevice.DEVICE_TYPE_DUAL)
|
||||
.filter(bonded -> !filterEnabled || bonded.getUuids() == null || java.util.Arrays.stream(bonded.getUuids()).anyMatch(serviceUUIDs::contains))
|
||||
.forEach(bonded -> listAdapter.add(bonded.getAddress(), bonded.getName()));
|
||||
|
||||
List<ScanFilter> scanFilter = null;
|
||||
if (PreferencesUtils.getBluetoothFilterEnabled()) {
|
||||
if (filterEnabled) {
|
||||
scanFilter = serviceUUIDs.stream()
|
||||
.map(it -> new ScanFilter.Builder().setServiceUuid(it).build())
|
||||
.toList();
|
||||
|
||||
@@ -7,13 +7,51 @@ import android.widget.ArrayAdapter;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* <a href="Data source for UTC Offsets">https://en.wikipedia.org/wiki/List_of_UTC_offsets</a>
|
||||
*/
|
||||
public class ZoneOffsetAdapter extends ArrayAdapter<ZoneOffset> {
|
||||
public ZoneOffsetAdapter(@NonNull Context context, int resource) {
|
||||
super(context, resource, IntStream.range(-12, +12)
|
||||
.boxed()
|
||||
.map(ZoneOffset::ofHours)
|
||||
.toList());
|
||||
super(context, resource);
|
||||
|
||||
addAll(
|
||||
ZoneOffset.ofHoursMinutes(-12, 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package de.dennisguse.opentracks.ui.customRecordingLayout;
|
||||
|
||||
public enum CustomLayoutFieldType {
|
||||
GENERIC(1),
|
||||
CLOCK(2);
|
||||
|
||||
private final int value;
|
||||
|
||||
CustomLayoutFieldType(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int value() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/health_connect_activity_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/app_name" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="20dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceHeadline6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_permissions_rationale_heading" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/health_connect_permissions_rationale_summary" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -386,7 +386,6 @@ limitations under the License.
|
||||
<item quantity="many">اكتمل مع %1$d خطأ</item>
|
||||
<item quantity="other">اكتمل مع %1$d خطأ</item>
|
||||
</plurals>
|
||||
<string name="voice_announce_lap_power_key">الإعلان الصوتي عن قوة اللفة voiceAnnounceLapPower</string>
|
||||
<string name="settings_defaults_title">الوحدات الافتراضية والنشاط</string>
|
||||
<string name="settings_defaults_summary">الوحدات المفضلة، النشاط الافتراضي، اسم المسار الافتراضي</string>
|
||||
<string name="settings_announcements_summary">الوقت، المسافة، سرعة الصوت</string>
|
||||
@@ -457,7 +456,7 @@ limitations under the License.
|
||||
<string name="settings_recording_title">التسجيل</string>
|
||||
<string name="settings_public_api_enabled_title">واجهة برمجة التطبيقات العامة</string>
|
||||
<string name="settings_public_api_disabled_toast">واجهة برمجة التطبيقات العامة لـ المسارات المفتوحة معطلة: يمكن تفعيلها في الإعدادات.</string>
|
||||
<string name="settings_export_section">التصدير</string>
|
||||
<string name="settings_export_as_file_section">التصدير</string>
|
||||
<string name="instant_export_enabled_summary">تصدير المسار إلى التخزين بعد انتهاء التسجيل</string>
|
||||
<string name="create_marker_error">غير قادر على إنشاء علامة. حاول مرة أخرى.</string>
|
||||
<string name="sensor_state_heart_rate_avg">متوسط معدل ضربات القلب</string>
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
<string name="settings_gps_summary">Intervalo de tiempo, Intervalo de distancia, Distancia máxima, Precisión</string>
|
||||
<string name="settings_sensors_title">Sensores</string>
|
||||
<string name="settings_sensors_summary">Sensores de ciclismo, Circunferencia de la rueda, Sensores de carrera</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="activity_type_swimming">Natación</string>
|
||||
<string name="activity_type_swimming_open">Natación en aguas abiertas</string>
|
||||
<string name="activity_type_workout">Entrenamiento</string>
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
<string name="settings_stats_units_imperial_meter">Imperiale (mi, m)</string>
|
||||
<string name="settings_stats_units_metric">Metrica (km, m)</string>
|
||||
<string name="settings_import_section">Impurtà</string>
|
||||
<string name="settings_export_section">Espurtà</string>
|
||||
<string name="settings_export_as_file_section">Espurtà</string>
|
||||
<string name="settings_public_api_package_title">Nome di u pacchettu per l’API publica</string>
|
||||
<string name="settings_public_api_enabled_summary_off">Solu OpenTracks pò lancià è piantà l’arregistramenti.</string>
|
||||
<string name="settings_public_api_enabled_summary_on">D’altre appiecazioni installate ponu lancià o piantà l’arregistramenti.</string>
|
||||
@@ -534,7 +534,6 @@
|
||||
<string name="settings_locale_title">Lingua</string>
|
||||
<string name="settings_locale_system_default">Predefinizione di u sistema</string>
|
||||
<string name="stats_gain_loss">Guadagnu è perdita</string>
|
||||
<string name="voice_announce_lap_power_key">AnnunziamentuVucalePutenzaÀUGiru</string>
|
||||
<string name="lap_heart_rate">Frequenza cardiaca à u giru</string>
|
||||
<string name="settings_charts_title">Grafici</string>
|
||||
<string name="settings_chart_display_elevation_title">Affissà l’altitudine</string>
|
||||
|
||||
@@ -444,7 +444,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Cyklistika</string>
|
||||
<string name="settings_running_sensor">Běh</string>
|
||||
<string name="settings_import_section">Importovat</string>
|
||||
<string name="settings_export_section">Exportovat</string>
|
||||
<string name="settings_export_as_file_section">Exportovat</string>
|
||||
<string name="stats_sensors_power">Napájení</string>
|
||||
<string name="permission_bluetooth_failed_rejected">V nastavení systému je třeba ručně povolit aplikaci OpenTracks přístup k blízkým zařízením.</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
@@ -580,7 +580,6 @@ limitations under the License.
|
||||
<string name="settings_announcements_unit">Jednotka vzdálenosti/rychlosti</string>
|
||||
<string name="voiceSpeed">{n, plural, =1 {1} few {{n,number}} other {{n,number,#.0}} }</string>
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } few {{n,number}} other {{n,number,#.0}} }</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power">Výkon</string>
|
||||
<string name="settings_announcements_lap_power">Výkon kola</string>
|
||||
<string name="power_x_watt">Výkon {x, plural, =1 {1 watt} few {# watty} other {# wattů} }</string>
|
||||
|
||||
@@ -471,7 +471,7 @@ limitations under the License.
|
||||
<string name="custom_layout_select_layout">Vælg layout</string>
|
||||
<string name="settings_running_sensor">Kører</string>
|
||||
<string name="settings_import_section">Importere</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="stats_clock">Ur</string>
|
||||
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (anbefalet)</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -511,7 +511,7 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Zeige nur Bluetooth-Geräte, die benötigte Dienste bekanntmachen</string>
|
||||
<string name="settings_public_api_enabled_summary_on">Andere installierte Anwendungen können Aufzeichnungen starten oder stoppen.</string>
|
||||
<string name="settings_public_api_disabled_toast">OpenTracks öffentliche API ist deaktiviert: es kann in den Einstellungen aktiviert werden.</string>
|
||||
<string name="settings_export_section">Export</string>
|
||||
<string name="settings_export_as_file_section">Export</string>
|
||||
<string name="settings_public_api_enabled_summary_off">Nur OpenTracks kann Aufzeichnungen starten und stoppen.</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_api_title">Öffentliche API</string>
|
||||
@@ -635,7 +635,6 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">Abtastzeitintervall</string>
|
||||
<string name="voiceIdle">Untätig werden.</string>
|
||||
<string name="voice_announce_lap_power_key">StimmeAnkündigungLapPower</string>
|
||||
<string name="settings_announcements_lap_power">Rundenleistung</string>
|
||||
<string name="create_marker_error">Die Markierung konnte nicht erstellt werden. Versuchen Sie es erneut.</string>
|
||||
<string name="settings_locale_title">Sprache</string>
|
||||
@@ -654,4 +653,9 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
|
||||
<string name="settings_night_mode_option_day_dynamic">Tag (DynamicColor)</string>
|
||||
<string name="settings_night_mode_option_night_dynamic">Nacht (DynamicColor)</string>
|
||||
<string name="settings_night_mode_option_night_oled_dynamic">OLED-freundlich (DynamicColor)</string>
|
||||
<string name="stats_sensors_temperature">Temperatur</string>
|
||||
<string name="sensor_state_temperature">Thermometer</string>
|
||||
<string name="settings_announcements_segment_average_speed_pace">Durchschnittsgeschwindigkeit/pace (Segment)</string>
|
||||
<string name="stats_average_segment_pace">Durchschnittliche Segment Geschwindigkeit</string>
|
||||
<string name="stats_elapsed_segment_time">Verstrichene Segment Zeit</string>
|
||||
</resources>
|
||||
|
||||
@@ -94,7 +94,7 @@ limitations under the License.
|
||||
<string name="description_max_altitude">Μέγιστη ανύψωση: %1$d μέτρα (%2$d πόδια)</string>
|
||||
<string name="description_max_speed">Μέγιστη ταχύτητα: %1$.2f χλμ/ώρα (%2$.1f μίλια/ώρα)</string>
|
||||
<string name="description_min_altitude">Ελάχιστη ανύψωση: %1$d μέτρα (%2$d πόδια)</string>
|
||||
<string name="description_moving_time">Χρόνος μεταφοράς: %1$s</string>
|
||||
<string name="description_moving_time">Χρόνος κίνησης: %1$s</string>
|
||||
<string name="description_pace_imperial">Ρυθμός (λεπτά/μίλι)</string>
|
||||
<string name="description_pace_metric">Ρυθμός (λεπτά/χλμ)</string>
|
||||
<string name="description_recorded_time">Εγγραφή: %1$s</string>
|
||||
@@ -195,7 +195,7 @@ limitations under the License.
|
||||
<string name="settings_stats_units_metric">Μετρικό (χλμ, μέτρα)</string>
|
||||
<string name="settings_stats_units_title">Προτιμώμενες μονάδες</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">Νομίζω ότι μπορεί να σε ενδιαφέρει αυτό το ίχνος.\n\nΓια να δεις αυτό το ίχνος, χρησιμοποίησε το Google Earth ή άλλο λογισμικό χαρτογράφησης.\n\n%1$s</string>
|
||||
<string name="share_track_share_file_body">Νομίζω ότι μπορεί να σε ενδιαφέρει αυτό το ίχνος.\n\n%1$s</string>
|
||||
<string name="share_track_subject">Θα ήθελα να μοιραστώ μία διαδρομή μαζί σου</string>
|
||||
<!-- Stats -->
|
||||
<string name="stats_average_moving_pace">Μέσος ρυθμός κίνησης</string>
|
||||
@@ -230,22 +230,22 @@ limitations under the License.
|
||||
<!-- Tracks -->
|
||||
<!-- Unit -->
|
||||
<string name="unit_feet">πόδια</string>
|
||||
<string name="unit_kilometer">χλμ.</string>
|
||||
<string name="unit_kilometer">χλμ</string>
|
||||
<string name="unit_kilometer_per_hour">χλμ/ώρα</string>
|
||||
<string name="unit_meter">μέτρα</string>
|
||||
<string name="unit_mile">μίλι</string>
|
||||
<string name="unit_mile_per_hour">μίλια/ώρα</string>
|
||||
<string name="unit_minute_per_kilometer">λεπτά / χιλ.</string>
|
||||
<string name="unit_minute_per_kilometer">λεπτά/χλμ</string>
|
||||
<string name="unit_minute_per_mile">λεπτά/μίλι</string>
|
||||
<!-- Value -->
|
||||
<string name="value_integer_feet_excellent_gps">%1$d πόδια (τέλειο σήμα GPS)</string>
|
||||
<string name="value_integer_feet_recommended">%1$d ft (συνιστάται)</string>
|
||||
<string name="value_integer_feet_recommended">%1$d πόδια (συνιστάται)</string>
|
||||
<string name="value_integer_kilometer_hour">%1$d χλμ/ώρα</string>
|
||||
<string name="value_integer_meter_excellent_gps">%1$d μέτρα (τέλειο σήμα GPS)</string>
|
||||
<string name="value_integer_meter_poor_gps">%1$d μέτρα (χαμηλό σήμα GPS)</string>
|
||||
<string name="value_integer_meter_recommended">%1$d m (συνιστάται)</string>
|
||||
<string name="value_integer_mile_hour">%1$d μίλια/ώρα</string>
|
||||
<string name="value_integer_minute">%1$d λεπ.</string>
|
||||
<string name="value_integer_minute">%1$d λεπ</string>
|
||||
<string name="value_integer_second">%1$d δευτ</string>
|
||||
<string name="value_none">Καμία</string>
|
||||
<string name="value_off">Απενεργοποιημένο</string>
|
||||
@@ -325,4 +325,119 @@ limitations under the License.
|
||||
<string name="settings_recording_min_sampling_interval_title">Χρονικό μεσοδιάστημα δειγματοληψίας</string>
|
||||
<string name="settings_recording_location_distance_summary">%1$s μεταξύ καταγεγραμμένων τοποθεσιών</string>
|
||||
<string name="field_remove_from_layout">Αφαίρεση</string>
|
||||
<string name="value_float_kilometer_hour">%1$.1f χλμ/ώρα</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f χλμ/ώρα (συνιστάται)</string>
|
||||
<string name="value_integer_knots">%1$d κόμβοι</string>
|
||||
<string name="value_float_mile_hour">%1$.1f μίλια/ώρα</string>
|
||||
<string name="value_float_knots">%1$.1f κόμβοι</string>
|
||||
<string name="value_float_mile_hour_recommended">%1$.1f μίλια/ώρα (συνιστάται)</string>
|
||||
<string name="value_float_knots_recommended">%1$.1f κόμβοι (συνιστάται)</string>
|
||||
<string name="value_internal_sensor">Εσωτερικός αισθητήρας</string>
|
||||
<string name="value_int_seconds">%1$d δευτ (συνιστάται)</string>
|
||||
<string name="voice_per_kilometer">ανά χιλιόμετρο</string>
|
||||
<string name="voice_per_mile">ανά μίλι</string>
|
||||
<string name="voice_per_nautical_mile">ανά ναυτικό μίλι</string>
|
||||
<string name="lap_time">Χρόνος γύρου</string>
|
||||
<string name="pace">Ρυθμός</string>
|
||||
<string name="lap_speed">Ταχύτητα γύρου</string>
|
||||
<string name="speed">Μέση ταχύτητα κίνησης</string>
|
||||
<string name="total_distance">Συνολική απόσταση</string>
|
||||
<string name="voiceDistanceKilometersPlural">{n, plural, =1 {1 χιλιόμετρο} other {{n,number,#.0} χιλιόμετρα} }</string>
|
||||
<string name="voiceDistanceMilesPlural">{n, plural, =1 {1 μίλι} other {{n,number,#.0} μίλια} }</string>
|
||||
<string name="voiceDistanceNauticalMilesPlural">{n, plural, =1 {1 ναυτικό μίλι} other {{n,number,#.0} ναυτικά μίλια} }</string>
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
|
||||
<string name="voiceHoursPlural">{n, plural, =1 {1 ώρα} other {{n,number} ώρες} }</string>
|
||||
<string name="settings_import_section">Εισαγωγή</string>
|
||||
<string name="settings_api_title">Δημόσιο API</string>
|
||||
<string name="settings_public_api_enabled_title">Δημόσιο API</string>
|
||||
<string name="settings_public_api_package_title">Όνομα πακέτου για Δημόσιο API</string>
|
||||
<string name="settings_api_summary">API απομακρυσμένου ελέγχου και στατιστικών για άλλες εφαρμογές</string>
|
||||
<string name="voiceMinutesPlural">{n, plural, =1 {1 λεπτό} other {{n,number} λεπτά} }</string>
|
||||
<string name="voiceSecondsPlural">{n, plural, =1 {1 δευτερόλεπτο} other {{n,number} δευτερόλεπτα} }</string>
|
||||
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 χιλιόμετρο ανά ώρα} other {{n,number,#.0} χιλιόμετρα ανά ώρα} }</string>
|
||||
<string name="voiceSpeedMilesPerHourPlural">{n, plural, =1 {1 μίλι ανά ώρα} other {{n,number,#.0} μίλια ανά ώρα} }</string>
|
||||
<string name="voiceSpeedMKnotsPlural">{n, plural, =1 {1 κόμβος} other {{n,number,#.0} κόμβοι} }</string>
|
||||
<string name="voiceSpeed">{n, plural, =1 {1} other {{n,number,#.0}} }</string>
|
||||
<string name="description_speed_nautical">Ταχύτητα (κόμβοι)</string>
|
||||
<string name="description_sensor_temperature">Θερμοκρασία (Κελσίου)</string>
|
||||
<string name="export_with_photos">με φωτογραφίες</string>
|
||||
<string name="export_without_photos">χωρίς φωτογραφίες</string>
|
||||
<string name="export_progress_summary_errors_msg">Σφάλματα</string>
|
||||
<string name="import_progress_summary_errors_msg">Σφάλματα</string>
|
||||
<string name="import_progress_summary_exists_msg">Υπάρχει ήδη</string>
|
||||
<string name="import_error_info">%1$s: %2$s</string>
|
||||
<string name="gps_recording_status">%1$s: %2$s</string>
|
||||
<string name="generic_undo">Αναίρεση</string>
|
||||
<string name="generic_filter">Φίλτρο</string>
|
||||
<string name="menu_filter">Φίλτρο</string>
|
||||
<string name="menu_voice_rate">Ταχύτητα φωνής</string>
|
||||
<string name="sensor_state_cadence_avg">Μέσο πεταλάρισμα</string>
|
||||
<string name="sensor_state_cadence_max">Μέγιστο πεταλάρισμα</string>
|
||||
<string name="sensor_state_temperature">Θερμόμετρο</string>
|
||||
<string name="sensor_state_pressure">Βαρόμετρο</string>
|
||||
<string name="bluetooth_sensor_summary">%1$s (%2$s)</string>
|
||||
<string name="bluetooth_disabled">Παρακαλούμε ενεργοποιήστε το Bluetooth.</string>
|
||||
<string name="settings_ui_title">Διεπαφή Χρήστη</string>
|
||||
<string name="settings_ui_summary">Εμφάνιση, Διάταξη Εγγραφής, Θέμα</string>
|
||||
<string name="settings_gps_sensor">GPS</string>
|
||||
<string name="description_speed_source_gps">GPS</string>
|
||||
<string name="settings_sensors_title">Αισθητήρες</string>
|
||||
<string name="settings_sensors_summary">Αισθητήρες Ποδηλασίας, Περιφέρεια Ρόδας, Αισθητήρες Τρεξίματος</string>
|
||||
<string name="settings_defaults_title">Προεπιλεγμένες Μονάδες και Δραστηριότητα</string>
|
||||
<string name="settings_announcements_summary">Χρόνος, Απόσταση, Ταχύτητα Φωνής</string>
|
||||
<string name="settings_import_export_title">Εισαγωγή και Εξαγωγή</string>
|
||||
<string name="settings_import_export_summary">Εισαγωγή, Εξαγωγή, Αυτόματη Εξαγωγή</string>
|
||||
<string name="settings_units_category">Μονάδες</string>
|
||||
<string name="settings_activity_defaults_category">Προεπιλογές Δραστηριότητας</string>
|
||||
<string name="settings_reset_summary">Επαναφορά Ρυθμίσεων στις Προεπιλεγμένες Τιμές</string>
|
||||
<string name="generic_from">Από</string>
|
||||
<string name="generic_to">Μέχρι</string>
|
||||
<string name="generic_today">Σήμερα</string>
|
||||
<string name="generic_yesterday">Χθες</string>
|
||||
<string name="generic_dismiss">Παράβλεψη</string>
|
||||
<string name="generic_completed">Ολοκληρώθηκε</string>
|
||||
<string name="generic_click_twice_cancel">Πατήστε ξανά πίσω για ακύρωση</string>
|
||||
<string name="generic_choose_an_option">Επιλέξτε μια επιλογή</string>
|
||||
<string name="generic_skip">Παράλειψη</string>
|
||||
<string name="generic_separator_done_total">/</string>
|
||||
<string name="generic_show_errors">Εμφάνιση σφαλμάτων</string>
|
||||
<plurals name="generic_completed_with_errors">
|
||||
<item quantity="one">Ολοκληρώθηκε με ένα σφάλμα</item>
|
||||
<item quantity="other">Ολοκληρώθηκε με %1$d σφάλματα</item>
|
||||
</plurals>
|
||||
<string name="generic_overwrite">Αντικατάσταση</string>
|
||||
<string name="gps_disabled_msg">GPS απενεργοποιημένο</string>
|
||||
<string name="gps_fixed_and_ready">GPS σταθερό και έτοιμο</string>
|
||||
<string name="image_discard">Απόρριψη</string>
|
||||
<string name="menu_select_layout">Επιλογή διάταξης</string>
|
||||
<string name="custom_layout_select_layout">Επιλογή διάταξης</string>
|
||||
<string name="settings_announcements_distance_frequency">Μήκος γύρου</string>
|
||||
<string name="settings_locale_title">Γλώσσα</string>
|
||||
<string name="settings_locale_system_default">Προεπιλογή συστήματος</string>
|
||||
<string name="settings_night_mode_option_system">Σύστημα</string>
|
||||
<string name="settings_night_mode_option_day">Μέρα</string>
|
||||
<string name="settings_night_mode_option_night">Νύχτα</string>
|
||||
<string name="custom_layout_list_title">Διατάξεις</string>
|
||||
<string name="stats_custom_layout_default_layout">Προεπιλεγμένη διάταξη</string>
|
||||
<string name="settings_all_reset">Επαναφορά</string>
|
||||
<string name="settings_recording_fullscreen_on_while_recording_title">Πλήρης οθόνη</string>
|
||||
<string name="settings_recording_show_gps_disabled_warning_title">Προειδοποίηση απενεργοποιημένου GPS</string>
|
||||
<string name="settings_recording_keep_screen_on_while_recording_title">Διατήρηση της οθόνης ενεργοποιημένης</string>
|
||||
<string name="settings_recording_show_on_lockscreen_while_recording_title">Εμφάνιση στατιστικών στο κλείδωμα οθόνης</string>
|
||||
<string name="settings_ui_dynamic_colors_title">Δυναμικά Χρώματα</string>
|
||||
<string name="settings_recording_customize_layout_title">Προσαρμόστε τη διάταξη εγγραφής</string>
|
||||
<string name="settings_sensor_cycling_cadence">Πεταλάρισμα</string>
|
||||
<string name="stats_sensors_cadence">Πεταλάρισμα</string>
|
||||
<string name="stats_sensors_temperature">Θερμοκρασία</string>
|
||||
<string name="stats_clock">Ρολόι</string>
|
||||
<string name="track_recording_notification_accuracy">Ακρίβεια τοποθεσίας: %1$s</string>
|
||||
<string name="track_distance_notification">Απόσταση: %1$s</string>
|
||||
<string name="track_speed_notification">Ταχύτητα: %1$s</string>
|
||||
<string name="track_pace_notification">Ρυθμός: %1$s</string>
|
||||
<string name="stats_coordinates">Γεωγραφικό πλάτος/Γεωγραφικό μήκος</string>
|
||||
<string name="settings_sensor_running_speed_and_cadence">Απόσταση, Ταχύτητα και Πεταλάρισμα</string>
|
||||
<string name="settings_sensor_cycling_distance_speed">Απόσταση και Ταχύτητα</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_title">Φιλτράτισμα Συσκευών Bluetooth</string>
|
||||
<string name="settings_sensor_wheel_circumference">Περιφέρεια Ρόδας (mm)</string>
|
||||
<string name="settings_public_api_dashboard_enabled_title">Αυτόματη μεταφορά δεδομένων</string>
|
||||
</resources>
|
||||
|
||||
@@ -503,7 +503,7 @@ limitations under the License.
|
||||
<string name="permission_bluetooth_failed_rejected">Es necesario conceder manualmente a OpenTracks el acceso a los Dispositivos Cercanos en la configuración del sistema.</string>
|
||||
<string name="settings_running_sensor">Correr</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="generic_today">Hoy</string>
|
||||
<string name="generic_yesterday">Ayer</string>
|
||||
<string name="menu_add_custom_layout">Nuevo diseño</string>
|
||||
@@ -654,7 +654,6 @@ limitations under the License.
|
||||
<string name="settings_announcements_lap_power">Potencia por vuelta</string>
|
||||
<string name="power_x_watt">Potencia {x, plural, =1 {1 vatio} otros {# vatios} }</string>
|
||||
<string name="create_marker_error">No se ha podido crear un marcador. Inténtelo de nuevo.</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power">Potencia</string>
|
||||
<string name="settings_locale_title">Idioma</string>
|
||||
<string name="settings_locale_system_default">Predeterminado del sistema</string>
|
||||
|
||||
@@ -442,7 +442,7 @@ limitations under the License.
|
||||
<string name="settings_reset_summary">Lähtesta seadistused vaikeväärtusteks</string>
|
||||
<string name="settings_recording_customize_layout_title">Kohanda oma salvestuse paigutust</string>
|
||||
<string name="settings_recording_customize_layout_select_columns">Andmeväljad rea kohta</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="settings_announcements_totaltime_frequency">Ajavahemik</string>
|
||||
<string name="settings_layout_reset">Lähtesta kohandatud paigutus</string>
|
||||
<string name="settings_layout_reset_confirm_title">Kas lähtestad oma paigutused?</string>
|
||||
@@ -595,8 +595,8 @@ limitations under the License.
|
||||
<string name="show_on_dashboard_not_installed">Pole paigaldatud ühtegi rakendust, mis toetaks OpenTracksi juhtpaneeli API-t.</string>
|
||||
<string name="introduction_osm_dashboard">Kaardi kuvamine pole OpenTracks-i automaatselt lisatud. Oma salvestiste kaardil vaatamiseks palun paigalda OSMDashboard.</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">Proovivõtu ajavahemik</string>
|
||||
<string name="voice_announce_lap_power_key">RingiVõimsuseHäälteade</string>
|
||||
<string name="description_sensor_temperature">Temperatuur (Celsius)</string>
|
||||
<string name="sensor_state_temperature">Termomeeter</string>
|
||||
<string name="stats_sensors_temperature">Temperatuur</string>
|
||||
<string name="activity_type_gravel_biking">rattasõit kruusateedel</string>
|
||||
</resources>
|
||||
|
||||
@@ -458,7 +458,7 @@ GPS gailuak datu okerrak salatzen baditu (adibidez, kokapena, abiadura, kota), O
|
||||
<string name="settings_layout_reset_done">Diseinuaren ezarpen guztiak balio lehenetsietara itzuli dira</string>
|
||||
<string name="settings_all_reset">Berrezarri</string>
|
||||
<string name="settings_import_section">Inportatu</string>
|
||||
<string name="settings_export_section">Esportatu</string>
|
||||
<string name="settings_export_as_file_section">Esportatu</string>
|
||||
<string name="stats_coordinates">Latitudea /Longitudea</string>
|
||||
<string name="stats_clock">Erlojua</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -490,7 +490,7 @@ limitations under the License.
|
||||
<string name="stats_sensors_power">Teho</string>
|
||||
<string name="no_compatible_file_manager_installed">Yhteensopiva tiedostojen hallinta puuttuu</string>
|
||||
<string name="settings_export_filename_title">Tiedostonimen muoto</string>
|
||||
<string name="settings_export_section">Vienti</string>
|
||||
<string name="settings_export_as_file_section">Vienti</string>
|
||||
<string name="settings_recording_customize_layout_title">Muokkaa tallennuksen asettelua</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Näytä vain Bluetooth-laitteet, jotka ilmoittavat tarvittavat palvelut</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Tallennettuja tietoja ei jaeta automaattisesti muiden sovellusten kanssa.</string>
|
||||
|
||||
@@ -505,7 +505,7 @@ Si le dispositif GPS signale des données inexactes (par exemple, la localisatio
|
||||
<string name="settings_cycling_sensor">Cyclisme</string>
|
||||
<string name="settings_running_sensor">Course à pied</string>
|
||||
<string name="settings_import_section">Importer</string>
|
||||
<string name="settings_export_section">Exporter</string>
|
||||
<string name="settings_export_as_file_section">Exporter</string>
|
||||
<string name="settings_reset_summary">Réinitialiser les paramètres aux valeurs par défaut</string>
|
||||
<string name="settings_activity_defaults_category">Valeurs par défaut de l\'activité</string>
|
||||
<string name="settings_sensors_summary">Capteurs de cyclisme, Circonférence de roue, Capteurs de course à pied</string>
|
||||
@@ -660,7 +660,6 @@ Si le dispositif GPS signale des données inexactes (par exemple, la localisatio
|
||||
<string name="settings_locale_system_default">Paramètres par défaut du système</string>
|
||||
<string name="settings_locale_title">Langue</string>
|
||||
<string name="stats_gain_loss">Gain et perte</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_chart_display_pace_or_speed_title">Afficher le pas ou la vitesse</string>
|
||||
<string name="settings_chart_display_elevation_title">Afficher l\'altitude</string>
|
||||
<string name="settings_chart_display_heart_rate_title">Afficher la fréquence cardiaque</string>
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<string name="settings_public_api_enabled_summary_off">Ní féidir ach le OpenTracks taifeadtaí a thosú agus a stopadh.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Ní dhéanfar sonraí taifeadta a roinnt go huathoibríoch le feidhmchláir eile.</string>
|
||||
<string name="settings_import_section">Iompórtáil</string>
|
||||
<string name="settings_export_section">Easpórtáil</string>
|
||||
<string name="settings_export_as_file_section">Easpórtáil</string>
|
||||
<string name="share_track_share_file_body">Sílim go mb\'fhéidir go mbeadh suim agat sa rian seo.
|
||||
\n
|
||||
\n%1$s</string>
|
||||
@@ -510,7 +510,6 @@
|
||||
<string name="sensor_state_cadence_unit">rpm</string>
|
||||
<string name="sensor_state_heart_rate_unit">bpm</string>
|
||||
<string name="settings_night_mode_option_night_oled">OLED-chairdiúil</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power">Cumhacht</string>
|
||||
<string name="value_int_seconds">%1$d s (molta)</string>
|
||||
<string name="menu_export_all_one_file">Easpórtáil gach ceann (comhad amháin do gach traic)</string>
|
||||
|
||||
@@ -443,7 +443,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Correr</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_announcements_distance_frequency">Lonxitude da volta</string>
|
||||
<string name="permission_bluetooth_failed">OpenTracks require permiso para usar o Bluetooth.</string>
|
||||
<string name="permission_bluetooth_failed_rejected">Tes que permitirlle a OpenTracks nos axustes do sistema que acceda a Dispositivos Próximos.</string>
|
||||
@@ -600,11 +600,11 @@ limitations under the License.
|
||||
<string name="settings_night_mode_option_day_dynamic">Día (Cores Dinámicas)</string>
|
||||
<string name="settings_night_mode_option_night_dynamic">Noite (Cores Dinámicas)</string>
|
||||
<string name="settings_night_mode_option_night_oled_dynamic">para OLED (Cores Dinámicas)</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_announcements_segment_average_speed_pace">Velocidade/Ritmo medios (Tramo)</string>
|
||||
<string name="stats_average_segment_pace">Ritmo medio no Tramo</string>
|
||||
<string name="stats_elapsed_segment_time">Tempo transcurrido no tramo</string>
|
||||
<string name="stats_sensors_temperature">Temperatura</string>
|
||||
<string name="description_sensor_temperature">Temperatura (Celsius)</string>
|
||||
<string name="sensor_state_temperature">Termómetro</string>
|
||||
<string name="activity_type_gravel_biking">ciclismo por grava</string>
|
||||
</resources>
|
||||
|
||||
@@ -305,7 +305,7 @@ limitations under the License.
|
||||
<string name="waypoint_type_water_fountain">fontana</string>
|
||||
<string name="waypoint_type_waypoint">točka na ruti</string>
|
||||
<string name="settings_import_section">Uvoz</string>
|
||||
<string name="settings_export_section">Izvoz</string>
|
||||
<string name="settings_export_as_file_section">Izvoz</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Spriječi ponovni uvoz ruta</string>
|
||||
<string name="menu_export_all_one_file">Izvezi sve (jedna datoteka za sve rute)</string>
|
||||
<string name="menu_select_layout">Odaberi raspored</string>
|
||||
@@ -594,7 +594,6 @@ limitations under the License.
|
||||
<string name="create_marker_error">Nije moguće stvoriti oznaku. Pokušaj ponovo.</string>
|
||||
<string name="settings_night_mode_option_night_oled">Noć (pogodno za OLED)</string>
|
||||
<string name="power_x_watt">Snaga {x, plural, =1 {1 watt} few {# watta} other {# watta} }</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_announcements_lap_power">Snaga runde</string>
|
||||
<string name="power">Snaga</string>
|
||||
</resources>
|
||||
|
||||
@@ -467,7 +467,7 @@ limitations under the License.
|
||||
<string name="average_heart_rate">Átlagos pulzusszám</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Csak azokat a Bluetooth-eszközöket jelenítse meg, amelyek szükséges szolgáltatásokat kérnek</string>
|
||||
<string name="settings_import_section">Importálás</string>
|
||||
<string name="settings_export_section">Exportálás</string>
|
||||
<string name="settings_export_as_file_section">Exportálás</string>
|
||||
<string name="settings_layout_reset_confirm_title">Az elrendezések visszaállítása?</string>
|
||||
<string name="settings_layout_reset_done">Az összes elrendezési beállítás visszaállt az alapértelmezett értékekre</string>
|
||||
<string name="custom_layout_list_layout_removed">Az elrendezést eltávolították</string>
|
||||
@@ -578,7 +578,6 @@ limitations under the License.
|
||||
<string name="settings_announcements_lap_power">Kör teljesítmény</string>
|
||||
<string name="create_marker_error">Nem sikerült jelölőt létrehozni. Próbálja újra.</string>
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power_x_watt">Teljesítmény {x, plural, =1 {1 watt} other {# watts} }</string>
|
||||
<string name="settings_locale_title">Nyelv</string>
|
||||
<string name="settings_locale_system_default">Rendszer alapértelmezett</string>
|
||||
|
||||
@@ -448,7 +448,7 @@ Infatti, un\'applicazione deve supportare <i>ACTION_VIEW</i> con MIME <i>applica
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Corsa</string>
|
||||
<string name="settings_import_section">Importa</string>
|
||||
<string name="settings_export_section">Esporta</string>
|
||||
<string name="settings_export_as_file_section">Esporta</string>
|
||||
<string name="stats_coordinates">Latitudine/Longitudine</string>
|
||||
<string name="field_remove_from_layout">Rimuovi</string>
|
||||
<string name="field_set_primary">Primario</string>
|
||||
@@ -549,7 +549,6 @@ Infatti, un\'applicazione deve supportare <i>ACTION_VIEW</i> con MIME <i>applica
|
||||
<string name="voiceDistanceMilesPlural">{n, plural, =1 {un miglio} other {{n,number,#.0} miglia} }</string>
|
||||
<string name="no_compatible_gallery_installed">Nessuna delle applicazioni galleria (foto) installate è compatibile</string>
|
||||
<string name="no_compatible_camera_installed">Nessuna delle applicazioni telecamera installate è compatibile</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_announcements_idle_category_title">Annunci di inattività</string>
|
||||
<string name="settings_announcements_idle_title">Inattività</string>
|
||||
<string name="settings_recording_title">Registrazione</string>
|
||||
|
||||
@@ -150,7 +150,7 @@ limitations under the License.
|
||||
<string name="menu_aggregated_statistics">集計統計</string>
|
||||
<string name="menu_delete">削除</string>
|
||||
<string name="menu_edit">編集</string>
|
||||
<string name="menu_export_all">すべてエクスポート</string>
|
||||
<string name="menu_export_all">すべてエクスポート(トラックごとに別ファイル)</string>
|
||||
<string name="menu_help">ヘルプ</string>
|
||||
<string name="menu_import_all">すべてインポート</string>
|
||||
<string name="menu_insert_marker">マーカーを追加</string>
|
||||
@@ -313,4 +313,24 @@ limitations under the License.
|
||||
<string name="import_progress_summary_ok_msg">インポート完了</string>
|
||||
<string name="import_progress_summary_exists_msg">既に存在しています</string>
|
||||
<string name="help_track_map_content">小さな地図のアイコンを使います.\n a) (live updates) <a href="https://codeberg.org/OpenTracksApp/OSMDashboard">OSMDashboard</a>をインストールして下さい。 <a href="https://www.openstreetmap.org">OpenStreetMap</a> を利用したオンラインまたはオフラインの地図を使用できるようになります。\n実際、OSMDashbordは (一時的に保存された) 表示するトラッキングデータにだけアクセスする独立したアプリケーションです。\n b) 一般的な地図アプリケーションを使用 (e.g., <a href="http://osmand.net/">OsmAnd</a> and <a href="https://maps.me/">MAPS.ME</a>) 。 KMZフォーマットをサポートしている必要があります (位置及びタイムスタンプのみを含む)。 加えて、 MIME <i>application/vnd.google-earth.kmz</i> の <i>ACTION_VIEW</i> をサポートしている必要もあります。</string>
|
||||
<string name="help_storage_pictures_title">マーカーとして撮影した写真の保存場所</string>
|
||||
<string name="help_storage_pictures_content">アプリケーションの内部ストレージ(つまり、OpenTracks内部からのみアクセス可能な場所)に保存されます。</string>
|
||||
<string name="help_track_export_title">記録したトラックのエクスポート/共有方法</string>
|
||||
<string name="stats_sensors_temperature">温度</string>
|
||||
<string name="settings_announcements_average_heart_rate">平均心拍数</string>
|
||||
<string name="description_speed_source_gps">GPS</string>
|
||||
<string name="export_dir_not_writable">" (書き込み失敗!)"</string>
|
||||
<string name="export_progress_summary_overwrite_msg">上書きされたファイル</string>
|
||||
<string name="export_progress_summary_skip_msg">スキップされたファイル</string>
|
||||
<string name="export_progress_summary_errors_msg">エラー</string>
|
||||
<string name="generic_separator_done_total">/</string>
|
||||
<string name="generic_today">今日</string>
|
||||
<string name="generic_yesterday">昨日</string>
|
||||
<string name="gps_disabled_msg">GPSが無効化されています</string>
|
||||
<string name="import_no_kml_file_found">KMZ中にkmlファイルが見つかりませんでした</string>
|
||||
<string name="import_error_info">%1$s: %2$s</string>
|
||||
<string name="create_marker_error">マーカーを作成することができませんでした。もう一度やり直して下さい。</string>
|
||||
<string name="menu_export_all_one_file">すべてエクスポート(全てのトラックを1つのファイルに)</string>
|
||||
<string name="menu_select_layout">レイアウトを選択</string>
|
||||
<string name="permission_gps_failed">OpenTracksはGPSにアクセスする権限が必要です。</string>
|
||||
</resources>
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">ರೆಕಾರ್ಡಿಂಗ್ ಅನ್ನು ಪ್ರಾರಂಭಿಸಿದ ಅಪ್ಲಿಕೇಶನ್ ರೆಕಾರ್ಡ್ ಮಾಡಿದ ಡೇಟಾವನ್ನು ಸಹ ಪ್ರವೇಶಿಸಬಹುದು.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">ರೆಕಾರ್ಡ್ ಮಾಡಲಾದ ಡೇಟಾವನ್ನು ಇತರ ಅಪ್ಲಿಕೇಶನ್ಗಳೊಂದಿಗೆ ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ.</string>
|
||||
<string name="settings_import_section">ಆಮದು</string>
|
||||
<string name="settings_export_section">ರಫ್ತು</string>
|
||||
<string name="settings_export_as_file_section">ರಫ್ತು</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">ಟ್ರ್ಯಾಕ್ಗಳ ಮರು-ಆಮದು ತಡೆಯಿರಿ</string>
|
||||
<string name="share_track_share_file_body">ನೀವು ಈ ಟ್ರ್ಯಾಕ್ನಲ್ಲಿ ಆಸಕ್ತಿ ಹೊಂದಿರಬಹುದು ಎಂದು ನಾನು ಭಾವಿಸುತ್ತೇನೆ.
|
||||
\n
|
||||
|
||||
@@ -437,7 +437,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Sykling</string>
|
||||
<string name="settings_running_sensor">Løping</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="value_float_kilometer_hour">%1$.1f km/t</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/t (anbefales)</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
@@ -588,4 +588,8 @@ limitations under the License.
|
||||
<string name="stats_sensors_temperature">Temperatur</string>
|
||||
<string name="sensor_state_temperature">Termometer</string>
|
||||
<string name="description_sensor_temperature">Temperatur (Celsius)</string>
|
||||
<string name="activity_type_gravel_biking">gravelsykling</string>
|
||||
<string name="settings_night_mode_option_day_dynamic">Dag (DynamicColor)</string>
|
||||
<string name="settings_night_mode_option_night_dynamic">Natt (DynamicColor)</string>
|
||||
<string name="settings_night_mode_option_night_oled_dynamic">OLED-vennlig (DynamicColor)</string>
|
||||
</resources>
|
||||
|
||||
@@ -465,7 +465,7 @@ Als het GPS-apparaat onnauwkeurige gegevens rapporteert (bijv. locatie, snelheid
|
||||
<string name="settings_cycling_sensor">Fietsen</string>
|
||||
<string name="settings_running_sensor">Hardlopen</string>
|
||||
<string name="settings_import_section">Invoer</string>
|
||||
<string name="settings_export_section">Uitvoer</string>
|
||||
<string name="settings_export_as_file_section">Uitvoer</string>
|
||||
<string name="settings_generic_sensor">Generiek</string>
|
||||
<string name="export_dir_not_writable">" (geen schrijfrecht!)"</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/u (aanbevolen)</string>
|
||||
@@ -581,7 +581,6 @@ Als het GPS-apparaat onnauwkeurige gegevens rapporteert (bijv. locatie, snelheid
|
||||
<string name="settings_announcements_unit">Afstand-/snelheidseenheid</string>
|
||||
<string name="voiceSpeed">{n, plural, =1 {1} other {{n,number,#.0}} }</string>
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power">Power</string>
|
||||
<string name="settings_announcements_lap_power">Lap power</string>
|
||||
<string name="power_x_watt">Power {x, plural, =1 {1 watt} other {# watt} }</string>
|
||||
|
||||
@@ -450,7 +450,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Kolarstwo</string>
|
||||
<string name="settings_running_sensor">Bieganie</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="settings_generic_sensor">Domyślny</string>
|
||||
<string name="description_speed_source_gps">GPS</string>
|
||||
<string name="permission_bluetooth_failed">OpenTracks wymaga uprawnień do urzycia Bluetooth.</string>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<string name="activity_type_train">treino</string>
|
||||
<string name="activity_type_ultimate_frisbee">frisbee definitivo</string>
|
||||
<string name="activity_type_unknown">desconhecida</string>
|
||||
<string name="activity_type_wakeboarding">esqui aquático</string>
|
||||
<string name="activity_type_wakeboarding">wakeboarding</string>
|
||||
<string name="activity_type_walking">caminhada</string>
|
||||
<string name="activity_type_water_skiing">esqui aquático</string>
|
||||
<string name="activity_type_wind_surfing">windsurfe</string>
|
||||
@@ -136,7 +136,7 @@
|
||||
<string name="recording_service">Serviço de gravação</string>
|
||||
<string name="sensor_state_heart_rate">Frequência cardíaca</string>
|
||||
<string name="sensor_state_heart_rate_value">%1$d bom</string>
|
||||
<string name="sensor_state_power">Alimentação</string>
|
||||
<string name="sensor_state_power">Potência</string>
|
||||
<string name="settings_advanced">Avançado</string>
|
||||
<string name="settings_chart_by_distance">Por distância</string>
|
||||
<string name="settings_chart_by_time">Por tempo</string>
|
||||
@@ -280,7 +280,7 @@
|
||||
<string name="settings_default_export_uri_title">Diretório de exportação de trilhas</string>
|
||||
<string name="stats_split_interval">Intervalo de divisão</string>
|
||||
<string name="track_recording_notification_accuracy">Precisão da localização: %1$s</string>
|
||||
<string name="about_preference_title">Sobre OpenTracks</string>
|
||||
<string name="about_preference_title">Sobre</string>
|
||||
<string name="about_description"><i>OpenTracks</i> é um aplicativo de rastreamento de esportes que respeita totalmente a sua privacidade.</string>
|
||||
<string name="help_feature_content">* criar trilhas de GPS
|
||||
\n* use um sensor de frequência cardíaca (apenas Bluetooth LE)
|
||||
@@ -288,10 +288,7 @@
|
||||
\n* criar marcadores (incluindo imagens)
|
||||
\n* funciona completamente offline
|
||||
\n* mostrar trilhas em um mapa (veja abaixo)</string>
|
||||
<string name="help_track_map_content"> Use o pequeno ícone do mapa.
|
||||
\n a) (atualizações ao vivo) Instale <a href="https://codeberg.org/OpenTracksApp/OSMDashboard">OSMDashboard</a>. Ele fornece mapas online ou offline via <a href="https://www.openstreetmap.org">OpenStreetMap</a>.
|
||||
\n Na verdade, OSMDashboard é um aplicativo separado que só pode acessar (temporariamente) os dados da(s) trilha(s) que serão mostradas.
|
||||
\n b) Por meio de um aplicativo de mapa normal (por exemplo, <a href="http://osmand.net/">OsmAn </a> e <a href="https://maps.me/">MAPS.ME</a>). Esses aplicativos precisam ser compatíveis com o formato de arquivo KMZ (apenas locais e marcações de data/hora). Na verdade, um aplicativo precisa ser compatível com<i>ACTION_VIEW</i> com MIME<i> application/vnd.google-earth.kmz</i>. </string>
|
||||
<string name="help_track_map_content">Use o pequeno ícone no mapa.\n a) (atualizações ao vivo) Instale <a href="https://codeberg.org/OpenTracksApp/OSMDashboard">OSMDashboard</a>. Ele fornece mapas online ou offline via <a href="https://www.openstreetmap.org">OpenStreetMap</a>.\n Na verdade, OSMDashboard é um aplicativo separado que só pode acessar (temporariamente) os dados da(s) trilha(s) que serão mostradas. \n b) Por meio de um aplicativo de mapa normal (por exemplo:, <a href="http://osmand.net/">OsmAnd</a> e <a href="https://maps.me/">MAPS.ME</a>). Esses aplicativos precisam ser compatíveis com o formato de arquivo KMZ (apenas locais e marcações de data/hora). Na verdade, um aplicativo precisa ser compatível com <i>ACTION_VIEW</i> com MIME <i>application/vnd.google-earth.kmz</i>.</string>
|
||||
<string name="help_data_storage_content"> Os dados do sensor são armazenados por local registrado. Em termos de sensor de frequência cardíaca, isso significa que apenas <i>uma</i> medição de frequência cardíaca é armazenada por local. Se você estiver parado ou dentro de casa, nenhum dado do sensor será registrado (mas será mostrado). </string>
|
||||
<string name="help_support_content"> Você pode:
|
||||
\n a) espalhe a palavra sobre OpenTracks,
|
||||
@@ -304,6 +301,7 @@
|
||||
<string name="activity_type_escooter">escolta</string>
|
||||
<plurals name="generic_completed_with_errors">
|
||||
<item quantity="one">Concluído com %1$d erro</item>
|
||||
<item quantity="many">Concluído com %1$d erros</item>
|
||||
<item quantity="other">Concluído com %1$d erros</item>
|
||||
</plurals>
|
||||
<string name="import_unsupported_format">Formato de arquivo não suportado</string>
|
||||
@@ -372,7 +370,7 @@
|
||||
<string name="import_progress_summary_exists_msg">Já existe</string>
|
||||
<string name="import_progress_summary_errors_msg">Erros</string>
|
||||
<string name="sensor_state_heart_rate_max">Freqüência cardíaca máxima</string>
|
||||
<string name="sensor_state_power_avg">Energia Avg</string>
|
||||
<string name="sensor_state_power_avg">Potência Média</string>
|
||||
<string name="sensor_state_heart_rate_avg">Ritmo cardíaco Avg</string>
|
||||
<string name="sensor_state_cadence_max">Ritmo cardíaco médio</string>
|
||||
<string name="sensor_state_cadence_avg">Avg Cadência</string>
|
||||
@@ -396,7 +394,7 @@
|
||||
<string name="settings_layout_reset_done">Todas as configurações de layout foram revertidas para os valores padrão</string>
|
||||
<string name="settings_all_reset">Redefinir</string>
|
||||
<string name="stats_coordinates">Latitude/Longitude</string>
|
||||
<string name="stats_sensors_power">Poder</string>
|
||||
<string name="stats_sensors_power">Potência</string>
|
||||
<string name="field_set_primary">Primário</string>
|
||||
<string name="field_set_secondary">Secundário</string>
|
||||
<string name="field_remove_from_layout">Remover</string>
|
||||
@@ -412,7 +410,7 @@
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Correndo</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="value_float_kilometer_hour">%1$.1f km/h</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/h (Recomendado)</string>
|
||||
<string name="stats_clock">Relógio</string>
|
||||
@@ -500,4 +498,75 @@
|
||||
<string name="show_on_dashboard">Painel API do OpenTracks</string>
|
||||
<string name="always">Sempre</string>
|
||||
<string name="description_speed_nautical">Velocidade (nós)</string>
|
||||
</resources>
|
||||
<string name="help_data_google_play_services_content">Não</string>
|
||||
<string name="activity_type_gravel_biking">ciclismo de gravel</string>
|
||||
<string name="activity_type_kickscooter">patinete</string>
|
||||
<string name="description_sensor_temperature">Temperatura (Celsius)</string>
|
||||
<string name="create_marker_error">Incapaz de criar um marcador. Tente outra vez.</string>
|
||||
<string name="marker_detail_title">%1$d de %2$d: %3$s</string>
|
||||
<string name="menu_export_all_one_file">Exportar tudo (Um arquivo para todas as trilhas)</string>
|
||||
<string name="permission_recording_failed">OpenTracks precisa de permissão para acessar a sua localização e dispositivos próximos.</string>
|
||||
<string name="sensor_state_cadence_unit">rpm</string>
|
||||
<string name="sensor_state_heart_rate_unit">bpm</string>
|
||||
<string name="sensor_state_temperature">Termômetro</string>
|
||||
<string name="sensor_state_pressure">Barômetro</string>
|
||||
<string name="sensor_state_power_max">Potência Máxima</string>
|
||||
<string name="sensor_state_power_unit">W</string>
|
||||
<string name="bluetooth_sensor_summary">%1$s (%2$s)</string>
|
||||
<string name="settings_announcements_idle_category_title">Anúncios de Inatividade</string>
|
||||
<string name="settings_announcements_idle_title">Inativo</string>
|
||||
<string name="settings_charts_title">Gráficos</string>
|
||||
<string name="settings_chart_display_elevation_title">Mostrar elevação</string>
|
||||
<string name="settings_chart_display_pace_or_speed_title">Mostrar ritmo ou velocidade</string>
|
||||
<string name="settings_chart_display_heart_rate_title">Mostrar frequência cardíaca</string>
|
||||
<string name="settings_recording_title">Gravação</string>
|
||||
<string name="settings_theme_switch_restart">Mudanças de tema podem exigir uma reinicialização manual.</string>
|
||||
<string name="settings_ui_dynamic_colors_title">Cores Dinâmicas</string>
|
||||
<string name="settings_ui_dynamic_colors_summary">Utilizar Cores Dinâmicas definidas pelo usuário (Android 12+ apenas; necessita de reinicialização manual.)</string>
|
||||
<string name="settings_recording_show_gps_disabled_warning_title">Aviso de GPS desabilitado</string>
|
||||
<string name="settings_recording_show_gps_disabled_warning_summary">Ao gravar, exibir um aviso se o GPS estiver desabilitado.</string>
|
||||
<string name="settings_recording_min_required_accuracy_summary">Localização é ignorada se sua precisão for maior que %1$s</string>
|
||||
<string name="settings_recording_idle_timeout_title">Limiar inativo</string>
|
||||
<string name="settings_locale_title">Língua</string>
|
||||
<string name="settings_locale_system_default">Padrão do sistema</string>
|
||||
<string name="settings_night_mode_option_system_dynamic">Sistema (Cores Dinâmicas)</string>
|
||||
<string name="settings_night_mode_option_day_dynamic">Dia (Cores Dinâmicas)</string>
|
||||
<string name="settings_night_mode_option_night_dynamic">Noite (Cores Dinâmicas)</string>
|
||||
<string name="settings_night_mode_option_night_oled">OLED compatível</string>
|
||||
<string name="settings_night_mode_option_night_oled_dynamic">Compatível OLED (Cores Dinâmicas)</string>
|
||||
<string name="settings_announcements_segment_average_speed_pace">Velocidade/ritmo médio (Segmento)</string>
|
||||
<string name="settings_announcements_unit">Unidade de distância/velocidade</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_title">Filtrar Dispositivos Bluetooth</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_off">Mostrar todos os dispositivos Bluetooth detectáveis</string>
|
||||
<string name="settings_sensor_heart_rate_max">Zonas: Frequência cardíaca máxima (bpm)</string>
|
||||
<string name="settings_stats_units_imperial_meter">Imperial (mi, m)</string>
|
||||
<string name="settings_api_summary">API de controle remoto e estatísticas para outros apps</string>
|
||||
<string name="stats_average_segment_pace">Ritmo médio no segmento</string>
|
||||
<string name="stats_elapsed_segment_time">Tempo decorrido no segmento</string>
|
||||
<string name="stats_gain_loss">Ganhos e Perdas</string>
|
||||
<string name="stats_sensors_temperature">Temperatura</string>
|
||||
<string name="track_edit_time_offset">Deslocamento de fuso horário</string>
|
||||
<string name="value_internal_sensor">Sensor interno</string>
|
||||
<string name="value_int_seconds">%1$d s (recomendado)</string>
|
||||
<string name="voiceIdle">Ficando inativo.</string>
|
||||
<string name="voiceHoursPlural">{n, plural, =1 {1 hora} other {{n,number} horas} }</string>
|
||||
<string name="voiceMinutesPlural">{n, plural, =1 {1 minuto} other {{n,number} minutos} }</string>
|
||||
<string name="voiceSecondsPlural">{n, plural, =1 {1 segundo} other {{n,number} segundos} }</string>
|
||||
<string name="voiceSpeedKilometersPerHourPlural">{n, plural, =1 {1 quilômetro por hora} other {{n,number,#.0} quilômetros por hora} }</string>
|
||||
<string name="voiceSpeedMilesPerHourPlural">{n, plural, =1 {1 milha por hora} other {{n,number,#.0} milhas por hora} }</string>
|
||||
<string name="voiceSpeedMKnotsPlural">{n, plural, =1 {1 nó} other {{n,number,#.0} nós} }</string>
|
||||
<string name="voiceSpeed">{n, plural, =1 {1} other {{n,number,#.0}} }</string>
|
||||
<string name="voice_on_device_speaker_title">Use o alto-falante do dispositivo</string>
|
||||
<string name="voiceDistanceKilometersPlural">{n, plural, =1 {1 quilômetro} other {{n,number,#.0} quilômetros} }</string>
|
||||
<string name="voiceDistanceMilesPlural">{n, plural, =1 {1 milha} other {{n,number,#.0} milhas} }</string>
|
||||
<string name="voiceDistanceNauticalMilesPlural">{n, plural, =1 {1 milha náutica} other {{n,number,#.0} milhas náuticas} }</string>
|
||||
<string name="voiceDistance">{n, plural, =1 {1 } other {{n,number,#.0}} }</string>
|
||||
<string name="lap_heart_rate">Frequência cardíaca na volta</string>
|
||||
<string name="no_compatible_camera_installed">Nenhum aplicativo compatível de camera instalado</string>
|
||||
<string name="no_compatible_gallery_installed">Nenhum aplicativo compatível de galeria de imagens instalado</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">Intervalo de tempo de amostragem</string>
|
||||
<string name="settings_recording_location_distance_summary">%1$s entre localizações gravadas</string>
|
||||
<string name="settings_announcements_lap_power">Potência por volta</string>
|
||||
<string name="power_x_watt">Potência {x, plural, =1 {1 watt} other {# watts} }</string>
|
||||
<string name="power">Potência</string>
|
||||
</resources>
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">Uma aplicação que iniciou uma gravação também pode acessar os dados gravados.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Os dados gravados não serão compartilhados automaticamente com outras aplicações.</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Impedir a reimportação de trilhas</string>
|
||||
<string name="stats_loss">Perda</string>
|
||||
<string name="stats_coordinates">Latitude/Longitude</string>
|
||||
|
||||
@@ -466,7 +466,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_enabled_summary_off">Apenas o OpenTracks pode iniciar e parar gravações.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Os dados gravados não serão compartilhados automaticamente com outras aplicações.</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Impedir a reimportação de trilhas</string>
|
||||
<string name="stats_loss">Perda</string>
|
||||
<string name="stats_coordinates">Latitude/Longitude</string>
|
||||
|
||||
@@ -498,7 +498,7 @@ limitations under the License.
|
||||
<string name="current_heart_rate">Текущий пульс</string>
|
||||
<string name="generic_filter">Фильтр</string>
|
||||
<string name="generic_from">с</string>
|
||||
<string name="settings_export_section">Экспорт</string>
|
||||
<string name="settings_export_as_file_section">Экспорт</string>
|
||||
<string name="settings_import_section">Импорт</string>
|
||||
<string name="settings_api_title">Публичный API</string>
|
||||
<string name="settings_public_api_disabled_toast">Публичный API OpenTracks отключен: он может быть включен в настройках.</string>
|
||||
@@ -600,7 +600,6 @@ limitations under the License.
|
||||
<string name="settings_locale_system_default">Как в системе</string>
|
||||
<string name="settings_night_mode_option_night_oled">Полностью чёрный</string>
|
||||
<string name="settings_announcements_unit">Единицы расстояния/скорости</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="power_x_watt">Мощность {x, plural, =1 {1 Ватт} few {# Ватта} other {# Ваттов} }</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">Интервал выборки</string>
|
||||
<string name="settings_announcements_lap_power">Мощность на круге</string>
|
||||
|
||||
@@ -381,7 +381,7 @@ limitations under the License.
|
||||
<string name="settings_running_sensor">Spúšťanie</string>
|
||||
<string name="settings_cycling_sensor">Cyklistika</string>
|
||||
<string name="custom_layout_list_layout_removed">Rozloženie bolo odstránené</string>
|
||||
<string name="settings_export_section">Exportujte</string>
|
||||
<string name="settings_export_as_file_section">Exportujte</string>
|
||||
<string name="settings_import_section">Importovať</string>
|
||||
<string name="track_recording_notification_accuracy">Presnosť polohy: %1$s</string>
|
||||
<string name="stats_clock">Hodiny</string>
|
||||
|
||||
@@ -352,7 +352,7 @@ limitations under the License.
|
||||
<string name="import_parser_error">Napaka analizatorja: %1$s</string>
|
||||
<string name="settings_sensor_cycling_cadence">Kadenca</string>
|
||||
<string name="menu_select_layout">Izberite postavitev</string>
|
||||
<string name="settings_export_section">Izvoz</string>
|
||||
<string name="settings_export_as_file_section">Izvoz</string>
|
||||
<string name="stats_sensors_cadence">Kadenca</string>
|
||||
<string name="stats_sensors_heart_rate">Srčni utrip</string>
|
||||
<string name="settings_ui_title">Uporabniški vmesnik</string>
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<string name="introduction_osm_dashboard">ஓபன்ட்ராக்ச் ஒரு வரைபடத்தை வழங்காது. உங்கள் பதிவுகளை வரைபடத்தில் காண OSMDashboard ஐ நிறுவவும்.</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">மாதிரி நேர இடைவெளி</string>
|
||||
<string name="settings_recording_location_distance_summary">பதிவுசெய்யப்பட்ட இடங்களுக்கு இடையில் %1$s</string>
|
||||
<string name="voice_announce_lap_power_key">VoiceAnnouncelappower</string>
|
||||
<string name="settings_announcements_lap_power">மடியில் ஆற்றல்</string>
|
||||
<string name="power_x_watt">ஆற்றல் {x, plural, = 1 {1 watt} other {# வாட்ச்}}</string>
|
||||
<string name="power">விசை</string>
|
||||
@@ -339,7 +338,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">பதிவைத் தொடங்கிய பயன்பாடு பதிவு செய்யப்பட்ட தரவையும் அணுகலாம்.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">பதிவுசெய்யப்பட்ட தரவு மற்ற பயன்பாடுகளுடன் தானாக பகிரப்படாது.</string>
|
||||
<string name="settings_import_section">இறக்குமதி</string>
|
||||
<string name="settings_export_section">ஏற்றுமதி</string>
|
||||
<string name="settings_export_as_file_section">ஏற்றுமதி</string>
|
||||
<string name="share_track_share_file_body">இந்த பாதையில் நீங்கள் ஆர்வமாக இருக்கலாம் என்று நினைக்கிறேன்.\n\n %1$s</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">தடங்களை மீண்டும் இறக்குமதி செய்வதைத் தடுக்கவும்</string>
|
||||
<string name="share_track_subject">நான் உங்களுடன் ஒரு பாதையைப் பகிர்ந்து கொள்ள விரும்புகிறேன்</string>
|
||||
|
||||
@@ -438,7 +438,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Kaydedilen veriler diğer uygulamalarla otomatik olarak paylaşılmayacaktır.</string>
|
||||
<!-- Settings import/export -->
|
||||
<string name="settings_import_section">İçe Aktar</string>
|
||||
<string name="settings_export_section">Dışa Aktar</string>
|
||||
<string name="settings_export_as_file_section">Dışa Aktar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Parkurların yeniden içe aktarılmasını engelle</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">Bu parkurun ilginizi çekebileceğini düşündüm.\n\n%1$s</string>
|
||||
@@ -683,7 +683,6 @@ limitations under the License.
|
||||
<string name="introduction_osm_dashboard">OpenTracks kendi başına bir harita sunmaz. Kayıtlarınızı bir harita üzerinde görüntülemek için lütfen OSMDashboard\'u yükleyin.</string>
|
||||
<string name="settings_recording_min_sampling_interval_title">Örnekleme zaman aralığı</string>
|
||||
<string name="settings_recording_location_distance_summary">Kaydedilen konumlar arasında %1$s</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_announcements_lap_power">Tur gücü</string>
|
||||
<string name="power_x_watt">Güç {x, plural, =1 {1 watt} other {# watt} }</string>
|
||||
<string name="power">Güç</string>
|
||||
|
||||
@@ -425,7 +425,7 @@ limitations under the License.
|
||||
<string name="field_remove_from_layout">Видалити</string>
|
||||
<string name="stats_clock">Годинник</string>
|
||||
<string name="settings_recording_customize_layout_select_columns">Поля даних у рядку</string>
|
||||
<string name="settings_export_section">Експорт</string>
|
||||
<string name="settings_export_as_file_section">Експорт</string>
|
||||
<string name="field_set_secondary">Вторинний</string>
|
||||
<string name="settings_all_reset">Скидання</string>
|
||||
<string name="generic_filter">Фільтр</string>
|
||||
@@ -598,9 +598,9 @@ limitations under the License.
|
||||
<string name="voiceIdle">Перехід у режим без руху.</string>
|
||||
<string name="settings_recording_idle_timeout_title">Поріг режиму «Без руху»</string>
|
||||
<string name="stats_gain_loss">Підйом і спуск</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="stats_elapsed_segment_time">Час на відрізок</string>
|
||||
<string name="description_sensor_temperature">Температура (Цельсій)</string>
|
||||
<string name="sensor_state_temperature">Термометр</string>
|
||||
<string name="stats_sensors_temperature">Температура</string>
|
||||
<string name="activity_type_gravel_biking">гравійний велосипед</string>
|
||||
</resources>
|
||||
|
||||
@@ -455,7 +455,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Đạp xe</string>
|
||||
<string name="settings_running_sensor">Chạy</string>
|
||||
<string name="settings_import_section">Nhập</string>
|
||||
<string name="settings_export_section">Xuất</string>
|
||||
<string name="settings_export_as_file_section">Xuất</string>
|
||||
<string name="settings_generic_sensor">Chung chung</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (khuyến khích)</string>
|
||||
|
||||
@@ -393,7 +393,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">記錄的資料將不會自動與其他應用程式共用。</string>
|
||||
<!-- Settings import/export -->
|
||||
<string name="settings_import_section">匯入</string>
|
||||
<string name="settings_export_section">匯出</string>
|
||||
<string name="settings_export_as_file_section">匯出</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">防止重新匯入蹤跡</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">我想您應該會對這個路線感興趣。
|
||||
@@ -613,7 +613,6 @@ limitations under the License.
|
||||
<string name="settings_theme_switch_restart">主題變更也許需要手動重新啟動。</string>
|
||||
<string name="settings_night_mode_option_night_oled">夜(OLED 友善的)</string>
|
||||
<string name="power_x_watt">功率 {x, plural, =1 {1 瓦特} other {# 瓦特} }</string>
|
||||
<string name="voice_announce_lap_power_key">voiceAnnounceLapPower</string>
|
||||
<string name="settings_charts_title">圖表</string>
|
||||
<string name="stats_gain_loss">收益&損失</string>
|
||||
<string name="settings_recording_show_gps_disabled_warning_title">GPS 禁用警告</string>
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
<!-- Activity Type -->
|
||||
<string name="activity_type_airplane">坐飞机</string>
|
||||
<string name="activity_type_atv">全地形越野</string>
|
||||
<string name="activity_type_biking">自行车</string>
|
||||
<string name="activity_type_biking">骑行</string>
|
||||
<string name="activity_type_blimp">小型飞艇游览</string>
|
||||
<string name="activity_type_boat">船</string>
|
||||
<string name="activity_type_climbing">攀岩</string>
|
||||
@@ -539,7 +539,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_disabled_toast">OpenTracks 公共 API 已禁用:可以在设置中启用。</string>
|
||||
<string name="settings_public_api_dashboard_enabled_title">自动数据传输</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">记录的数据不会自动与其他应用程序共享。</string>
|
||||
<string name="settings_export_section">导出</string>
|
||||
<string name="settings_export_as_file_section">导出</string>
|
||||
<string name="stats_loss">下降</string>
|
||||
<string name="stats_coordinates">纬度/经度</string>
|
||||
<string name="track_pace_notification">配速:%1$s</string>
|
||||
@@ -633,4 +633,5 @@ limitations under the License.
|
||||
<string name="description_sensor_temperature">温度(摄氏度)</string>
|
||||
<string name="sensor_state_temperature">温度计</string>
|
||||
<string name="stats_sensors_temperature">温度</string>
|
||||
<string name="activity_type_gravel_biking">砾石骑行</string>
|
||||
</resources>
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<resources>
|
||||
<string name="app_web_url" translatable="false">https://codeberg.org/OpenTracksApp/OpenTracks</string>
|
||||
<string name="app_name" translatable="false">OpenTracks</string>
|
||||
<string name="launcher_name" translatable="false">OpenTracks</string>
|
||||
|
||||
<string name="egm2008" translatable="false">EGM2008</string>
|
||||
<string name="wgs84" translatable="false">WGS84</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user