Compare commits

..

38 Commits

Author SHA1 Message Date
Dennis Guse a6f5635cb8 Release: v4.8.4 2023-09-17 10:18:46 +02:00
Dennis Guse bebf89608c Bump com.android.tools.build:gradle from 8.2.0-beta01 to 8.2.0-beta04 2023-09-17 10:17:35 +02:00
Dennis Guse 0cf02082d3 Merge pull request #1687 from lmarz/track
TrackListActivity: Switch to RecyclerView

Fixes #1613
2023-09-14 22:06:00 +02:00
Leon Marz e18017d500 TrackListActivity: Switch to RecyclerView
Part of: #1613
2023-09-14 18:11:02 +02:00
Leon Marz 7776b51f05 TrackListActivity: Set launchMode to singleTop 2023-09-14 18:11:02 +02:00
Leon Marz 30c76de136 Add recycler view adapter for track list 2023-09-14 18:11:02 +02:00
Leon Marz 853469cb28 ContentProviderUtils.java: Add searchTracks method 2023-09-14 18:11:02 +02:00
quaelnix cf9b432224 Add max power stats 2023-09-13 18:38:32 +02:00
dependabot[bot] e130039118 Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-07 09:54:38 +02:00
Martin Herrman adb491ee81 Update README.md
Add SportsTracker to file format compatibility table
2023-09-05 22:57:52 +02:00
Dennis Guse 1b295e06da Release: v4.8.3 2023-08-31 22:07:48 +02:00
Dennis Guse 4ca6b230c3 Bugfix: prevent crash due to invalid UUID example (Android 14+ / API34+).
Fixes #1682.
2023-08-31 22:05:31 +02:00
Dennis Guse bb722de756 Upgrade to Gradlew 8.3.0 2023-08-28 20:31:14 +02:00
Dennis Guse 8eab847fae Merge pull request #1672 from lmarz/marker
MarkerListActivity: Switch to RecyclerView
2023-08-28 20:17:38 +02:00
Leon Marz c256cbe07d MarkerListActivity: Switch to RecyclerView
Part of: #1613
2023-08-28 10:02:00 +02:00
Leon Marz 5978886a18 Add recycler view adapter for marker list 2023-08-28 10:02:00 +02:00
Leon Marz 7afa4b86d4 ContentProviderUtils: Add searchMarkers method 2023-08-28 10:01:51 +02:00
Dennis Guse f59ae51c6f Release: v4.8.2 2023-08-27 09:01:20 +02:00
Max Ch 19a5d46e88 Translated using Weblate (Ukrainian)
Currently translated at 97.8% (502 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/uk/
2023-08-26 19:40:32 +02:00
josé m d2b64391d9 Translated using Weblate (Galician)
Currently translated at 10.3% (12 of 116 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://hosted.weblate.org/projects/opentracks/app-store-metadata-files/gl/
2023-08-26 19:40:32 +02:00
Stephan Paternotte fe920d5b5e Translated using Weblate (Dutch)
Currently translated at 100.0% (116 of 116 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://hosted.weblate.org/projects/opentracks/app-store-metadata-files/nl/
2023-08-26 19:40:32 +02:00
Max Ch d1ba31ff3f Translated using Weblate (Ukrainian)
Currently translated at 93.1% (478 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/uk/
2023-08-26 19:40:32 +02:00
josé m e7a01b1cd1 Translated using Weblate (Galician)
Currently translated at 100.0% (513 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/gl/
2023-08-26 19:40:32 +02:00
Stephan Paternotte 70b65d05c0 Translated using Weblate (Dutch)
Currently translated at 100.0% (115 of 115 strings)

Translation: OpenTracks/App store metadata files
Translate-URL: https://hosted.weblate.org/projects/opentracks/app-store-metadata-files/nl/
2023-08-26 19:40:32 +02:00
Max Ch c9ad1b45c6 Translated using Weblate (Ukrainian)
Currently translated at 89.4% (459 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/uk/
2023-08-26 19:40:32 +02:00
ovl-1 4657a9faa0 Translated using Weblate (Norwegian Bokmål)
Currently translated at 97.2% (499 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/nb_NO/
2023-08-26 19:40:32 +02:00
gallegonovato fba6cd7d4a Translated using Weblate (Spanish)
Currently translated at 100.0% (513 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/es/
2023-08-26 19:40:32 +02:00
Tea-NT 970239c614 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (513 of 513 strings)

Translation: OpenTracks/opentracks.src-main-res-values-strings-xml--main
Translate-URL: https://hosted.weblate.org/projects/opentracks/opentracks-src-main-res-values-strings-xml-main/zh_Hans/
2023-08-26 19:40:32 +02:00
Peter Storch f83c23e450 show postworkout error messages, #1669 2023-08-26 19:40:27 +02:00
Dennis Guse 326da494eb Java Record for Track.Id and TrackPoint.Id.
Part of #1526.
2023-08-25 23:52:10 +02:00
Dennis Guse fb85f2e976 Bump com.android.tools.build:gradle from 8.1.1 to 8.2.0-beta01 2023-08-25 23:52:10 +02:00
dependabot[bot] 60a34bfcbb Bump org.mockito:mockito-android from 5.4.0 to 5.5.0
Bumps [org.mockito:mockito-android](https://github.com/mockito/mockito) from 5.4.0 to 5.5.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.4.0...v5.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-25 23:31:17 +02:00
Dennis Guse 95816937bc Bugfix: request ACCESS_BACKGROUND_LOCATION fails; removing it for now.
Introduced in 3910837d1a

Part of #1653.
2023-08-22 22:06:56 +02:00
Dennis Guse 4f745a94bf Release: v4.8.1 2023-08-22 07:15:53 +02:00
Dennis Guse 9b6df4bf99 Bugfix: request ACCESS_BACKGROUND_LOCATION fails; removing it for now.
Introduced in 3910837d1a

Part of #1653.
2023-08-22 07:15:08 +02:00
dependabot[bot] 73f325e633 Bump com.android.tools.build:gradle from 8.1.0 to 8.1.1
Bumps com.android.tools.build:gradle from 8.1.0 to 8.1.1.

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-22 06:44:06 +02:00
Dennis Guse 28981611f1 GPX export: skip gain/loss if not present.
Part of #1671.
2023-08-20 19:38:37 +02:00
Dennis Guse 7d8c4dcb29 Bugfix: re-introduced race condition fix for newGpsStatus.
Follow-up to e9f24f6bae

Fixes #1668.
2023-08-20 19:28:37 +02:00
68 changed files with 1323 additions and 913 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
+8 -8
View File
@@ -125,14 +125,14 @@ For testing via adb: `adb shell am start -e someParameter someValue -n "package/
The Public API is disabled by default to protect the user's privacy, but it can easily be enabled in the settings.
## File formats compatibility with open-source software
| | [GPX 1.1](https://www.topografix.com/GPX/1/1/) | [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) | [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) |
|------------------------------------------------------|------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------|
| [OpenLayers 7.1.0](https://openlayers.org/) | ? | [no](https://github.com/openlayers/openlayers/issues/14104) | [no](https://github.com/openlayers/openlayers/issues/14104) |
| [Golden Cheetah 3.5](https://www.goldencheetah.org/) | ? | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) |
| [GpxPod](https://apps.nextcloud.com/apps/gpxpod) | ? | ? | ? |
| [OsmAnd](https://github.com/osmandapp/OsmAnd) | ? | [no](https://github.com/osmandapp/OsmAnd/issues/15271) | [no](https://github.com/osmandapp/OsmAnd/issues/15271) |
| [FitTrackee](https://github.com/SamR1/FitTrackee) | yes | n/a | n/a |
| | [GPX 1.1](https://www.topografix.com/GPX/1/1/) | [KML 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) | [KMZ 2.3](https://docs.opengeospatial.org/is/12-007r2/12-007r2.html) |
|-----------------------------------------------------------|--------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------|
| [OpenLayers 7.1.0](https://openlayers.org/) | ? | [no](https://github.com/openlayers/openlayers/issues/14104) | [no](https://github.com/openlayers/openlayers/issues/14104) |
| [Golden Cheetah 3.5](https://www.goldencheetah.org/) | ? | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) | [no](https://github.com/GoldenCheetah/GoldenCheetah/issues/4271) |
| [GpxPod](https://apps.nextcloud.com/apps/gpxpod) | ? | ? | ? |
| [OsmAnd](https://github.com/osmandapp/OsmAnd) | ? | [no](https://github.com/osmandapp/OsmAnd/issues/15271) | [no](https://github.com/osmandapp/OsmAnd/issues/15271) |
| [FitTrackee](https://github.com/SamR1/FitTrackee) | yes | n/a | n/a |
| [SportsTracker](https://github.com/ssaring/sportstracker) | yes, [single tracks only](https://github.com/ssaring/sportstracker/issues/260) | no | no |
## Dashboard API (incl. map)
+4 -4
View File
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'com.android.tools.build:gradle:8.2.0-beta04'
}
}
@@ -64,8 +64,8 @@ android {
defaultConfig {
applicationId "de.dennisguse.opentracks"
versionCode 5361
versionName "v4.8.0"
versionCode 5399
versionName "v4.8.4"
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
@@ -141,7 +141,7 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'org.mockito:mockito-android:5.4.0'
androidTestImplementation 'org.mockito:mockito-android:5.5.0'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
}
@@ -0,0 +1,5 @@
v4.8.1: OpenTracks
Bugfix:
- Location permission was not granted due to ACCESS_LOCATION_BACKGROUND (removed)
@@ -0,0 +1,5 @@
v4.8.2: OpenTracks
Changes:
- After recording export: show error message if unsuccessful
@@ -0,0 +1,8 @@
v4.8.3: OpenTracks
Bugfix:
- Android 14: prevent crash due to malformed UUID (example for filename template)
Developer:
- Marker list is now a RecyclerView
@@ -0,0 +1,7 @@
v4.8.4: OpenTracks
Changes:
- Show max power for recorded tracks
Developer:
- TrackList now uses RecyclerView
@@ -0,0 +1,4 @@
v4.3.4: OpenTracks
Bugfix:
- Android 13: regional locales could not be selected (introduced in v4.3.0)
@@ -0,0 +1,5 @@
v4.3.5: OpenTracks
Bugfix:
- Sensor parsing had a overflow one off bug (#1559)
- Insert marker: don't crash if no camera app is installed (#1561)
@@ -0,0 +1,8 @@
v4.7.1: OpenTracks
Bugfixes:
- Android 14: crash on recording start (missing permissions)
- Android 14: crash on track deletion (ForegroundService without reason)
Developer:
- AggregatedStats is now a RecyclerView
@@ -0,0 +1,10 @@
v4.8.0: OpenTracks
Cambios:
- Permiso: ACCESS_BACKGROUND_LOCATION é requerido debido á API
- Preparación para a detección da pausa en función do tempoPreparation for time-based idle detection
- Retirada da distinción por orixe nos datos gardados (sensores GPS vs. BLE)
Desenvolvemento:
- retirados algúns atallos no código que entraban en conflito (TrackRecordingService e GpsStatusManager)
- TrackRecordingServiceConnection xestiona o apagado do servizo
@@ -0,0 +1,4 @@
v4.8.1: OpenTracks
Arranxo:
- Non se concedía o permiso de localización debido a ACCESS_LOCATION_BACKGROUND (eliminado)
@@ -0,0 +1,10 @@
v4.8.0: OpenTracks
Veranderingen:
- Toestemming: ACCESS_BACKGROUND_LOCATION is vereist vanwege API
- Voorbereiding voor tijdgebaseerde stationaire detectie
- Onderscheid bron (GPS vs BLE sensoren) vervallen voor opgeslagen gegevens
Ontwikkelaar:
- verwijderde raceconditie-werkplekken (TrackRecordingService en GpsStatusManager)
- TrackRecordingServiceConnection zorgt voor service-uitschakeling
@@ -0,0 +1,4 @@
v4.8.1: OpenTracks
Bugfix :
- Toestemming tot locatie is niet verleend vanwege ACCESS_LOCATION_BACKGROUND (verwijderd)
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+4 -1
View File
@@ -130,10 +130,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@@ -9,8 +9,8 @@ public class TestUtil {
public static GrantPermissionRule createGrantPermissionRule() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION, android.Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.POST_NOTIFICATIONS);
return GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.POST_NOTIFICATIONS);
}
return GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION, android.Manifest.permission.BLUETOOTH_CONNECT);
return GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.BLUETOOTH_CONNECT);
}
}
@@ -37,7 +37,7 @@ public class TestDataUtil {
public static Track createTrack(Track.Id trackId) {
Track track = new Track();
track.setId(trackId);
track.setName("Test: " + trackId.getId());
track.setName("Test: " + trackId.id());
return track;
}
@@ -119,7 +119,7 @@ public class CustomContentProviderUtilsTest {
assertNotNull(trackPoint);
locations.add(trackPoint);
// Make sure the IDs are returned in the right order.
assertEquals(lastPointId.getId() - numPoints + locations.size(), trackPoint.getId().getId());
assertEquals(lastPointId.id() - numPoints + locations.size(), trackPoint.getId().id());
}
assertEquals(numPoints, locations.size());
}
@@ -128,7 +128,7 @@ public class CustomContentProviderUtilsTest {
private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) {
Track track = new Track();
track.setId(id);
track.setName("Test: " + id.getId());
track.setName("Test: " + id.id());
contentProviderUtils.insertTrack(track);
track = contentProviderUtils.getTrack(id);
assertNotNull(track);
@@ -155,7 +155,7 @@ public class CustomContentProviderUtilsTest {
}
}
assertTrue(numPoints == 0 || lastPointId.getId() > 0);
assertTrue(numPoints == 0 || lastPointId.id() > 0);
assertEquals(numPoints, counter);
return lastPointId;
@@ -172,7 +172,7 @@ public class CustomContentProviderUtilsTest {
// Id
when(cursorMock.getColumnIndexOrThrow(TracksColumns._ID)).thenReturn(columnIndex);
when(cursorMock.isNull(columnIndex)).thenReturn(false);
when(cursorMock.getLong(columnIndex)).thenReturn(trackId.getId());
when(cursorMock.getLong(columnIndex)).thenReturn(trackId.id());
//Uuid
columnIndex++;
@@ -182,7 +182,7 @@ public class CustomContentProviderUtilsTest {
// Name
columnIndex++;
String name = NAME_PREFIX + trackId.getId();
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);
@@ -460,7 +460,7 @@ public class CustomContentProviderUtilsTest {
Marker marker = contentProviderUtils.createMarker(cursorMock);
assertEquals(id, marker.getId().id());
assertEquals(name, marker.getName());
assertEquals(trackId, marker.getTrackId().getId());
assertEquals(trackId, marker.getTrackId().id());
}
/**
@@ -640,7 +640,7 @@ public class CustomContentProviderUtilsTest {
marker.setDescription(TEST_DESC);
Marker.Id markerId = new Marker.Id(ContentUris.parseId(contentProviderUtils.insertMarker(marker)));
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.getId());
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.id());
assertTrue(dir.exists());
assertTrue(dir.isDirectory());
assertEquals(1, dir.list().length);
@@ -675,7 +675,7 @@ public class CustomContentProviderUtilsTest {
marker.setDescription(TEST_DESC);
Marker.Id markerId = new Marker.Id(ContentUris.parseId(contentProviderUtils.insertMarker(marker)));
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.getId());
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.id());
assertTrue(dir.exists());
assertTrue(dir.isDirectory());
assertEquals(1, dir.list().length);
@@ -712,7 +712,7 @@ public class CustomContentProviderUtilsTest {
Marker.Id markerId = new Marker.Id(ContentUris.parseId(contentProviderUtils.insertMarker(marker)));
contentProviderUtils.insertMarker(otherMarker);
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.getId());
File dir = new File(FileUtils.getPhotoDir(context), "" + trackId.id());
assertTrue(dir.exists());
assertTrue(dir.isDirectory());
assertEquals(2, dir.list().length);
@@ -889,7 +889,7 @@ public class CustomContentProviderUtilsTest {
for (int i = 0; i < trackpointIds.size(); i++) {
assertTrue(trackPointIterator.hasNext());
TrackPoint trackPoint = trackPointIterator.next();
assertEquals(startTrackPointId.getId() + i, trackPoint.getId().getId());
assertEquals(startTrackPointId.id() + i, trackPoint.getId().id());
checkLocation(i, trackPoint.getLocation());
}
@@ -35,7 +35,7 @@ public class TrackFilenameGenerator2Test {
// given
Track track = new Track();
track.setName("Best Track");
track.setUuid(UUID.fromString("0000fee0-0000-1000-8000-00805f9b34fb"));
track.setUuid(UUID.fromString("f81d4fae-7dec-11d0-a765-00a0c91e6bf6"));
track.getTrackStatistics().setStartTime(Instant.parse("2020-02-02T02:02:02Z"));
// when
@@ -75,6 +75,6 @@ public class TrackNameUtilsTest {
@Test
public void testTrackName_number() {
PreferencesUtils.setString(R.string.track_name_key, CONTEXT.getString(R.string.settings_recording_track_name_number_value));
assertEquals("Track " + TRACK_ID.getId(), TrackNameUtils.getTrackName(CONTEXT, TRACK_ID, START_TIME));
assertEquals("Track " + TRACK_ID.id(), TrackNameUtils.getTrackName(CONTEXT, TRACK_ID, START_TIME));
}
}
-2
View File
@@ -31,8 +31,6 @@ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/
<gpxtrkx:MovingTime>0</gpxtrkx:MovingTime>
<gpxtrkx:StoppedTime>0</gpxtrkx:StoppedTime>
<gpxtrkx:MaxSpeed>0.0</gpxtrkx:MaxSpeed>
<gpxtrkx:Ascent>null</gpxtrkx:Ascent>
<gpxtrkx:Descent>null</gpxtrkx:Descent>
</gpxtrkx:TrackStatsExtension>
</extensions>
<trkseg>
+3 -2
View File
@@ -46,7 +46,7 @@ limitations under the License.
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -333,7 +333,8 @@ limitations under the License.
<activity
android:name=".TrackListActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
@@ -66,7 +66,7 @@ public class TrackEditActivity extends AbstractActivity implements ChooseActivit
contentProviderUtils = new ContentProviderUtils(this);
track = contentProviderUtils.getTrack(trackId);
if (track == null) {
Log.e(TAG, "No track for " + trackId.getId());
Log.e(TAG, "No track for " + trackId.id());
finish();
return;
}
@@ -16,7 +16,6 @@
package de.dennisguse.opentracks;
import android.app.ActivityOptions;
import android.app.SearchManager;
import android.content.Context;
import android.content.Intent;
@@ -26,37 +25,27 @@ import android.graphics.drawable.AnimatedVectorDrawable;
import android.location.LocationManager;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Pair;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.SearchView;
import androidx.core.content.ContextCompat;
import androidx.cursoradapter.widget.ResourceCursorAdapter;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.android.material.button.MaterialButton;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.DistanceFormatter;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.TracksColumns;
import de.dennisguse.opentracks.databinding.TrackListBinding;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
@@ -66,15 +55,14 @@ import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.settings.UnitSystem;
import de.dennisguse.opentracks.share.ShareUtils;
import de.dennisguse.opentracks.ui.TrackListAdapter;
import de.dennisguse.opentracks.ui.aggregatedStatistics.AggregatedStatisticsActivity;
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
import de.dennisguse.opentracks.ui.markers.MarkerListActivity;
import de.dennisguse.opentracks.ui.util.ActivityUtils;
import de.dennisguse.opentracks.ui.util.ListItemUtils;
import de.dennisguse.opentracks.util.IntentDashboardUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.PermissionRequester;
import de.dennisguse.opentracks.util.StringUtils;
/**
* An activity displaying a list of tracks.
@@ -87,12 +75,10 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
// The following are set in onCreate
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private ResourceCursorAdapter resourceCursorAdapter;
private TrackListAdapter adapter;
private TrackListBinding viewBinding;
private final TrackLoaderCallBack loaderCallbacks = new TrackLoaderCallBack();
// Preferences
private UnitSystem unitSystem = UnitSystem.defaultUnitSystem();
@@ -128,11 +114,14 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
private final OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> {
if (PreferencesUtils.isKey(R.string.stats_units_key, key)) {
unitSystem = PreferencesUtils.getUnitSystem();
if (adapter != null) {
adapter.updateUnitSystem(unitSystem);
}
}
if (key != null) {
runOnUiThread(() -> {
TrackListActivity.this.invalidateOptionsMenu();
loaderCallbacks.restart();
loadData();
});
}
};
@@ -140,6 +129,8 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
// Menu items
private MenuItem searchMenuItem;
private String searchQuery;
private final TrackRecordingServiceConnection.Callback bindChangedCallback = (service, unused) -> {
service.getRecordingStatusObservable()
.observe(TrackListActivity.this, this::onRecordingStatusChanged);
@@ -180,63 +171,10 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
});
viewBinding.trackList.setEmptyView(viewBinding.trackListEmptyView);
viewBinding.trackList.setOnItemClickListener((parent, view, position, trackIdId) -> {
Track.Id trackId = new Track.Id(trackIdId);
if (recordingStatus.isRecording() && trackId.equals(recordingStatus.getTrackId())) {
// Is recording -> open record activity.
Intent newIntent = IntentUtils.newIntent(TrackListActivity.this, TrackRecordingActivity.class)
.putExtra(TrackRecordedActivity.EXTRA_TRACK_ID, trackId);
startActivity(newIntent);
} else {
// Not recording -> open detail activity.
Intent newIntent = IntentUtils.newIntent(TrackListActivity.this, TrackRecordedActivity.class)
.putExtra(TrackRecordedActivity.EXTRA_TRACK_ID, trackId);
ActivityOptions activityOptions = ActivityOptions.makeSceneTransitionAnimation(
this,
new Pair<>(view.findViewById(R.id.list_item_icon), TrackRecordedActivity.VIEW_TRACK_ICON));
startActivity(newIntent, activityOptions.toBundle());
}
});
resourceCursorAdapter = new ResourceCursorAdapter(this, R.layout.list_item, null, 0) {
@Override
public void bindView(View view, Context context, Cursor cursor) {
int idIndex = cursor.getColumnIndexOrThrow(TracksColumns._ID);
int iconIndex = cursor.getColumnIndexOrThrow(TracksColumns.ICON);
int nameIndex = cursor.getColumnIndexOrThrow(TracksColumns.NAME);
int totalTimeIndex = cursor.getColumnIndexOrThrow(TracksColumns.TOTALTIME);
int totalDistanceIndex = cursor.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE);
int startTimeIndex = cursor.getColumnIndexOrThrow(TracksColumns.STARTTIME);
int startTimeOffsetIndex = cursor.getColumnIndexOrThrow(TracksColumns.STARTTIME_OFFSET);
int activityTypeIndex = cursor.getColumnIndexOrThrow(TracksColumns.ACTIVITY_TYPE_LOCALIZED);
int descriptionIndex = cursor.getColumnIndexOrThrow(TracksColumns.DESCRIPTION);
int markerCountIndex = cursor.getColumnIndexOrThrow(TracksColumns.MARKER_COUNT);
Track.Id trackId = new Track.Id(cursor.getLong(idIndex));
boolean isRecording = trackId.equals(recordingStatus.getTrackId());
String icon = cursor.getString(iconIndex);
int iconId = ActivityType.findBy(icon)
.getIconDrawableId();
String name = cursor.getString(nameIndex);
String totalTime = StringUtils.formatElapsedTime(Duration.ofMillis(cursor.getLong(totalTimeIndex)));
String totalDistance = DistanceFormatter.Builder()
.setUnit(unitSystem)
.build(TrackListActivity.this).formatDistance(Distance.of(cursor.getDouble(totalDistanceIndex)));
int markerCount = cursor.getInt(markerCountIndex);
long startTime = cursor.getLong(startTimeIndex);
int startTimeOffset = cursor.getInt(startTimeOffsetIndex);
String activityType = icon != null && !icon.equals("") ? null : cursor.getString(activityTypeIndex);
String description = cursor.getString(descriptionIndex);
ListItemUtils.setListItem(TrackListActivity.this, view, isRecording,
iconId, R.string.image_track, name, totalTime, totalDistance, markerCount,
OffsetDateTime.ofInstant(Instant.ofEpochMilli(startTime), ZoneOffset.ofTotalSeconds(startTimeOffset)),
activityType, description, false);
}
};
viewBinding.trackList.setAdapter(resourceCursorAdapter);
ActivityUtils.configureListViewContextualMenu(viewBinding.trackList, contextualActionModeCallback);
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
adapter = new TrackListAdapter(this, viewBinding.trackList, recordingStatus, unitSystem);
viewBinding.trackList.setLayoutManager(layoutManager);
viewBinding.trackList.setAdapter(adapter);
viewBinding.trackListFabAction.setOnClickListener((view) -> {
if (recordingStatus.isRecording()) {
@@ -271,12 +209,11 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
});
setSupportActionBar(viewBinding.trackListToolbar);
loadData(getIntent());
adapter.setActionModeCallback(contextualActionModeCallback);
}
private void requestRequiredPermissions() {
PermissionRequester.ALL.requestPermissionsIfNeeded(this, this, null, (requester) -> Toast.makeText(this, R.string.permission_gps_failed, Toast.LENGTH_SHORT).show());
PermissionRequester.ALL.requestPermissionsIfNeeded(this, this, null, (requester) -> Toast.makeText(this, R.string.permission_gps_failed, Toast.LENGTH_LONG).show());
}
@Override
@@ -293,7 +230,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
// Update UI
this.invalidateOptionsMenu();
LoaderManager.getInstance(this).restartLoader(0, null, loaderCallbacks);
loadData();
// Float button
setFloatButton();
@@ -312,6 +249,7 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
super.onDestroy();
viewBinding = null;
trackRecordingServiceConnection = null;
adapter = null;
}
@Override
@@ -333,10 +271,6 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
updateGpsMenuItem(gpsStatusValue.isGpsStarted(), recordingStatus.isRecording());
SearchView searchView = (SearchView) searchMenuItem.getActionView();
searchView.setQuery("", false);
return super.onPrepareOptionsMenu(menu);
}
@@ -375,11 +309,6 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
return super.onKeyUp(keyCode, event);
}
@Override
public void overridePendingTransition(int enterAnim, int exitAnim) {
//Disable animations as it is weird going into searchMode; looks okay for SplashScreen.
}
@Override
public void onBackPressed() {
SearchView searchView = (SearchView) searchMenuItem.getActionView();
@@ -387,8 +316,9 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
searchView.setIconified(true);
}
if (loaderCallbacks.getSearchQuery() != null) {
loaderCallbacks.setSearch(null);
if (searchQuery != null) {
searchQuery = null;
loadData();
return;
}
@@ -396,19 +326,30 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
@Override
public void onNewIntent(Intent intent) {
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
loadData(intent);
}
private void loadData(Intent intent) {
String searchQuery = null;
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
searchQuery = intent.getStringExtra(SearchManager.QUERY);
} else {
searchQuery = null;
}
}
loaderCallbacks.setSearch(searchQuery);
private void loadData() {
viewBinding.trackListToolbar.setTitle(Objects.requireNonNullElseGet(searchQuery, () -> getString(R.string.app_name)));
Cursor tracks = new ContentProviderUtils(this).searchTracks(searchQuery);
adapter.swapData(tracks);
if (tracks.getCount() == 0) {
viewBinding.trackListEmptyView.setVisibility(View.VISIBLE);
viewBinding.trackList.setVisibility(View.GONE);
} else {
viewBinding.trackListEmptyView.setVisibility(View.GONE);
viewBinding.trackList.setVisibility(View.VISIBLE);
}
}
@Override
@@ -490,64 +431,13 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
}
if (itemId == R.id.list_context_menu_select_all) {
for (int i = 0; i < viewBinding.trackList.getCount(); i++) {
viewBinding.trackList.setItemChecked(i, true);
}
adapter.setAllSelected(true);
return false;
}
return false;
}
private class TrackLoaderCallBack implements LoaderManager.LoaderCallbacks<Cursor> {
private String searchQuery = null;
public String getSearchQuery() {
return searchQuery;
}
public void setSearch(String searchQuery) {
this.searchQuery = searchQuery;
restart();
viewBinding.trackListToolbar.setTitle(searchQuery == null ? getString(R.string.app_name) : searchQuery);
}
public void restart() {
LoaderManager.getInstance(TrackListActivity.this).restartLoader(0, null, loaderCallbacks);
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
final String[] PROJECTION = new String[]{TracksColumns._ID, TracksColumns.NAME,
TracksColumns.DESCRIPTION, TracksColumns.ACTIVITY_TYPE_LOCALIZED, TracksColumns.STARTTIME, TracksColumns.STARTTIME_OFFSET,
TracksColumns.TOTALDISTANCE, TracksColumns.TOTALTIME, TracksColumns.ICON, TracksColumns.MARKER_COUNT};
final String sortOrder = TracksColumns.STARTTIME + " DESC";
if (searchQuery == null) {
return new CursorLoader(TrackListActivity.this, TracksColumns.CONTENT_URI, PROJECTION, null, null, sortOrder);
} else {
final String SEARCH_QUERY = TracksColumns.NAME + " LIKE ? OR " +
TracksColumns.DESCRIPTION + " LIKE ? OR " +
TracksColumns.ACTIVITY_TYPE_LOCALIZED + " LIKE ?";
final String[] selectionArgs = new String[]{"%" + searchQuery + "%", "%" + searchQuery + "%", "%" + searchQuery + "%"};
return new CursorLoader(TrackListActivity.this, TracksColumns.CONTENT_URI, PROJECTION, SEARCH_QUERY, selectionArgs, sortOrder);
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
resourceCursorAdapter.swapCursor(cursor);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
resourceCursorAdapter.swapCursor(null);
}
}
public void onGpsStatusChanged(GpsStatusValue newStatus) {
gpsStatusValue = newStatus;
updateGpsMenuItem(true, recordingStatus.isRecording());
@@ -561,5 +451,6 @@ public class TrackListActivity extends AbstractTrackDeleteActivity implements Co
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
setFloatButton();
adapter.updateRecordingStatus(recordingStatus);
}
}
@@ -189,13 +189,13 @@ public class ContentProviderUtils {
}
String whereClause = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?")));
contentResolver.delete(TracksColumns.CONTENT_URI, whereClause, trackIds.stream().map(id -> Long.toString(id.getId())).toArray(String[]::new));
contentResolver.delete(TracksColumns.CONTENT_URI, whereClause, trackIds.stream().map(trackId -> Long.toString(trackId.id())).toArray(String[]::new));
}
public void deleteTrack(Context context, @NonNull Track.Id trackId) {
// Delete track folder resources.
FileUtils.deleteDirectoryRecurse(FileUtils.getPhotoDir(context, trackId));
contentResolver.delete(TracksColumns.CONTENT_URI, TracksColumns._ID + "=?", new String[]{Long.toString(trackId.getId())});
contentResolver.delete(TracksColumns.CONTENT_URI, TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
}
//TODO Only use for tests; also move to tests.
@@ -228,8 +228,33 @@ public class ContentProviderUtils {
return tracks;
}
public Cursor searchTracks(String searchQuery) {
// Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested.
final String[] PROJECTION = new String[]{TracksColumns._ID, TracksColumns.UUID, TracksColumns.NAME,
TracksColumns.DESCRIPTION, TracksColumns.ACTIVITY_TYPE_LOCALIZED, TracksColumns.STARTTIME,
TracksColumns.STARTTIME_OFFSET, TracksColumns.STOPTIME, TracksColumns.MARKER_COUNT,
TracksColumns.TOTALDISTANCE, TracksColumns.TOTALTIME, TracksColumns.MOVINGTIME,
TracksColumns.AVGSPEED, TracksColumns.AVGMOVINGSPEED, TracksColumns.MAXSPEED,
TracksColumns.MIN_ALTITUDE, TracksColumns.MAX_ALTITUDE, TracksColumns.ALTITUDE_GAIN,
TracksColumns.ALTITUDE_LOSS, TracksColumns.ICON
};
String selection = null;
String[] selectionArgs = null;
final String sortOrder = TracksColumns.STARTTIME + " DESC";
if (searchQuery != null) {
selection = TracksColumns.NAME + " LIKE ? OR " +
TracksColumns.DESCRIPTION + " LIKE ? OR " +
TracksColumns.ACTIVITY_TYPE_LOCALIZED + " LIKE ?";
selectionArgs = new String[]{"%" + searchQuery + "%", "%" + searchQuery + "%", "%" + searchQuery + "%"};
}
return contentResolver.query(TracksColumns.CONTENT_URI, PROJECTION, selection, selectionArgs, sortOrder);
}
public Track getTrack(@NonNull Track.Id trackId) {
try (Cursor cursor = getTrackCursor(TracksColumns._ID + "=?", new String[]{Long.toString(trackId.getId())}, null)) {
try (Cursor cursor = getTrackCursor(TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())}, null)) {
if (cursor != null && cursor.moveToNext()) {
return createTrack(cursor);
}
@@ -278,7 +303,7 @@ public class ContentProviderUtils {
* @param track the track
*/
public void updateTrack(Track track) {
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(track), TracksColumns._ID + "=?", new String[]{Long.toString(track.getId().getId())});
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(track), TracksColumns._ID + "=?", new String[]{Long.toString(track.getId().id())});
}
private ContentValues createContentValues(Track track) {
@@ -286,7 +311,7 @@ public class ContentProviderUtils {
TrackStatistics trackStatistics = track.getTrackStatistics();
if (track.getId() != null) {
values.put(TracksColumns._ID, track.getId().getId());
values.put(TracksColumns._ID, track.getId().id());
}
values.put(TracksColumns.UUID, UUIDUtils.toBytes(track.getUuid()));
values.put(TracksColumns.NAME, track.getName());
@@ -315,7 +340,7 @@ public class ContentProviderUtils {
}
public void updateTrackStatistics(@NonNull Track.Id trackId, @NonNull TrackStatistics trackStatistics) {
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(trackStatistics), TracksColumns._ID + "=?", new String[]{Long.toString(trackId.getId())});
contentResolver.update(TracksColumns.CONTENT_URI, createContentValues(trackStatistics), TracksColumns._ID + "=?", new String[]{Long.toString(trackId.id())});
}
private ContentValues createContentValues(TrackStatistics trackStatistics) {
@@ -413,7 +438,7 @@ public class ContentProviderUtils {
public Integer getNextMarkerNumber(@NonNull Track.Id trackId) {
String[] projection = {MarkerColumns._ID};
String selection = MarkerColumns.TRACKID + "=?";
String[] selectionArgs = new String[]{Long.toString(trackId.getId())};
String[] selectionArgs = new String[]{Long.toString(trackId.id())};
try (Cursor cursor = getMarkerCursor(projection, selection, selectionArgs, MarkerColumns._ID, -1)) {
if (cursor != null) {
return cursor.getCount();
@@ -443,10 +468,10 @@ public class ContentProviderUtils {
String[] selectionArgs;
if (minMarkerId != null) {
selection = MarkerColumns.TRACKID + "=? AND " + MarkerColumns._ID + ">=?";
selectionArgs = new String[]{Long.toString(trackId.getId()), Long.toString(minMarkerId.id())};
selectionArgs = new String[]{Long.toString(trackId.id()), Long.toString(minMarkerId.id())};
} else {
selection = MarkerColumns.TRACKID + "=?";
selectionArgs = new String[]{Long.toString(trackId.getId())};
selectionArgs = new String[]{Long.toString(trackId.id())};
}
return getMarkerCursor(null, selection, selectionArgs, MarkerColumns._ID, maxCount);
}
@@ -510,7 +535,7 @@ public class ContentProviderUtils {
values.put(MarkerColumns.DESCRIPTION, marker.getDescription());
values.put(MarkerColumns.CATEGORY, marker.getCategory());
values.put(MarkerColumns.ICON, marker.getIcon());
values.put(MarkerColumns.TRACKID, marker.getTrackId().getId());
values.put(MarkerColumns.TRACKID, marker.getTrackId().id());
values.put(MarkerColumns.LENGTH, marker.getLength().toM());
values.put(MarkerColumns.DURATION, marker.getDuration().toMillis());
@@ -548,6 +573,35 @@ public class ContentProviderUtils {
return contentResolver.query(MarkerColumns.CONTENT_URI, projection, selection, selectionArgs, sortOrder);
}
public List<Marker> searchMarkers(Track.Id trackId, String query) {
String selection = null;
String[] selectionArgs = null;
String sortOrder = null;
if (query == null) {
if (trackId != null) {
selection = MarkerColumns.TRACKID + " = ?";
selectionArgs = new String[]{Long.toString(trackId.id())};
}
} else {
selection = MarkerColumns.NAME + " LIKE ? OR " +
MarkerColumns.DESCRIPTION + " LIKE ? OR " +
MarkerColumns.CATEGORY + " LIKE ?";
selectionArgs = new String[]{"%" + query + "%", "%" + query + "%", "%" + query + "%"};
sortOrder = MarkerColumns.DEFAULT_SORT_ORDER + " DESC";
}
ArrayList<Marker> markers = new ArrayList<>();
try (Cursor cursor = getMarkerCursor(null, selection, selectionArgs, sortOrder, -1)) {
if (cursor.moveToFirst()) {
do {
markers.add(createMarker(cursor));
} while (cursor.moveToNext());
}
}
return markers;
}
/**
* Fills a {@link TrackPoint} from a cursor.
*
@@ -632,7 +686,7 @@ public class ContentProviderUtils {
@Deprecated
public TrackPoint.Id getLastTrackPointId(@NonNull Track.Id trackId) {
String selection = TrackPointsColumns._ID + "=(SELECT MAX(" + TrackPointsColumns._ID + ") from " + TrackPointsColumns.TABLE_NAME + " WHERE " + TrackPointsColumns.TRACKID + "=?)";
String[] selectionArgs = new String[]{Long.toString(trackId.getId())};
String[] selectionArgs = new String[]{Long.toString(trackId.id())};
try (Cursor cursor = getTrackPointCursor(new String[]{TrackPointsColumns._ID}, selection, selectionArgs, TrackPointsColumns._ID)) {
if (cursor != null && cursor.moveToFirst()) {
return new TrackPoint.Id(cursor.getLong(cursor.getColumnIndexOrThrow(TrackPointsColumns._ID)));
@@ -651,7 +705,7 @@ public class ContentProviderUtils {
@Deprecated
public TrackPoint.Id getTrackPointId(Track.Id trackId, Location location) {
String selection = TrackPointsColumns._ID + "=(SELECT MAX(" + TrackPointsColumns._ID + ") FROM " + TrackPointsColumns.TABLE_NAME + " WHERE " + TrackPointsColumns.TRACKID + "=? AND " + TrackPointsColumns.TIME + "=?)";
String[] selectionArgs = new String[]{Long.toString(trackId.getId()), Long.toString(location.getTime())};
String[] selectionArgs = new String[]{Long.toString(trackId.id()), Long.toString(location.getTime())};
try (Cursor cursor = getTrackPointCursor(new String[]{TrackPointsColumns._ID}, selection, selectionArgs, TrackPointsColumns._ID)) {
if (cursor != null && cursor.moveToFirst()) {
return new TrackPoint.Id(cursor.getLong(cursor.getColumnIndexOrThrow(TrackPointsColumns._ID)));
@@ -681,10 +735,10 @@ public class ContentProviderUtils {
String[] selectionArgs;
if (startTrackPointId != null) {
selection = TrackPointsColumns.TRACKID + "=? AND " + TrackPointsColumns._ID + ">=?";
selectionArgs = new String[]{Long.toString(trackId.getId()), Long.toString(startTrackPointId.getId())};
selectionArgs = new String[]{Long.toString(trackId.id()), Long.toString(startTrackPointId.id())};
} else {
selection = TrackPointsColumns.TRACKID + "=?";
selectionArgs = new String[]{Long.toString(trackId.getId())};
selectionArgs = new String[]{Long.toString(trackId.id())};
}
return getTrackPointCursor(null, selection, selectionArgs, TrackPointsColumns.DEFAULT_SORT_ORDER);
@@ -699,7 +753,7 @@ public class ContentProviderUtils {
@Deprecated
public TrackPoint getLastValidTrackPoint(Track.Id trackId) {
String selection = TrackPointsColumns._ID + "=(SELECT MAX(" + TrackPointsColumns._ID + ") FROM " + TrackPointsColumns.TABLE_NAME + " WHERE " + TrackPointsColumns.TRACKID + "=? AND " + TrackPointsColumns.TYPE + " IN (" + TrackPoint.Type.SEGMENT_START_AUTOMATIC.type_db + "," + TrackPoint.Type.TRACKPOINT.type_db + "))";
String[] selectionArgs = new String[]{Long.toString(trackId.getId())};
String[] selectionArgs = new String[]{Long.toString(trackId.id())};
return findTrackPointBy(selection, selectionArgs);
}
@@ -722,7 +776,7 @@ public class ContentProviderUtils {
*/
private ContentValues createContentValues(TrackPoint trackPoint, Track.Id trackId) {
ContentValues values = new ContentValues();
values.put(TrackPointsColumns.TRACKID, trackId.getId());
values.put(TrackPointsColumns.TRACKID, trackId.id());
values.put(TrackPointsColumns.TYPE, trackPoint.getType().type_db);
if (trackPoint.hasLocation()) {
@@ -805,7 +859,7 @@ public class ContentProviderUtils {
public static String formatIdListForUri(Track.Id... trackIds) {
long[] ids = new long[trackIds.length];
for (int i = 0; i < trackIds.length; i++) {
ids[i] = trackIds[i].getId();
ids[i] = trackIds[i].id();
}
return formatIdListForUri(ids);
@@ -834,18 +888,20 @@ public class ContentProviderUtils {
public SensorStatistics getSensorStats(@NonNull Track.Id trackId) {
SensorStatistics sensorStatistics = null;
try (Cursor cursor = contentResolver.query(ContentUris.withAppendedId(TracksColumns.CONTENT_URI_SENSOR_STATS, trackId.getId()), null, null, null, null)) {
try (Cursor cursor = contentResolver.query(ContentUris.withAppendedId(TracksColumns.CONTENT_URI_SENSOR_STATS, trackId.id()), null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int MAX_HR_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_HR);
final int AVG_HR_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_HR);
final int MAX_CADENCE_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_CADENCE);
final int AVG_CADENCE_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_CADENCE);
final int MAX_POWER_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_POWER);
final int AVG_POWER_INDEX = cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_POWER);
sensorStatistics = new SensorStatistics(
!cursor.isNull(MAX_HR_INDEX) ? HeartRate.of(cursor.getFloat(MAX_HR_INDEX)) : null,
!cursor.isNull(AVG_HR_INDEX) ? HeartRate.of(cursor.getFloat(AVG_HR_INDEX)) : null,
!cursor.isNull(MAX_CADENCE_INDEX) ? Cadence.of(cursor.getFloat(MAX_CADENCE_INDEX)) : null,
!cursor.isNull(AVG_CADENCE_INDEX) ? Cadence.of(cursor.getFloat(AVG_CADENCE_INDEX)) : null,
!cursor.isNull(MAX_POWER_INDEX) ? Power.of(cursor.getFloat(MAX_POWER_INDEX)) : null,
!cursor.isNull(AVG_POWER_INDEX) ? Power.of(cursor.getFloat(AVG_POWER_INDEX)) : null
);
}
@@ -62,7 +62,7 @@ public class CustomContentProvider extends ContentProvider {
/**
* The string representing the query that compute sensor stats from trackpoints table.
* It computes the average for heart rate, cadence, and power (duration-based average) and the maximum for heart rate and cadence.
* It computes the average for heart rate, cadence and power (duration-based average) and the maximum for heart rate, cadence and power.
* Finally, it ignores manual pause (SEGMENT_START_MANUAL).
*/
private final String SENSOR_STATS_QUERY =
@@ -86,7 +86,9 @@ public class CustomContentProvider extends ContentProvider {
"SUM(t." + TrackPointsColumns.SENSOR_POWER + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " +
"/ " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_POWER + " " +
"SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_POWER + ", " +
"MAX(t." + TrackPointsColumns.SENSOR_POWER + ") " + TrackPointsColumns.ALIAS_MAX_POWER + " " +
"FROM " + TrackPointsColumns.TABLE_NAME + " t " +
"WHERE t." + TrackPointsColumns.TRACKID + " = ? " +
@@ -177,7 +177,7 @@ public class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
Track.Id trackId = new Track.Id(cursor.getLong(trackIdIndex));
ContentValues cv = new ContentValues();
cv.put("uuid", UUIDUtils.toBytes(UUID.randomUUID()));
db.update("tracks", cv, "_id = ?", new String[]{String.valueOf(trackId.getId())});
db.update("tracks", cv, "_id = ?", new String[]{String.valueOf(trackId.id())});
} while (cursor.moveToNext());
}
}
@@ -455,7 +455,7 @@ public class CustomSQLiteOpenHelper extends SQLiteOpenHelper {
ContentValues cv = new ContentValues();
cv.put("starttime_offset", zoneRules.getOffset(Instant.ofEpochMilli(startTime)).getTotalSeconds());
db.update("tracks", cv, "_id = ?", new String[]{String.valueOf(trackId.getId())});
db.update("tracks", cv, "_id = ?", new String[]{String.valueOf(trackId.id())});
} while (cursor.moveToNext());
}
}
@@ -86,7 +86,7 @@ public class ShareContentProvider extends CustomContentProvider {
StringBuilder trackIdBuilder = new StringBuilder();
for (Track.Id trackId : trackIds) {
trackIdBuilder.append(trackId.getId()).append(TRACKID_DELIMITER);
trackIdBuilder.append(trackId.id()).append(TRACKID_DELIMITER);
}
trackIdBuilder.deleteCharAt(trackIdBuilder.lastIndexOf(TRACKID_DELIMITER));
@@ -199,7 +199,7 @@ public class ShareContentProvider extends CustomContentProvider {
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
Set<Track.Id> trackIds = parseURI(uri);
final ArrayList<Track> tracks = new ArrayList<>();
String[] trackIdsString = trackIds.stream().map(Track.Id::toString).toArray(String[]::new);
String[] trackIdsString = trackIds.stream().map(id -> String.valueOf(id.id())).toArray(String[]::new);
String whereClause = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?")));
try (Cursor cursor = super.query(TracksColumns.CONTENT_URI, null, whereClause, trackIdsString, TracksColumns._ID)) {
@@ -166,7 +166,7 @@ public class TrackDataHub {
public void loadTrack(final @NonNull Track.Id trackId) {
handler.post(() -> {
if (trackId.equals(selectedTrackId)) {
Log.i(TAG, "Not reloading track " + trackId.getId());
Log.i(TAG, "Not reloading track " + trackId.id());
return;
}
selectedTrackId = trackId;
@@ -326,7 +326,7 @@ public class TrackDataHub {
TrackPoint.Id next = null;
if (localLastSeenTrackPointIdId != null) {
next = new TrackPoint.Id(localLastSeenTrackPointIdId.getId() + 1); //TODO startTrackPointId + 1 is an assumption assumption; should be derived from the DB.
next = new TrackPoint.Id(localLastSeenTrackPointIdId.id() + 1); //TODO startTrackPointId + 1 is an assumption assumption; should be derived from the DB.
}
TrackPoint trackPoint = null;
@@ -344,7 +344,7 @@ public class TrackDataHub {
TrackPoint.Id trackPointId = trackPoint.getId();
// Stop if past the last wanted point
if (maxPointId != null && trackPointId.getId() > maxPointId.getId()) {
if (maxPointId != null && trackPointId.id() > maxPointId.id()) {
break;
}
@@ -355,7 +355,7 @@ public class TrackDataHub {
}
if (samplingFrequency == -1) {
long numTotalPoints = Math.max(0L, lastTrackPointId.getId() - localFirstSeenTrackPointId.getId()); //TODO That is an assumption; should be derived from the DB.
long numTotalPoints = Math.max(0L, lastTrackPointId.id() - localFirstSeenTrackPointId.id()); //TODO That is an assumption; should be derived from the DB.
samplingFrequency = 1 + (int) (numTotalPoints / targetNumPoints);
}
@@ -67,7 +67,7 @@ public class TrackSelection implements ContentProviderUtils.ContentProviderSelec
}
// Builds selection arguments.
ArrayList<String> args = trackIds.stream().map(id -> Long.toString(id.getId())).collect(Collectors.toCollection(ArrayList::new));
ArrayList<String> args = trackIds.stream().map(id -> Long.toString(id.id())).collect(Collectors.toCollection(ArrayList::new));
args.addAll(categories);
args.addAll(fromToArgs);
selectionArgs = args.stream().toArray(String[]::new);
@@ -143,53 +143,20 @@ public class Track {
return Objects.hash(id);
}
public static class Id implements Parcelable {
private final long id;
public Id(long id) {
this.id = id;
}
protected Id(Parcel in) {
id = in.readLong();
}
//TOOD Limit visibility to TrackRecordingService / ContentProvider
public long getId() {
return id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Id id1 = (Id) o;
return id == id1.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
public record Id(long id) implements Parcelable {
@Override
public int describeContents() {
return 0;
}
@NonNull
@Override
public String toString() {
return String.valueOf(id);
}
@Override
public void writeToParcel(Parcel parcel, int i) {
parcel.writeLong(id);
}
public static final Parcelable.Creator<Track.Id> CREATOR = new Parcelable.Creator<>() {
public static final Creator<Track.Id> CREATOR = new Creator<>() {
public Track.Id createFromParcel(Parcel in) {
return new Track.Id(in.readLong());
}
@@ -18,6 +18,7 @@ package de.dennisguse.opentracks.data.models;
import android.location.Location;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -25,7 +26,6 @@ import androidx.annotation.VisibleForTesting;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* Sensor and/or location information for a specific point in time.
@@ -462,40 +462,26 @@ public class TrackPoint {
'}';
}
public static class Id {
public record Id(long id) implements Parcelable {
private final long id;
public Id(long id) {
this.id = id;
}
protected Id(Parcel in) {
id = in.readLong();
}
//TOOD Limit visibility to TrackRecordingService / ContentProvider
public long getId() {
return id;
@Override
public int describeContents() {
return 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TrackPoint.Id id1 = (TrackPoint.Id) o;
return id == id1.id;
public void writeToParcel(Parcel parcel, int i) {
parcel.writeLong(id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
public static final Creator<Id> CREATOR = new Creator<>() {
public Id createFromParcel(Parcel in) {
return new Id(in.readLong());
}
@NonNull
@Override
public String toString() {
return String.valueOf(id);
}
public Id[] newArray(int size) {
return new Id[size];
}
};
}
}
@@ -62,6 +62,7 @@ public interface TrackPointsColumns extends BaseColumns {
String ALIAS_AVG_CADENCE = "avg_cadence";
String ALIAS_MAX_CADENCE = "max_cadence";
String ALIAS_AVG_POWER = "avg_power";
String ALIAS_MAX_POWER = "max_power";
String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " ("
+ _ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
@@ -270,10 +270,12 @@ public class StatisticsRecordedFragment extends Fragment {
viewBinding.statsAvgCadenceValue.setText(avgRPM);
}
if (sensorStatistics.hasPower()) {
String W = String.valueOf(Math.round(sensorStatistics.getAvgPower().getW()));
String maxW = String.valueOf(Math.round(sensorStatistics.getMaxPower().getW()));
String avgW = String.valueOf(Math.round(sensorStatistics.getAvgPower().getW()));
viewBinding.statsPowerGroup.setVisibility(View.VISIBLE);
viewBinding.statsPowerValue.setText(W);
viewBinding.statsMaxPowerValue.setText(maxW);
viewBinding.statsAvgPowerValue.setText(avgW);
}
}
}
@@ -93,7 +93,7 @@ public class TrackFilenameGenerator {
public String getExample() {
Track track = new Track();
track.setName("Berlin");
track.setUuid(UUID.fromString("fefefefefe-0000-1000-8000-00805f9b34fb"));
track.setUuid(UUID.fromString("f81d4fae-7dec-11d0-a765-00a0c91e6bf6"));
track.getTrackStatistics().setStartTime(Instant.ofEpochMilli(0));
return format(track, TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES);
@@ -19,6 +19,7 @@ package de.dennisguse.opentracks.io.file.exporter;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.View;
import android.widget.Toast;
@@ -340,7 +341,7 @@ public class ExportActivity extends FragmentActivity implements ExportService.Ex
}
@Override
public void onExportError(ExportTask exportTask) {
public void onExportError(ExportTask exportTask, String errorMessage) {
trackExportErrorCount++;
String name;
if (exportTask.isMultiExport()) {
@@ -348,6 +349,7 @@ public class ExportActivity extends FragmentActivity implements ExportService.Ex
} else {
name = contentProviderUtils.getTrack(exportTask.getTrackIds().get(0)).getName();
}
Log.e(TAG, "Error exporting " + name + ": " + errorMessage);
trackErrors.add(name);
nextExport(exportTask);
@@ -7,12 +7,12 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.JobIntentService;
import androidx.documentfile.provider.DocumentFile;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.util.ExportUtils;
public class ExportService extends JobIntentService {
@@ -22,7 +22,6 @@ public class ExportService extends JobIntentService {
private static final String EXTRA_RECEIVER = "extra_receiver";
private static final String EXTRA_EXPORT_TASK = "export_task";
private static final String EXTRA_DIRECTORY_URI = "extra_directory_uri";
private static final String TAG = ExportService.class.getSimpleName();
public static void enqueue(Context context, ExportServiceResultReceiver receiver, ExportTask exportTask, Uri directoryUri) {
Intent intent = new Intent(context, JobService.class);
@@ -46,17 +45,19 @@ public class ExportService extends JobIntentService {
// Build directory file.
DocumentFile directoryFile = DocumentFile.fromTreeUri(this, directoryUri);
if (directoryFile == null || !directoryFile.canWrite()) {
Log.e(TAG, "Can't write to directory: " + directoryFile);
bundle.putString(ExportServiceResultReceiver.EXTRA_EXPORT_ERROR_MESSAGE, getString(R.string.export_cannot_write_to_dir) + ": " + directoryFile);
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_ERROR, bundle);
return;
}
// Export.
boolean success = ExportUtils.exportTrack(this, directoryFile, exportTask);
// Send result to the receiver.
int resultCode = success ? ExportServiceResultReceiver.RESULT_CODE_SUCCESS : ExportServiceResultReceiver.RESULT_CODE_ERROR;
resultReceiver.send(resultCode, bundle);
// Export and send result
try {
ExportUtils.exportTrack(this, directoryFile, exportTask);
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_SUCCESS, bundle);
} catch (Exception e) {
bundle.putString(ExportServiceResultReceiver.EXTRA_EXPORT_ERROR_MESSAGE, e.getMessage());
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_ERROR, bundle);
}
}
public static class ExportServiceResultReceiver extends ResultReceiver {
@@ -66,6 +67,8 @@ public class ExportService extends JobIntentService {
public static final String RESULT_EXTRA_EXPORT_TASK = "result_extra_export_task";
public static final String EXTRA_EXPORT_ERROR_MESSAGE = "extra_export_error_message";
private final Receiver receiver;
public ExportServiceResultReceiver(Handler handler, @NonNull Receiver receiver) {
@@ -75,10 +78,10 @@ public class ExportService extends JobIntentService {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
ExportTask exportTask = resultData.getParcelable(ExportServiceResultReceiver.RESULT_EXTRA_EXPORT_TASK);
ExportTask exportTask = resultData.getParcelable(RESULT_EXTRA_EXPORT_TASK);
switch (resultCode) {
case RESULT_CODE_SUCCESS -> receiver.onExportSuccess(exportTask);
case RESULT_CODE_ERROR -> receiver.onExportError(exportTask);
case RESULT_CODE_ERROR -> receiver.onExportError(exportTask, resultData.getString(EXTRA_EXPORT_ERROR_MESSAGE));
default -> throw new RuntimeException("Unknown resultCode.");
}
}
@@ -87,7 +90,7 @@ public class ExportService extends JobIntentService {
default void onExportSuccess(ExportTask exportTask) {
}
default void onExportError(ExportTask exportTask) {
default void onExportError(ExportTask exportTask, String errorMessage) {
}
}
}
@@ -279,8 +279,12 @@ public class GPXTrackExporter implements TrackExporter {
printWriter.println("<gpxtrkx:MovingTime>" + trackStatistics.getMovingTime().getSeconds() + "</gpxtrkx:MovingTime>");
printWriter.println("<gpxtrkx:StoppedTime>" + trackStatistics.getStoppedTime().getSeconds() + "</gpxtrkx:StoppedTime>");
printWriter.println("<gpxtrkx:MaxSpeed>" + trackStatistics.getMaxSpeed().toMPS() + "</gpxtrkx:MaxSpeed>");
printWriter.println("<gpxtrkx:Ascent>" + trackStatistics.getTotalAltitudeGain() + "</gpxtrkx:Ascent>");
printWriter.println("<gpxtrkx:Descent>" + trackStatistics.getTotalAltitudeLoss() + "</gpxtrkx:Descent>");
if (trackStatistics.hasTotalAltitudeGain()) {
printWriter.println("<gpxtrkx:Ascent>" + trackStatistics.getTotalAltitudeGain() + "</gpxtrkx:Ascent>");
}
if (trackStatistics.hasTotalAltitudeLoss()) {
printWriter.println("<gpxtrkx:Descent>" + trackStatistics.getTotalAltitudeLoss() + "</gpxtrkx:Descent>");
}
printWriter.println("</gpxtrkx:TrackStatsExtension>");
printWriter.println("</extensions>");
@@ -88,7 +88,7 @@ class TrackRecordingManager implements SharedPreferences.OnSharedPreferenceChang
trackId = resumeTrackId;
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
Log.e(TAG, "Ignore resumeTrack. Track " + trackId.getId() + " does not exists.");
Log.e(TAG, "Ignore resumeTrack. Track " + trackId.id() + " does not exists.");
return false;
}
@@ -32,6 +32,8 @@ import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.Duration;
import de.dennisguse.opentracks.data.models.Distance;
@@ -244,6 +246,15 @@ public class TrackRecordingService extends Service implements TrackPointCreator.
public void newGpsStatus(GpsStatusValue gpsStatusValue) {
Log.e(TAG, "newGpsStatus: " + gpsStatusValue.message);
if (notificationManager == null) {
StringWriter writer = new StringWriter();
Exception e = new RuntimeException("TrackRecording.newGpsStatus() called after onDestroy(); objectID: " + this + " with thread: " + Thread.currentThread().toString());
e.printStackTrace(new PrintWriter(writer));
Log.e(TAG, e.getMessage() + " " + writer);
return;
}
notificationManager.updateContent(getString(gpsStatusValue.message));
gpsStatusObservable.postValue(gpsStatusValue);
}
@@ -16,8 +16,9 @@ import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
public class SettingsActivity extends AbstractActivity implements ChooseActivityTypeDialogFragment.ChooseActivityTypeCaller {
public static final String EXTRAS_CHECK_EXPORT_DIRECTORY = "Check Export Directory";
private boolean checkExportDirectory = false;
public static final String EXTRAS_EXPORT_ERROR_MESSAGE = "Export error message";
private String exportErrorMessage = null;
public static final String FRAGMENT_KEY = "fragmentKey";
@@ -30,8 +31,8 @@ public class SettingsActivity extends AbstractActivity implements ChooseActivity
super.onCreate(savedInstanceState);
Intent intent = getIntent();
if (intent != null && intent.hasExtra(EXTRAS_CHECK_EXPORT_DIRECTORY)) {
checkExportDirectory = true;
if (intent != null) {
exportErrorMessage = intent.getStringExtra(EXTRAS_EXPORT_ERROR_MESSAGE);
}
if (savedInstanceState != null) {
@@ -48,15 +49,15 @@ public class SettingsActivity extends AbstractActivity implements ChooseActivity
@Override
protected void onResume() {
if (checkExportDirectory) {
checkExportDirectory = false;
if (exportErrorMessage != null) {
new AlertDialog.Builder(this)
.setIcon(R.drawable.ic_logo_24dp)
.setTitle(R.string.app_name)
.setMessage(R.string.export_error_post_workout)
.setMessage(getString(R.string.export_error_post_workout) + "\n" + exportErrorMessage)
.setNeutralButton(android.R.string.ok, null)
.create()
.show();
exportErrorMessage = null;
}
super.onResume();
@@ -50,7 +50,7 @@ public class ShareUtils {
for (Track.Id trackId : trackIds) {
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
Log.e(TAG, "TrackId " + trackId.getId() + " could not be resolved.");
Log.e(TAG, "TrackId " + trackId.id() + " could not be resolved.");
continue;
}
@@ -9,13 +9,15 @@ public class SensorStatistics {
private final HeartRate avgHr;
private final Cadence maxCadence;
private final Cadence avgCadence;
private final Power maxPower;
private final Power avgPower;
public SensorStatistics(HeartRate maxHr, HeartRate avgHr, Cadence maxCadence, Cadence avgCadence, Power avgPower) {
public SensorStatistics(HeartRate maxHr, HeartRate avgHr, Cadence maxCadence, Cadence avgCadence, Power maxPower, Power avgPower) {
this.maxHr = maxHr;
this.avgHr = avgHr;
this.maxCadence = maxCadence;
this.avgCadence = avgCadence;
this.maxPower = maxPower;
this.avgPower = avgPower;
}
@@ -47,6 +49,10 @@ public class SensorStatistics {
return avgPower != null;
}
public Power getMaxPower() {
return maxPower;
}
public Power getAvgPower() {
return avgPower;
}
@@ -0,0 +1,293 @@
package de.dennisguse.opentracks.ui;
import android.app.ActivityOptions;
import android.content.Intent;
import android.database.Cursor;
import android.util.Pair;
import android.util.SparseBooleanArray;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.recyclerview.widget.RecyclerView;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackRecordedActivity;
import de.dennisguse.opentracks.TrackRecordingActivity;
import de.dennisguse.opentracks.data.models.ActivityType;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.TracksColumns;
import de.dennisguse.opentracks.databinding.TrackListItemBinding;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.settings.UnitSystem;
import de.dennisguse.opentracks.ui.util.ActivityUtils;
import de.dennisguse.opentracks.ui.util.ListItemUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.StringUtils;
public class TrackListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ActionMode.Callback {
private static final String TAG = TrackListAdapter.class.getSimpleName();
private final AppCompatActivity context;
private final RecyclerView recyclerView;
private final SparseBooleanArray selection = new SparseBooleanArray();
private RecordingStatus recordingStatus;
private UnitSystem unitSystem;
private Cursor tracks;
private boolean selectionMode = false;
private ActivityUtils.ContextualActionModeCallback actionModeCallback;
private ActionMode actionMode;
public TrackListAdapter(AppCompatActivity context, RecyclerView recyclerView, RecordingStatus recordingStatus, UnitSystem unitSystem) {
this.context = context;
this.recyclerView = recyclerView;
this.recordingStatus = recordingStatus;
this.unitSystem = unitSystem;
}
public void setActionModeCallback(ActivityUtils.ContextualActionModeCallback actionModeCallback) {
this.actionModeCallback = actionModeCallback;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.track_list_item, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ViewHolder viewHolder = (ViewHolder) holder;
tracks.moveToPosition(position);
viewHolder.bind(tracks);
}
@Override
public int getItemCount() {
if (tracks == null) {
return 0;
}
return tracks.getCount();
}
public void swapData(Cursor tracks) {
this.tracks = tracks;
this.notifyDataSetChanged();
}
public void updateRecordingStatus(RecordingStatus recordingStatus) {
this.recordingStatus = recordingStatus;
}
public void updateUnitSystem(UnitSystem unitSystem) {
this.unitSystem = unitSystem;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.list_context_menu, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
selectionMode = true;
actionModeCallback.onPrepare(menu, getCheckedPositions(), getCheckedIds(), true);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
if (actionModeCallback.onClick(item.getItemId(), getCheckedPositions(), getCheckedIds())) {
mode.finish();
}
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
selectionMode = false;
setAllSelected(false);
selection.clear();
actionModeCallback.onDestroy();
}
public void setAllSelected(boolean isSelected) {
for (int i = 0; i < getItemCount(); i++) {
ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(i);
holder.setSelected(isSelected);
}
}
private int[] getCheckedPositions() {
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < selection.size(); i++) {
if (selection.valueAt(i)) {
positions.add(selection.keyAt(i));
}
}
return positions.stream().mapToInt(i -> i).toArray();
}
private long[] getCheckedIds() {
List<Long> ids = new ArrayList<>();
for (int i = 0; i < selection.size(); i++) {
if (selection.valueAt(i)) {
ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(selection.keyAt(i));
ids.add(holder.getId());
}
}
return ids.stream().mapToLong(i -> i).toArray();
}
public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
private final TrackListItemBinding viewBinding;
private final View view;
private ActivityType activityType;
private String name;
private int markerCount;
private Duration totalTime;
private Distance totalDistance;
private Instant startTime;
private ZoneOffset zoneOffset;
private String activityTypeLocalized;
private String description;
private Track.Id trackId;
public ViewHolder(@NonNull View itemView) {
super(itemView);
viewBinding = TrackListItemBinding.bind(itemView);
view = itemView;
view.setOnClickListener(this);
view.setOnLongClickListener(this);
}
private void getData(Cursor track) {
final int idIndex = track.getColumnIndexOrThrow(TracksColumns._ID);
final int nameIndex = track.getColumnIndexOrThrow(TracksColumns.NAME);
final int descriptionIndex = track.getColumnIndexOrThrow(TracksColumns.DESCRIPTION);
final int activityTypeLocalizedIndex = track.getColumnIndexOrThrow(TracksColumns.ACTIVITY_TYPE_LOCALIZED);
final int startTimeIndex = track.getColumnIndexOrThrow(TracksColumns.STARTTIME);
final int startTimeOffsetIndex = track.getColumnIndexOrThrow(TracksColumns.STARTTIME_OFFSET);
final int totalDistanceIndex = track.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE);
final int totalTimeIndex = track.getColumnIndexOrThrow(TracksColumns.TOTALTIME);
final int iconIndex = track.getColumnIndexOrThrow(TracksColumns.ICON);
final int markerCountIndex = track.getColumnIndexOrThrow(TracksColumns.MARKER_COUNT);
activityType = ActivityType.findBy(track.getString(iconIndex));
name = track.getString(nameIndex);
markerCount = track.getInt(markerCountIndex);
totalTime = Duration.ofMillis(track.getLong(totalTimeIndex));
totalDistance = Distance.of(track.getFloat(totalDistanceIndex));
startTime = Instant.ofEpochMilli(track.getLong(startTimeIndex));
zoneOffset = ZoneOffset.ofTotalSeconds(track.getInt(startTimeOffsetIndex));
activityTypeLocalized = track.getString(activityTypeLocalizedIndex);
description = track.getString(descriptionIndex);
trackId = new Track.Id(track.getLong(idIndex));
}
public void bind(Cursor track) {
getData(track);
int iconId = activityType.getIconDrawableId();
int iconDesc = R.string.image_track;
if (recordingStatus.isRecording()) {
iconId = R.drawable.ic_track_recording;
iconDesc = R.string.image_record;
}
viewBinding.trackListItemIcon.setImageResource(iconId);
viewBinding.trackListItemIcon.setContentDescription(context.getString(iconDesc));
viewBinding.trackListItemName.setText(name);
String timeDistanceText = ListItemUtils.getTimeDistanceText(context, unitSystem, recordingStatus.isRecording(), totalTime, totalDistance, markerCount);
viewBinding.trackListItemTimeDistance.setText(timeDistanceText);
viewBinding.trackListItemMarkerCountIcon.setVisibility(markerCount > 0 ? View.VISIBLE : View.GONE);
viewBinding.trackListItemMarkerCount.setText(markerCount > 0 ? Integer.toString(markerCount) : null);
if (!recordingStatus.isRecording()) {
ListItemUtils.setDateAndTime(context, viewBinding.trackListItemDate, viewBinding.trackListItemTime, startTime, zoneOffset);
} else {
viewBinding.trackListItemDate.setText(null);
viewBinding.trackListItemTime.setText(null);
}
String category = activityType == null ? activityTypeLocalized : null;
String categoryDescription = StringUtils.getCategoryDescription(category, description);
viewBinding.trackListItemCategoryDescription.setText(categoryDescription);
viewBinding.trackListItemCategoryDescription.setVisibility(categoryDescription.equals("") ? View.GONE : View.VISIBLE);
setSelected(selection.get(getLayoutPosition()));
}
public void setSelected(boolean isSelected) {
selection.put(getLayoutPosition(), isSelected);
view.setActivated(isSelected);
}
public long getId() {
return trackId.id();
}
@Override
public void onClick(View v) {
if (selectionMode) {
setSelected(!view.isActivated());
actionMode.invalidate();
return;
}
if (recordingStatus.isRecording() && trackId.equals(recordingStatus.getTrackId())) {
// Is recording -> open record activity.
Intent newIntent = IntentUtils.newIntent(context, TrackRecordingActivity.class)
.putExtra(TrackRecordedActivity.EXTRA_TRACK_ID, trackId);
context.startActivity(newIntent);
} else {
// Not recording -> open detail activity.
Intent newIntent = IntentUtils.newIntent(context, TrackRecordedActivity.class)
.putExtra(TrackRecordedActivity.EXTRA_TRACK_ID, trackId);
ActivityOptions activityOptions = ActivityOptions.makeSceneTransitionAnimation(
context,
new Pair<>(view.findViewById(R.id.track_list_item_icon), TrackRecordedActivity.VIEW_TRACK_ICON));
context.startActivity(newIntent, activityOptions.toBundle());
}
}
@Override
public boolean onLongClick(View v) {
actionMode = context.startSupportActionMode(TrackListAdapter.this);
setSelected(true);
return true;
}
}
}
@@ -18,19 +18,16 @@ package de.dennisguse.opentracks.ui.markers;
import android.app.SearchManager;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.SearchView;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import java.util.List;
import java.util.Objects;
import de.dennisguse.opentracks.AbstractActivity;
@@ -38,15 +35,12 @@ import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.databinding.MarkerListBinding;
import de.dennisguse.opentracks.services.RecordingStatus;
import de.dennisguse.opentracks.services.TrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.share.ShareUtils;
import de.dennisguse.opentracks.ui.markers.DeleteMarkerDialogFragment.DeleteMarkerCaller;
import de.dennisguse.opentracks.ui.util.ActivityUtils;
import de.dennisguse.opentracks.ui.util.ScrollVisibleViews;
import de.dennisguse.opentracks.util.IntentUtils;
/**
@@ -54,23 +48,20 @@ import de.dennisguse.opentracks.util.IntentUtils;
*
* @author Leif Hendrik Wilden
*/
public class MarkerListActivity extends AbstractActivity implements DeleteMarkerCaller {
public class MarkerListActivity extends AbstractActivity {
public static final String EXTRA_TRACK_ID = "track_id";
private static final String TAG = MarkerListActivity.class.getSimpleName();
private ContentProviderUtils contentProviderUtils;
private RecordingStatus recordingStatus = TrackRecordingService.STATUS_DEFAULT;
private Track track;
private MarkerResourceCursorAdapter resourceCursorAdapter;
private Track.Id trackId;
private MarkerListAdapter adapter;
private MarkerListBinding viewBinding;
private final MarkerLoaderCallback loaderCallbacks = new MarkerLoaderCallback();
private TrackRecordingServiceConnection trackRecordingServiceConnection;
private final TrackRecordingServiceConnection.Callback bindCallback = (service, unused) -> service.getRecordingStatusObservable()
@@ -105,33 +96,24 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
private MenuItem insertMarkerMenuItem;
private MenuItem searchMenuItem;
private String searchQuery;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL);
Track.Id trackId = getIntent().getParcelableExtra(EXTRA_TRACK_ID);
contentProviderUtils = new ContentProviderUtils(this);
track = trackId != null ? contentProviderUtils.getTrack(trackId) : null;
viewBinding.markerList.setEmptyView(viewBinding.markerListEmpty);
viewBinding.markerList.setOnItemClickListener((parent, view, position, id) -> {
resourceCursorAdapter.markerInvalid(id);
Intent intent = IntentUtils.newIntent(MarkerListActivity.this, MarkerDetailActivity.class)
.putExtra(MarkerDetailActivity.EXTRA_MARKER_ID, new Marker.Id(id));
startActivity(intent);
});
resourceCursorAdapter = new MarkerResourceCursorAdapter(this, R.layout.list_item);
ScrollVisibleViews scrollVisibleViews = new ScrollVisibleViews(resourceCursorAdapter);
viewBinding.markerList.setOnScrollListener(scrollVisibleViews);
viewBinding.markerList.setAdapter(resourceCursorAdapter);
ActivityUtils.configureListViewContextualMenu(viewBinding.markerList, contextualActionModeCallback);
trackId = getIntent().getParcelableExtra(EXTRA_TRACK_ID);
trackRecordingServiceConnection = new TrackRecordingServiceConnection(bindCallback);
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
adapter = new MarkerListAdapter(this, viewBinding.markerList, null);
viewBinding.markerList.setLayoutManager(layoutManager);
viewBinding.markerList.setAdapter(adapter);
setSupportActionBar(viewBinding.markerListToolbar);
adapter.setActionModeCallback(contextualActionModeCallback);
}
@Override
@@ -145,7 +127,7 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
super.onResume();
trackRecordingServiceConnection.bind(this);
this.invalidateOptionsMenu();
loadData(getIntent());
loadData();
}
@Override
@@ -157,9 +139,8 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
@Override
protected void onDestroy() {
super.onDestroy();
resourceCursorAdapter.clear();
viewBinding = null;
resourceCursorAdapter = null;
adapter = null;
contentProviderUtils = null;
}
@@ -183,15 +164,15 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
insertMarkerMenuItem.setVisible(track != null && track.getId().equals(recordingStatus.getTrackId()));
insertMarkerMenuItem.setVisible(trackId != null && trackId.equals(recordingStatus.getTrackId()));
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (track != null && item.getItemId() == R.id.marker_list_insert_marker) {
if (trackId != null && item.getItemId() == R.id.marker_list_insert_marker) {
Intent intent = IntentUtils.newIntent(this, MarkerEditActivity.class)
.putExtra(MarkerEditActivity.EXTRA_TRACK_ID, track.getId());
.putExtra(MarkerEditActivity.EXTRA_TRACK_ID, trackId);
startActivity(intent);
return true;
}
@@ -229,7 +210,6 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
if (itemId == R.id.list_context_menu_edit) {
if (markerIds.length == 1) {
resourceCursorAdapter.markerInvalid(markerIds[0].id());
Intent intent = IntentUtils.newIntent(this, MarkerEditActivity.class)
.putExtra(MarkerEditActivity.EXTRA_MARKER_ID, markerIds[0]);
startActivity(intent);
@@ -243,23 +223,13 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
}
if (itemId == R.id.list_context_menu_select_all) {
for (int i = 0; i < viewBinding.markerList.getCount(); i++) {
viewBinding.markerList.setItemChecked(i, true);
}
adapter.setAllSelected(true);
return false;
}
return false;
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_SEARCH && searchMenuItem != null) {
return true;
}
return super.onKeyUp(keyCode, event);
}
@Override
public void onBackPressed() {
SearchView searchView = (SearchView) searchMenuItem.getActionView();
@@ -267,8 +237,9 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
searchView.setIconified(true);
}
if (loaderCallbacks.getSearchQuery() != null) {
loaderCallbacks.setSearch(null);
if (searchQuery != null) {
searchQuery = null;
loadData();
return;
}
@@ -276,78 +247,33 @@ public class MarkerListActivity extends AbstractActivity implements DeleteMarker
}
@Override
public void onNewIntent(Intent intent) {
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
loadData(intent);
}
private void loadData(Intent intent) {
String searchQuery = null;
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
searchQuery = intent.getStringExtra(SearchManager.QUERY);
} else {
searchQuery = null;
}
loaderCallbacks.setSearch(searchQuery);
}
@Override
public void onMarkerDeleted() {
// Do nothing
private void loadData() {
viewBinding.markerListToolbar.setTitle(Objects.requireNonNullElseGet(searchQuery, () -> getString(R.string.menu_markers)));
List<Marker> markers = contentProviderUtils.searchMarkers(trackId, searchQuery);
adapter.swapData(markers);
if (markers.isEmpty()) {
viewBinding.markerListEmpty.setVisibility(View.VISIBLE);
viewBinding.markerList.setVisibility(View.GONE);
} else {
viewBinding.markerListEmpty.setVisibility(View.GONE);
viewBinding.markerList.setVisibility(View.VISIBLE);
}
}
private void onRecordingStatusChanged(RecordingStatus status) {
recordingStatus = status;
}
private class MarkerLoaderCallback implements LoaderManager.LoaderCallbacks<Cursor> {
private String searchQuery = null;
public String getSearchQuery() {
return searchQuery;
}
public void setSearch(String searchQuery) {
this.searchQuery = searchQuery;
restart();
viewBinding.markerListToolbar.setTitle(Objects.requireNonNullElseGet(searchQuery, () -> getString(R.string.menu_markers)));
}
public void restart() {
LoaderManager.getInstance(MarkerListActivity.this).restartLoader(0, null, loaderCallbacks);
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
final String[] PROJECTION = new String[]{MarkerColumns._ID,
MarkerColumns.NAME, MarkerColumns.DESCRIPTION, MarkerColumns.CATEGORY,
MarkerColumns.TIME, MarkerColumns.PHOTOURL, MarkerColumns.TRACKID};
if (searchQuery == null) {
if (track != null) {
return new CursorLoader(MarkerListActivity.this, MarkerColumns.CONTENT_URI, PROJECTION, MarkerColumns.TRACKID + "=?", new String[]{String.valueOf(track.getId().getId())}, null);
} else {
return new CursorLoader(MarkerListActivity.this, MarkerColumns.CONTENT_URI, PROJECTION, null, null, null);
}
} else {
final String SEARCH_QUERY = MarkerColumns.NAME + " LIKE ? OR " +
MarkerColumns.DESCRIPTION + " LIKE ? OR " +
MarkerColumns.CATEGORY + " LIKE ?";
final String[] selectionArgs = new String[]{"%" + searchQuery + "%", "%" + searchQuery + "%", "%" + searchQuery + "%"};
return new CursorLoader(MarkerListActivity.this, MarkerColumns.CONTENT_URI, PROJECTION, SEARCH_QUERY, selectionArgs, MarkerColumns.DEFAULT_SORT_ORDER + " DESC");
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
resourceCursorAdapter.swapCursor(cursor);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
resourceCursorAdapter.swapCursor(null);
}
}
}
@@ -0,0 +1,276 @@
package de.dennisguse.opentracks.ui.markers;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.util.Log;
import android.util.LruCache;
import android.util.SparseBooleanArray;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.recyclerview.widget.RecyclerView;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Marker;
import de.dennisguse.opentracks.databinding.MarkerListItemBinding;
import de.dennisguse.opentracks.ui.util.ActivityUtils;
import de.dennisguse.opentracks.ui.util.ExecutorListViewService;
import de.dennisguse.opentracks.ui.util.ListItemUtils;
import de.dennisguse.opentracks.ui.util.ThemeUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.StringUtils;
public class MarkerListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ActionMode.Callback {
private static final String TAG = MarkerListAdapter.class.getSimpleName();
private final AppCompatActivity context;
private final RecyclerView recyclerView;
private List<Marker> markers;
private final SparseBooleanArray selection;
private boolean selectionMode;
private ActivityUtils.ContextualActionModeCallback actionModeCallback;
private ActionMode actionMode;
private final ExecutorListViewService<Marker.Id> executorService = new ExecutorListViewService<>(128);
private final LruCache<Marker.Id, Bitmap> memoryCache;
public MarkerListAdapter(AppCompatActivity context, RecyclerView recyclerView, List<Marker> markers) {
this.context = context;
this.recyclerView = recyclerView;
this.markers = markers;
selection = new SparseBooleanArray();
selectionMode = false;
// Get max available VM memory, exceeding this amount will throw an OutOfMemory exception.
final long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
final int cacheSize = (int) (Runtime.getRuntime().maxMemory() - usedMemory) / 8;
memoryCache = new LruCache<>(cacheSize) {
@Override
protected int sizeOf(Marker.Id key, Bitmap value) {
return value.getByteCount();
}
};
}
public void setActionModeCallback(ActivityUtils.ContextualActionModeCallback actionModeCallback) {
this.actionModeCallback = actionModeCallback;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.marker_list_item, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ViewHolder viewHolder = (ViewHolder) holder;
viewHolder.bind(markers.get(position));
}
@Override
public int getItemCount() {
if (markers == null) {
return 0;
}
return markers.size();
}
public void swapData(List<Marker> markers) {
this.markers = markers;
this.notifyDataSetChanged();
}
@Override
public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
actionMode.getMenuInflater().inflate(R.menu.list_context_menu, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
selectionMode = true;
actionModeCallback.onPrepare(menu, getCheckedPositions(), getCheckedIds(), true);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
if (actionModeCallback.onClick(menuItem.getItemId(), getCheckedPositions(), getCheckedIds())) {
actionMode.finish();
}
return true;
}
@Override
public void onDestroyActionMode(ActionMode actionMode) {
selectionMode = false;
setAllSelected(false);
selection.clear();
actionModeCallback.onDestroy();
}
public void setAllSelected(boolean isSelected) {
for (int i = 0; i < getItemCount(); i++) {
ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(i);
holder.setSelected(isSelected);
}
}
private int[] getCheckedPositions() {
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < selection.size(); i++) {
if (selection.valueAt(i)) {
positions.add(selection.keyAt(i));
}
}
return positions.stream().mapToInt(i -> i).toArray();
}
private long[] getCheckedIds() {
List<Long> ids = new ArrayList<>();
for (int i = 0; i < selection.size(); i++) {
if (selection.valueAt(i)) {
ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(selection.keyAt(i));
ids.add(holder.getId());
}
}
return ids.stream().mapToLong(i -> i).toArray();
}
public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
private final MarkerListItemBinding viewBinding;
private final View view;
private Marker marker;
public ViewHolder(@NonNull View view) {
super(view);
viewBinding = MarkerListItemBinding.bind(view);
this.view = view;
view.setOnClickListener(this);
view.setOnLongClickListener(this);
}
public void bind(Marker marker) {
this.marker = marker;
viewBinding.markerListItemPhoto.setVisibility(marker.hasPhoto() ? View.VISIBLE : View.GONE);
viewBinding.markerListItemTextGradient.setVisibility(marker.hasPhoto() ? View.VISIBLE : View.GONE);
if (marker.hasPhoto()) {
int height = ThemeUtils.getPhotoHeight(context);
ViewGroup.LayoutParams params = viewBinding.markerListItemPhoto.getLayoutParams();
params.height = height;
viewBinding.markerListItemPhoto.setLayoutParams(params);
viewBinding.markerListItemTextGradient.setLayoutParams(params);
asyncLoadPhoto(viewBinding.markerListItemPhoto, marker.getPhotoURI(), marker.getId());
}
viewBinding.markerListItemName.setText(marker.getName());
{
ZoneOffset timeZone = new ContentProviderUtils(context).getTrack(marker.getTrackId()).getZoneOffset();
ListItemUtils.setDateAndTime(context, viewBinding.markerListItemDate, viewBinding.markerListItemTime, marker.getTime(), timeZone);
}
String categoryDescription = StringUtils.getCategoryDescription(marker.getCategory(), marker.getDescription());
viewBinding.markerListItemTimeDistance.setText(categoryDescription);
setSelected(selection.get(getLayoutPosition()));
}
public void setSelected(boolean isSelected) {
selection.put(getLayoutPosition(), isSelected);
view.setActivated(isSelected);
}
public long getId() {
return marker.getId().id();
}
@Override
public void onClick(View view) {
if (selectionMode) {
setSelected(!view.isActivated());
actionMode.invalidate();
} else {
Intent intent = IntentUtils.newIntent(context, MarkerDetailActivity.class)
.putExtra(MarkerDetailActivity.EXTRA_MARKER_ID, marker.getId());
context.startActivity(intent);
}
}
@Override
public boolean onLongClick(View view) {
actionMode = context.startSupportActionMode(MarkerListAdapter.this);
setSelected(true);
return true;
}
}
/**
* It loads the photoUrl in the imageView from view.
* It takes the photo from cache or from storage if isn't in the cache.
*
* @param imageView view object where photo will be loaded.
* @param photoUri photo's url.
* @param id marker's id where photo belong.
*/
private void asyncLoadPhoto(ImageView imageView, Uri photoUri, Marker.Id id) {
Bitmap bm = memoryCache.get(id);
imageView.setImageBitmap(bm);
if (bm != null) {
return;
}
executorService.execute(id, () -> {
try (InputStream inputStream = context.getContentResolver().openInputStream(photoUri)) {
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
int height = ThemeUtils.getPhotoHeight(context);
Log.d(TAG, "Width : " + (bitmap.getWidth() / (bitmap.getHeight() / height)) + " | Height: " + height);
final Bitmap finalPhoto = ThumbnailUtils.extractThumbnail(bitmap, bitmap.getWidth() / (bitmap.getHeight() / height), height);
context.runOnUiThread(() -> imageView.setImageBitmap(finalPhoto));
memoryCache.put(id, finalPhoto);
} catch (IOException e) {
Log.e(TAG, "Failed to image " + e);
}
});
}
}
@@ -1,195 +0,0 @@
package de.dennisguse.opentracks.ui.markers;
import android.app.Activity;
import android.content.Context;
import android.content.res.TypedArray;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.collection.LruCache;
import androidx.cursoradapter.widget.ResourceCursorAdapter;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.OffsetDateTime;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.tables.MarkerColumns;
import de.dennisguse.opentracks.ui.util.ExecutorListViewService;
import de.dennisguse.opentracks.ui.util.ListItemUtils;
import de.dennisguse.opentracks.ui.util.ScrollVisibleViews;
public class MarkerResourceCursorAdapter extends ResourceCursorAdapter implements ScrollVisibleViews.VisibleViewsListener {
private static final String TAG = MarkerResourceCursorAdapter.class.getSimpleName();
private static final int LIST_PREFERRED_ITEM_HEIGHT_DEFAULT = 128;
private final Activity activity;
//TODO Should be Marker.Id
private final ExecutorListViewService<Long> executorService = new ExecutorListViewService<>(LIST_PREFERRED_ITEM_HEIGHT_DEFAULT);
private boolean scroll = false;
// Cache size is in bytes.
private final LruCache<String, Bitmap> memoryCache;
public MarkerResourceCursorAdapter(Activity activity, int layout) {
super(activity, layout, null, 0);
this.activity = activity;
// Get max available VM memory, exceeding this amount will throw an OutOfMemory exception.
final long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
final int cacheSize = (int) (Runtime.getRuntime().maxMemory() - usedMemory) / 8;
memoryCache = new LruCache<>(cacheSize) {
@Override
protected int sizeOf(@NonNull String key, @NonNull Bitmap bitmap) {
return bitmap.getByteCount();
}
};
}
@Override
public void bindView(View view, Context context, Cursor cursor) {
int idIndex = cursor.getColumnIndex(MarkerColumns._ID);
int nameIndex = cursor.getColumnIndex(MarkerColumns.NAME);
int timeIndex = cursor.getColumnIndexOrThrow(MarkerColumns.TIME);
int categoryIndex = cursor.getColumnIndex(MarkerColumns.CATEGORY);
int descriptionIndex = cursor.getColumnIndex(MarkerColumns.DESCRIPTION);
int photoUrlIndex = cursor.getColumnIndex(MarkerColumns.PHOTOURL);
int trackIdIndex = cursor.getColumnIndex(MarkerColumns.TRACKID);
long id = cursor.getLong(idIndex);
int iconId = MarkerUtils.ICON_ID;
String name = cursor.getString(nameIndex);
long time = cursor.getLong(timeIndex);
String category = cursor.getString(categoryIndex);
String description = cursor.getString(descriptionIndex);
String photoUrl = cursor.getString(photoUrlIndex);
long trackId = cursor.getLong(trackIdIndex);
view.setTag(String.valueOf(id));
boolean hasPhoto = photoUrl != null && !photoUrl.equals("");
ImageView imageView = view.findViewById(R.id.list_item_photo);
ImageView textGradient = view.findViewById(R.id.list_item_text_gradient);
imageView.setVisibility(hasPhoto ? View.VISIBLE : View.GONE);
textGradient.setVisibility(hasPhoto ? View.VISIBLE : View.GONE);
imageView.setImageBitmap(null);
if (hasPhoto) {
int height = getPhotoHeight(activity);
ViewGroup.LayoutParams params = imageView.getLayoutParams();
params.height = height;
imageView.setLayoutParams(params);
if (getBitmapFromMemCache(String.valueOf(id)) != null || !scroll) {
asyncLoadPhoto(view, imageView, photoUrl, id);
}
}
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
Track track = contentProviderUtils.getTrack(new Track.Id(trackId));
ListItemUtils.setListItem(activity, view, false, iconId, R.string.image_marker, name, null, null, 0,
OffsetDateTime.ofInstant(Instant.ofEpochMilli(time), track.getZoneOffset()), category, description, hasPhoto);
}
public void clear() {
executorService.shutdown();
}
@Override
public void onViewVisible(View view, int position) {
scroll = true;
Cursor cursor = getCursor();
if (!cursor.moveToPosition(position)) {
return;
}
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MarkerColumns._ID));
String photoUrl = cursor.getString(cursor.getColumnIndexOrThrow(MarkerColumns.PHOTOURL));
boolean hasPhoto = photoUrl != null && !photoUrl.equals("");
if (hasPhoto) {
ImageView imageView = view.findViewById(R.id.list_item_photo);
asyncLoadPhoto(view, imageView, photoUrl, id);
}
}
public void markerInvalid(long id) {
memoryCache.remove(String.valueOf(id));
scroll = false;
}
private void addBitmapToMemoryCache(String key, Bitmap bitmap) {
synchronized (memoryCache) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap);
}
}
}
private Bitmap getBitmapFromMemCache(String key) {
return memoryCache.get(key);
}
/**
* It loads the photoUrl in the imageView from view.
* It takes the photo from cache or from storage if isn't in the cache.
*
* @param view item's view.
* @param imageView view object where photo will be loaded.
* @param photoUrl photo's url.
* @param id marker's id where photo belong.
*/
private void asyncLoadPhoto(View view, ImageView imageView, String photoUrl, long id) {
Bitmap photo = getBitmapFromMemCache(String.valueOf(id));
imageView.setImageBitmap(photo);
if (photo == null) {
executorService.execute(id, () -> {
try (InputStream inputStream = activity.getContentResolver().openInputStream(Uri.parse(photoUrl))) {
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
int height = getPhotoHeight(activity);
Log.d(TAG, "Width : " + (bitmap.getWidth() / (bitmap.getHeight() / height)) + " | Height: " + height);
final Bitmap finalPhoto = ThumbnailUtils.extractThumbnail(bitmap, bitmap.getWidth() / (bitmap.getHeight() / height), height);
addBitmapToMemoryCache(String.valueOf(id), finalPhoto);
if (view.getTag().equals(String.valueOf(id))) {
activity.runOnUiThread(() -> imageView.setImageBitmap(finalPhoto));
}
} catch (IOException e) {
Log.e(TAG, "Failed to image " + e);
}
});
}
}
/**
* Gets the photo height.
*
* @param context the context
*/
private static int getPhotoHeight(Context context) {
int[] attrs = new int[]{android.R.attr.listPreferredItemHeight};
TypedArray typeArray = context.obtainStyledAttributes(attrs);
int height = typeArray.getDimensionPixelSize(0, LIST_PREFERRED_ITEM_HEIGHT_DEFAULT);
typeArray.recycle();
return 2 * height;
}
}
@@ -18,16 +18,20 @@ package de.dennisguse.opentracks.ui.util;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.DistanceFormatter;
import de.dennisguse.opentracks.settings.UnitSystem;
import de.dennisguse.opentracks.util.StringUtils;
/**
@@ -40,101 +44,6 @@ public class ListItemUtils {
private ListItemUtils() {
}
/**
* Sets a list item.
*
* @param context the context
* @param view the list item view
* @param isRecording true if recording
* @param iconId the icon id
* @param iconContentDescriptionId the icon content description id
* @param name the name value
* @param totalTime the total time value
* @param totalDistance the total distance value
* @param markerCount the marker count
* @param offsetDateTime the start time with offset
* @param category the category value
* @param description the description value
* @param hasPhoto true if this list item has photo
*/
public static void setListItem(Context context, View view, boolean isRecording, int iconId, int iconContentDescriptionId, String name, String totalTime, String totalDistance, int markerCount, OffsetDateTime offsetDateTime, String category, String description, boolean hasPhoto) {
// Set icon
if (isRecording) {
iconId = R.drawable.ic_track_recording;
iconContentDescriptionId = R.string.image_record;
}
ImageView iconImageView = view.findViewById(R.id.list_item_icon);
iconImageView.setImageResource(iconId);
iconImageView.setContentDescription(context.getString(iconContentDescriptionId));
// Set name
TextView nameTextView = view.findViewById(R.id.list_item_name);
setTextView(context, nameTextView, name, hasPhoto);
// Set totalTime/totalDistance
TextView timeDistanceTextView = view.findViewById(R.id.list_item_time_distance);
String timeDistanceText;
if (isRecording) {
timeDistanceText = context.getString(R.string.generic_recording);
} else {
// Match list_item_time_distance in list_item.xml
timeDistanceText = getTimeDistance(totalTime, totalDistance);
if (markerCount > 0) {
timeDistanceText += " ‧";
}
}
setTextView(context, timeDistanceTextView, timeDistanceText, hasPhoto);
// Set markerCount
ImageView markerCountIcon = view.findViewById(R.id.list_item_marker_count_icon);
TextView markerCountTextView = view.findViewById(R.id.list_item_marker_count);
boolean hasMarker = markerCount > 0;
markerCountIcon.setVisibility(hasMarker ? View.VISIBLE : View.GONE);
String markerCountValue = hasMarker ? String.valueOf(markerCount) : null;
if (hasMarker) {
// Scale markerCountIcon
int lineHeight = markerCountTextView.getLineHeight();
LayoutParams layoutParams = markerCountIcon.getLayoutParams();
layoutParams.width = lineHeight;
layoutParams.height = lineHeight;
}
setTextView(context, markerCountTextView, markerCountValue, hasPhoto);
// Set date/time
TextView dateTextView = view.findViewById(R.id.list_item_date);
TextView timeTextView = view.findViewById(R.id.list_item_time);
String dateValue = null;
String timeValue = null;
if (!isRecording) {
dateValue = StringUtils.formatDateTodayRelative(context, offsetDateTime);
String pattern = "HH:mm";
if (!offsetDateTime.getOffset().equals(OffsetDateTime.now().getOffset())) {
pattern = "HH:mm x";
}
timeValue = offsetDateTime.format(DateTimeFormatter.ofPattern(pattern));
}
setTextView(context, dateTextView, dateValue, hasPhoto);
setTextView(context, timeTextView, timeValue, hasPhoto);
// Set category and description
TextView categoryDescriptionTextView = view.findViewById(R.id.list_item_category_description);
String categoryDescription = isRecording ? null : StringUtils.getCategoryDescription(category, description);
// Place categoryDescription in either ownerTimeDistanceTextView or categoryDescriptionTextView
if (timeDistanceTextView.getVisibility() == View.GONE && markerCountIcon.getVisibility() == View.GONE) {
setTextView(context, categoryDescriptionTextView, null, hasPhoto);
// Match list_item_category_description in list_item.xml
timeDistanceTextView.setSingleLine(false);
timeDistanceTextView.setMaxLines(2);
setTextView(context, timeDistanceTextView, categoryDescription, hasPhoto);
} else {
// Match list_item_time_distance in list_item.xml
timeDistanceTextView.setSingleLine(true);
setTextView(context, categoryDescriptionTextView, categoryDescription, hasPhoto);
}
}
/**
* Gets a string for share owner, total time, and total distance.
*
@@ -158,6 +67,29 @@ public class ListItemUtils {
return builder.toString();
}
public static String getTimeDistanceText(Context context, UnitSystem unitSystem, boolean isRecording, Duration totalTime, Distance totalDistance, int markerCount) {
String timeDistanceText;
if (isRecording) {
timeDistanceText = context.getString(R.string.generic_recording);
} else {
// Match list_item_time_distance in list_item.xml
String time = StringUtils.formatElapsedTime(totalTime);
// TODO unit system
String distance = DistanceFormatter.Builder()
.setUnit(unitSystem)
.build(context)
.formatDistance(totalDistance);
timeDistanceText = getTimeDistance(time, distance);
if (markerCount > 0) {
timeDistanceText += " ‧";
}
}
return timeDistanceText;
}
/**
* Sets a text view.
*
@@ -180,4 +112,17 @@ public class ListItemUtils {
textView.setShadowLayer(0, 0, 0, 0);
}
}
public static void setDateAndTime(Context context, TextView dateView, TextView timeView, Instant time, ZoneOffset timeZone) {
OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(time, timeZone);
String dateContent = StringUtils.formatDateTodayRelative(context, offsetDateTime);
String pattern = "HH:mm";
if (!offsetDateTime.getOffset().equals(OffsetDateTime.now().getOffset())) {
pattern = "HH:mm x";
}
String timeContent = offsetDateTime.format(DateTimeFormatter.ofPattern(pattern));
dateView.setText(dateContent);
timeView.setText(timeContent);
}
}
@@ -52,4 +52,15 @@ public class ThemeUtils {
typedArray.recycle();
return fontSize;
}
/**
* Gets the photo height.
*/
public static int getPhotoHeight(Context context) {
int[] attrs = new int[]{android.R.attr.listPreferredItemHeight};
TypedArray typeArray = context.obtainStyledAttributes(attrs);
int height = typeArray.getDimensionPixelSize(0, 128);
typeArray.recycle();
return 2 * height;
}
}
@@ -8,7 +8,6 @@ import android.net.Uri;
import android.os.Handler;
import android.provider.DocumentsContract;
import android.util.Log;
import android.widget.Toast;
import androidx.documentfile.provider.DocumentFile;
@@ -19,7 +18,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
@@ -40,17 +38,12 @@ public class ExportUtils {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
if (directory == null || !directory.canWrite()) {
Toast.makeText(context, R.string.export_cannot_write_to_dir, Toast.LENGTH_LONG).show();
return;
}
ExportServiceResultReceiver resultReceiver = new ExportServiceResultReceiver(new Handler(), new ExportServiceResultReceiver.Receiver() {
@Override
public void onExportError(ExportTask unused) {
public void onExportError(ExportTask unused, String errorMessage) {
Intent intent = new Intent(context, SettingsActivity.class);
intent.putExtra(SettingsActivity.EXTRAS_CHECK_EXPORT_DIRECTORY, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(SettingsActivity.EXTRAS_EXPORT_ERROR_MESSAGE, errorMessage);
context.startActivity(intent);
}
});
@@ -59,7 +52,7 @@ public class ExportUtils {
}
}
public static boolean exportTrack(Context context, DocumentFile directory, ExportTask exportTask) {
public static void exportTrack(Context context, DocumentFile directory, ExportTask exportTask) {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
List<Track> tracks = exportTask.getTrackIds().stream().map(contentProviderUtils::getTrack).collect(Collectors.toList());
Uri exportDocumentFileUri;
@@ -71,27 +64,21 @@ public class ExportUtils {
}
if (exportDocumentFileUri == null) {
Log.e(TAG, "Couldn't create document file for export");
return false;
throw new RuntimeException("Couldn't create document file for export");
}
TrackExporter trackExporter = exportTask.getTrackFileFormat().createTrackExporter(context, contentProviderUtils);
try (OutputStream outputStream = context.getContentResolver().openOutputStream(exportDocumentFileUri)) {
if (trackExporter.writeTrack(tracks, outputStream)) {
return true;
} else {
if (!trackExporter.writeTrack(tracks, outputStream)) {
if (!DocumentFile.fromSingleUri(context, exportDocumentFileUri).delete()) {
Log.e(TAG, "Unable to delete exportDocumentFile");
throw new RuntimeException("Unable to delete exportDocumentFile");
}
Log.e(TAG, "Unable to export track");
return false;
throw new RuntimeException("Unable to export track");
}
} catch (FileNotFoundException e) {
Log.e(TAG, "Unable to open exportDocumentFile " + exportDocumentFileUri, e);
return false;
throw new RuntimeException("Unable to open exportDocumentFile " + exportDocumentFileUri, e);
} catch (IOException e) {
Log.e(TAG, "Unable to close exportDocumentFile output stream", e);
return false;
throw new RuntimeException("Unable to close exportDocumentFile output stream", e);
}
}
@@ -59,7 +59,7 @@ public class FileUtils {
}
public static File getPhotoDir(Context context, Track.Id trackId) {
File photoDirectory = new File(getPhotoDir(context), "" + trackId.getId());
File photoDirectory = new File(getPhotoDir(context), "" + trackId.id());
photoDirectory.mkdirs();
return photoDirectory;
}
@@ -60,11 +60,12 @@ public class PermissionRequester {
private static final List<String> GPS_PERMISSION;
static {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
GPS_PERMISSION = List.of(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION);
} else {
//TODO ACCESS_BACKGROUND_LOCATION is required for API, but the permission is not properly granted. See #1653.
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// GPS_PERMISSION = List.of(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION);
// } else {
GPS_PERMISSION = List.of(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION);
}
// }
}
private static final List<String> BLUETOOTH_PERMISSIONS;
@@ -44,7 +44,7 @@ public class TrackNameUtils {
} else if (trackName.equals(context.getString(R.string.settings_recording_track_name_date_iso_8601_value))) {
return startTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mmX"));
} else {
return context.getString(R.string.track_name_format, trackId.getId());
return context.getString(R.string.track_name_format, trackId.id());
}
}
}
-146
View File
@@ -1,146 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@drawable/activated_background"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<!-- Photo -->
<ImageView
android:id="@+id/list_item_photo"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="4dp"
android:contentDescription="@string/image_photo_marker"
android:scaleType="centerCrop" />
<!-- A gradient on top of the text -->
<ImageView
android:id="@+id/list_item_text_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:alpha=".6"
android:visibility="gone"
tools:visibility="visible"
android:contentDescription="@string/image_photo_marker"
android:src="@drawable/text_gradient" />
<!-- Icon -->
<ImageView
android:id="@+id/list_item_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:contentDescription="@string/image_track"
tools:src="@drawable/ic_activity_bike_24dp"/>
<!-- Name -->
<TextView
android:id="@+id/list_item_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="?attr/textAppearanceBody1"
android:singleLine="true"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@+id/list_item_icon"
app:layout_constraintEnd_toStartOf="@id/list_item_date"
tools:text="Main text" />
<!-- Date -->
<TextView
android:id="@+id/list_item_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/textAppearanceBodySmall"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@+id/list_item_name"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="end"
android:layout_marginStart="8dp"
tools:text="2022-02-17" />
<!-- Total time and distance -->
<TextView
android:id="@+id/list_item_time_distance"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="?attr/textAppearanceBodySmall"
app:layout_constraintTop_toBottomOf="@id/list_item_name"
app:layout_constraintStart_toStartOf="@id/list_item_name"
tools:text="50:43 (50.00km)" />
<!-- Marker count icon -->
<ImageView
android:id="@+id/list_item_marker_count_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/list_item_name"
app:layout_constraintStart_toEndOf="@id/list_item_time_distance"
app:layout_constraintBottom_toBottomOf="@id/list_item_time_distance"
android:contentDescription="@string/image_marker_count"
android:scaleType="centerInside"
android:src="@drawable/ic_marker_show_24dp" />
<!-- Marker count text -->
<TextView
android:id="@+id/list_item_marker_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/textAppearanceBodySmall"
app:layout_constraintStart_toEndOf="@id/list_item_marker_count_icon"
app:layout_constraintBottom_toBottomOf="@id/list_item_marker_count_icon"
android:singleLine="true"
tools:text="2" />
<!-- Time -->
<TextView
android:id="@+id/list_item_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="?attr/textAppearanceBodySmall"
app:layout_constraintTop_toBottomOf="@id/list_item_name"
app:layout_constraintStart_toEndOf="@id/list_item_marker_count"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="end"
android:layout_gravity="bottom"
android:layout_marginStart="8dp"
tools:text="10:10:10"/>
<!-- Second row barrier -->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/list_item_second_row_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="list_item_time_distance,list_item_marker_count_icon,list_item_marker_count,list_item_time" />
<!-- Category and description -->
<TextView
android:id="@+id/list_item_category_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="?attr/textAppearanceBodySmall"
app:layout_constraintTop_toBottomOf="@id/list_item_second_row_barrier"
app:layout_constraintStart_toStartOf="@id/list_item_name"
app:layout_constraintEnd_toEndOf="parent"
android:maxLines="2"
android:ellipsize="end"
tools:text="Description" />
</androidx.constraintlayout.widget.ConstraintLayout>
+1 -1
View File
@@ -37,7 +37,7 @@ limitations under the License.
android:paddingBottom="?attr/actionBarSize"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ListView
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/marker_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/activated_background"
android:padding="8dp">
<!-- Photo -->
<ImageView
android:id="@+id/marker_list_item_photo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/image_photo_marker"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@id/marker_list_item_bottom_line"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<!-- A gradient on top of the text -->
<ImageView
android:id="@+id/marker_list_item_text_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha=".6"
android:contentDescription="@string/image_photo_marker"
android:src="@drawable/text_gradient"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/marker_list_item_bottom_line"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- Name -->
<TextView
android:id="@+id/marker_list_item_name"
style="?attr/textAppearanceBody1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:singleLine="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Main text" />
<!-- Date -->
<TextView
android:id="@+id/marker_list_item_date"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="2022-02-17" />
<!-- Total time and distance -->
<TextView
android:id="@+id/marker_list_item_time_distance"
style='?attr/textAppearanceBodySmall'
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/marker_list_item_name"
app:layout_constraintTop_toBottomOf="@+id/marker_list_item_name"
tools:text="50:43 (50.00km)" />
<!-- Time -->
<TextView
android:id="@+id/marker_list_item_time"
style="?attr/textAppearanceBodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/marker_list_item_name"
tools:text="10:10:10" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/marker_list_item_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="marker_list_item_photo,marker_list_item_text_gradient,marker_list_item_name,marker_list_item_date,marker_list_item_time_distance,marker_list_item_time" />
<!-- Bottom Line -->
<View
android:id="@+id/marker_list_item_bottom_line"
style="@style/HorizontalLine"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/marker_list_item_barrier" />
</androidx.constraintlayout.widget.ConstraintLayout>
+43 -11
View File
@@ -584,53 +584,85 @@
app:layout_constraintEnd_toStartOf="@id/guideline3"
app:layout_constraintTop_toBottomOf="@id/stats_cadence_barrier" />
<!-- Sensors data -->
<androidx.constraintlayout.widget.Group
android:id="@+id/stats_power_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="stats_power_horizontal_line,stats_power_label,stats_power_value,stats_power_unit" />
app:constraint_referenced_ids="stats_power_horizontal_line,stats_max_power_label,stats_max_power_value,stats_max_power_unit,stats_avg_power_label,stats_avg_power_value,stats_avg_power_unit" />
<TextView
android:id="@+id/stats_power_label"
android:id="@+id/stats_max_power_label"
style="@style/TextAppearance.OpenTracks.PrimaryHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sensor_state_power_avg"
android:text="@string/sensor_state_power_max"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintEnd_toStartOf="@id/guideline2"
app:layout_constraintTop_toBottomOf="@id/stats_power_horizontal_line" />
<TextView
android:id="@+id/stats_power_value"
android:id="@+id/stats_max_power_value"
style="@style/TextAppearance.OpenTracks.PrimaryValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:value="@string/value_unknown"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintEnd_toStartOf="@id/stats_power_unit"
app:layout_constraintTop_toBottomOf="@id/stats_power_label"
app:layout_constraintEnd_toStartOf="@id/stats_max_power_unit"
app:layout_constraintTop_toBottomOf="@id/stats_max_power_label"
tools:text="100" />
<TextView
android:id="@+id/stats_power_unit"
android:id="@+id/stats_max_power_unit"
style="@style/TextAppearance.OpenTracks.PrimaryUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sensor_state_power_unit"
app:layout_constraintBottom_toBottomOf="@id/stats_power_value"
app:layout_constraintStart_toEndOf="@id/stats_power_value"
app:layout_constraintBottom_toBottomOf="@id/stats_max_power_value"
app:layout_constraintStart_toEndOf="@id/stats_max_power_value"
app:layout_constraintEnd_toStartOf="@id/guideline2" />
<TextView
android:id="@+id/stats_avg_power_label"
style="@style/TextAppearance.OpenTracks.PrimaryHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sensor_state_power_avg"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@id/guideline2"
app:layout_constraintEnd_toEndOf="@id/guideline3"
app:layout_constraintTop_toBottomOf="@id/stats_power_horizontal_line" />
<TextView
android:id="@+id/stats_avg_power_value"
style="@style/TextAppearance.OpenTracks.PrimaryValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:value="@string/value_unknown"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/guideline2"
app:layout_constraintEnd_toStartOf="@id/stats_avg_power_unit"
app:layout_constraintTop_toBottomOf="@id/stats_avg_power_label"
tools:text="100" />
<TextView
android:id="@+id/stats_avg_power_unit"
style="@style/TextAppearance.OpenTracks.PrimaryUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sensor_state_power_unit"
app:layout_constraintBottom_toBottomOf="@id/stats_avg_power_value"
app:layout_constraintStart_toEndOf="@id/stats_avg_power_value"
app:layout_constraintEnd_toStartOf="@id/guideline3" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/stats_power_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="stats_power_value" />
app:constraint_referenced_ids="stats_max_power_value,stats_avg_power_value" />
<!-- Horizontal Line -->
<View
+1 -1
View File
@@ -39,7 +39,7 @@ limitations under the License.
android:paddingBottom="?attr/actionBarSize"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ListView
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/track_list"
android:layout_width="match_parent"
android:layout_height="0dp"
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/activated_background"
android:padding="8dp">
<!-- Icon -->
<ImageView
android:id="@+id/track_list_item_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/image_track"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_activity_bike_24dp" />
<!-- Name -->
<TextView
android:id="@+id/track_list_item_name"
style="?attr/textAppearanceBody1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
app:layout_constraintStart_toEndOf="@id/track_list_item_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Main Text" />
<!-- Date -->
<TextView
android:id="@+id/track_list_item_date"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="2022-02-17" />
<!-- Total time and distance -->
<TextView
android:id="@+id/track_list_item_time_distance"
style="?attr/textAppearanceBodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/track_list_item_name"
app:layout_constraintTop_toBottomOf="@id/track_list_item_name"
tools:text="50:43 (50.00km)" />
<!-- Marker Count Icon -->
<ImageView
android:id="@+id/track_list_item_marker_count_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/image_marker_count"
android:scaleType="centerInside"
android:src="@drawable/ic_marker_show_24dp"
app:layout_constraintBottom_toBottomOf="@id/track_list_item_time_distance"
app:layout_constraintStart_toEndOf="@id/track_list_item_time_distance"
app:layout_constraintTop_toBottomOf="@id/track_list_item_name" />
<!-- Marker count -->
<TextView
android:id="@+id/track_list_item_marker_count"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
app:layout_constraintBottom_toBottomOf="@id/track_list_item_marker_count_icon"
app:layout_constraintStart_toEndOf="@id/track_list_item_marker_count_icon"
tools:text="2" />
<!-- Time -->
<TextView
android:id="@+id/track_list_item_time"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/track_list_item_name"
tools:text="10:10:10" />
<!-- Second row barrier -->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/track_list_item_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="track_list_item_time_distance,track_list_item_marker_count_icon,track_list_item_marker_count,track_list_item_time" />
<!-- Category and description -->
<TextView
android:id="@+id/track_list_item_category_description"
style="?attr/textAppearanceBodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/track_list_item_name"
app:layout_constraintTop_toBottomOf="@id/track_list_item_barrier"
tools:text="Description" />
<!-- Bottom Line -->
<View
android:id="@+id/marker_list_item_bottom_line"
style="@style/HorizontalLine"
android:layout_marginBottom="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/track_list_item_category_description" />
</androidx.constraintlayout.widget.ConstraintLayout>
+1 -2
View File
@@ -634,11 +634,9 @@ limitations under the License.
<string name="introduction_osm_dashboard">OpenTracks en sí no proporciona un mapa. Por favor, instale OSMDashboard para ver sus grabaciones en un mapa.</string>
<string name="share_crash_report">Compartir informe de fallo</string>
<string name="settings_stats_units_imperial_meter">Imperial (millas, metros)</string>
<string name="settings_recording_min_required_accuracy_summary">La ubicación se ignora si la precisión es superior a %1$s</string>
<string name="settings_sensor_heart_rate_max">Zonas: frecuencia cardíaca máxima (lpm)</string>
<string name="settings_api_summary">API de control remoto y estadísticas para otras aplicaciones</string>
<string name="no_compatible_camera_installed">No hay ninguna aplicación de cámara compatible instalada</string>
<string name="no_compatible_gallery_installed">No hay ninguna aplicación de galería de imágenes compatible instalada</string>
<string name="sensor_state_cadence_unit">rpm</string>
@@ -647,4 +645,5 @@ limitations under the License.
<string name="settings_sensor_bluetooth_service_filter_title">Filtrar dispositivos Bluetooth</string>
<string name="settings_sensor_bluetooth_service_filter_off">Mostrar todos los dispositivos Bluetooth detectados</string>
<string name="voice_on_device_speaker_title">Utiliza el altavoz del dispositivo</string>
<string name="menu_export_all_one_file">Exportar todo (un archivo para todas las pistas)</string>
</resources>
+1
View File
@@ -592,4 +592,5 @@ Se o GPS informa sobre datos pouco precisos (ex.: localización, velocidade, ele
<string name="sensor_state_cadence_unit">rpm</string>
<string name="sensor_state_heart_rate_unit">lpm</string>
<string name="sensor_state_power_unit">W</string>
<string name="show_on_dashboard">Taboleiro da API OpenTracks</string>
</resources>
+2 -1
View File
@@ -542,7 +542,7 @@ limitations under the License.
<string name="show_on_map_format_ask">Spør</string>
<string name="show_on_dashboard">OpenTracks-API for oversikten</string>
<string name="always">Alltid</string>
<string name="settings_announcements_average_heart_rate">Gjen. puls</string>
<string name="settings_announcements_average_heart_rate">Gjennomsnittlig puls</string>
<string name="settings_announcements_lap_heart_rate">Runde-puls</string>
<string name="settings_announcements_average_speed_pace">Gjen. hastighet/tempo</string>
<string name="image_discard">Forkast</string>
@@ -580,4 +580,5 @@ limitations under the License.
<string name="voiceDistanceNauticalMilesPlural">{n, plural, =1 {1 nautisk mil} other {{n,number,#.0} nautiske mil} }</string>
<string name="sensor_state_cadence_unit">o/min</string>
<string name="sensor_state_heart_rate_unit">slag/min</string>
<string name="average_heart_rate">Gjennomsnittlig puls</string>
</resources>
+66 -7
View File
@@ -152,7 +152,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>
@@ -352,9 +352,9 @@ limitations under the License.
<string name="activity_type_escooter">електро-байк</string>
<string name="settings_sensor_wheel_circumference">Окружність колеса (мм)</string>
<string name="description_altitude_loss">Зменшення висоти: %1$d м (%2$d фут.)</string>
<string name="export_progress_summary_new_msg">Нові треки експортовано</string>
<string name="export_progress_summary_overwrite_msg">Треки перезаписано</string>
<string name="export_progress_summary_skip_msg">Треки пропущено</string>
<string name="export_progress_summary_new_msg">Нові файли експортовано</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_open_track">Відкрити Трек</string>
<string name="import_file_imported">Файл %1$s імпортовано</string>
@@ -364,9 +364,9 @@ limitations under the License.
<string name="import_progress_summary_errors_msg">Помилки</string>
<string name="export_with_photos">з фото</string>
<string name="export_without_photos">без</string>
<string name="export_track_already_exists_msg">Трек %1$s вже існує в цільовій директорії.</string>
<string name="export_track_already_exists_msg">Файл %1$s вже існує в цільовій директорії.</string>
<string name="export_all_do_it_for_all">робити це для усіх конфліктів</string>
<string name="export_track_errors">Треки не експортовано</string>
<string name="export_track_errors">Файли не експортовано</string>
<string name="generic_overwrite">Перезаписати</string>
<string name="generic_skip">Пропустити</string>
<string name="generic_show_errors">Показати помилки</string>
@@ -482,7 +482,7 @@ limitations under the License.
<string name="settings_import_section">Імпорт</string>
<string name="stats_sensors_power">Сила</string>
<string name="stats_sensors_cadence">Каденція</string>
<string name="stats_sensors_heart_rate">частота серцебиття</string>
<string name="stats_sensors_heart_rate">Частота серцебиття</string>
<string name="settings_defaults_summary">Переважні одиниці виміру, активність за замовчуванням, Ім\'я треку за замовчуванням</string>
<string name="settings_open_tracks_summary">Все про проект OpenTracks</string>
<string name="settings_defaults_title">Одиниці виміру і дії за замовчуванням</string>
@@ -518,4 +518,63 @@ limitations under the License.
<string name="activity_type_workout">тренування</string>
<string name="activity_type_swimming_open">плавання у відкритих водоймах</string>
<string name="activity_type_swimming">плавання</string>
<string name="generic_dismiss">Відхилити</string>
<string name="settings_export_filename_title">Формат імені файлу</string>
<string name="description_pace_nautical">Темп (хв/мм)</string>
<string name="gps_recording_status">%1$s: %2$s</string>
<string name="description_speed_nautical">Швидкість (вузли)</string>
<string name="image_discard">Скасувати</string>
<string name="sensor_state_cadence_unit">об/хв</string>
<string name="sensor_state_heart_rate_unit">уд/хв</string>
<string name="sensor_state_power_unit">Вт</string>
<string name="settings_recording_min_required_accuracy_summary">Місцезнаходження ігнорується, якщо його точність перевищує %1$s</string>
<string name="settings_announcements_average_heart_rate">Середній пульс</string>
<string name="settings_announcements_lap_heart_rate">Пульс за коло</string>
<string name="settings_announcements_average_speed_pace">Середня швидкість/темп</string>
<string name="gps_recording_without_signal">OpenTracks записує без даних GPS.</string>
<string name="settings_announcements_lap_speed_pace">Швидкість/темп за коло</string>
<string name="settings_sensor_bluetooth_service_filter_title">Фільтрувати пристрої Bluetooth</string>
<string name="settings_sensor_bluetooth_service_filter_off">Показати всі видимі пристрої Bluetooth</string>
<string name="menu_export_all_one_file">Експортувати все (один файл для всіх треків)</string>
<string name="settings_stats_units_imperial_meter">Британська (милі, метри)</string>
<string name="settings_api_title">Публічний API</string>
<string name="settings_public_api_dashboard_enabled_summary_off">Записані дані не будуть автоматично передаватись іншим програмам.</string>
<string name="settings_public_api_package_title">Назва пакета для публічного API</string>
<string name="settings_public_api_dashboard_enabled_summary_on">Програма, яка почала запис, також може отримати доступ до записаних даних.</string>
<string name="unit_nautical_mile">Морські милі</string>
<string name="settings_public_api_enabled_summary_on">Інші встановлені програми можуть запускати або зупиняти записи.</string>
<string name="settings_public_api_enabled_summary_off">Тільки OpenTracks може запускати та зупиняти записи.</string>
<string name="track_delete_number_of_tracks">%1$d треків</string>
<string name="settings_public_api_dashboard_enabled_title">Автоматична передача даних</string>
<string name="settings_public_api_enabled_title">Публічний API</string>
<string name="settings_public_api_disabled_toast">Публічний API OpenTracks вимкнено: його можна ввімкнути в налаштуваннях.</string>
<string name="track_discarding">Скасування Доріжки</string>
<string name="unit_knots">вузли</string>
<string name="settings_sensor_heart_rate_max">Зони: максимальна частота серцевих скорочень (уд/хв)</string>
<string name="settings_stats_units_nautical">Морська (морські милі, фут)</string>
<string name="settings_api_summary">API дистанційного керування та статистики для інших програм</string>
<string name="voice_per_kilometer">за кілометр</string>
<string name="pace">Темп</string>
<string name="lap_speed">Швидкість за коло</string>
<string name="speed">Середня швидкість руху</string>
<string name="average_heart_rate">Середній пульс</string>
<string name="voice_per_mile">за милю</string>
<string name="voice_on_device_speaker_title">Використовувати динамік пристрою</string>
<string name="lap_time">Час за коло</string>
<string name="unit_minute_per_nautical_mile">хв/морська миля</string>
<string name="no_compatible_camera_installed">Не встановлено сумісну програму камери</string>
<string name="no_compatible_gallery_installed">Не встановлено сумісну програму галереї зображень</string>
<string name="report_bug">Повідомити про помилку</string>
<string name="select_show_on_map_behavior">Виберіть поведінку \"показати на карті\"</string>
<string name="show_on_map_format_ask">Запитувати</string>
<string name="voice_per_nautical_mile">за морську милю</string>
<string name="always">Завжди</string>
<string name="value_integer_knots">%1$d вузлів</string>
<string name="value_float_knots">%1$.1f вузлів</string>
<string name="total_distance">Загальна відстань</string>
<string name="current_heart_rate">Поточний пульс</string>
<string name="show_on_dashboard">OpenTracks Dashboard API</string>
<string name="introduction_osm_dashboard">Сам OpenTracks не надає карти. Установіть OSMDashboard, щоб переглядати свої записи на карті.</string>
<string name="show_on_dashboard_not_installed">Не встановлено програму, що підтримує OpenTracks Dashboard API.</string>
<string name="value_float_knots_recommended">%1$.1f вузлів (рекомендовано)</string>
</resources>
+1
View File
@@ -623,4 +623,5 @@ limitations under the License.
<string name="sensor_state_heart_rate_unit">bpm</string>
<string name="sensor_state_power_unit">瓦</string>
<string name="settings_sensor_bluetooth_service_filter_off">显示所有可发现的蓝牙设备</string>
<string name="help_data_google_play_services_content">否</string>
</resources>
+1
View File
@@ -336,6 +336,7 @@ limitations under the License.
<string name="sensor_state_heart_rate_value">%1$d bpm</string>
<string name="sensor_state_power">Power</string>
<string name="sensor_state_power_avg">Avg Power</string>
<string name="sensor_state_power_max">Max Power</string>
<string name="sensor_state_power_unit">W</string>
<string name="sensor_could_not_scan">Could not scan for Bluetooth devices (error %1$i).</string>
<string name="bluetooth_disabled">Please enable Bluetooth.</string>