Compare commits

...

22 Commits

Author SHA1 Message Date
Dennis Guse 3af24ea3d0 Release v3.2.3 2019-10-31 22:07:21 +01:00
Dennis Guse 5483212b06 Fixed StringUtils.formatDecimal to work with decimals of 0. 2019-10-30 23:02:02 +01:00
Dennis Guse 419279fe5e Cleanup. 2019-10-30 22:36:22 +01:00
Dennis Guse 501e899b27 StatsFragment: need to reconnect to TrackRecordingService in onResume as it is unbound in onStop.
Fixed #50.
2019-10-30 22:36:16 +01:00
Dennis Guse 3bb9edb009 Connect to new sensor if settings are changed.
Fixed #52.
2019-10-30 07:48:29 +01:00
Dennis Guse e52b1188a3 Cleanup. 2019-10-29 23:05:17 +01:00
Dennis Guse 9e877e4762 Removed stale verify. 2019-10-29 22:39:51 +01:00
Dennis Guse 5711757110 If sharing multiple tracks, share them as separate files. 2019-10-29 17:43:23 +01:00
Dennis Guse e1c33dad43 Move to Android ShareSheet. 2019-10-29 17:34:21 +01:00
Dennis Guse 4b2400774c TravisCI: start emulator, which is required for testing. 2019-10-28 23:25:07 +01:00
Dennis Guse afd4a59622 CI: enabled tests.
Fixed #6.
2019-10-27 16:50:28 +01:00
Dennis Guse bdce437470 Removed AnnoucementPeriodTaskTest as these were broken beyond repair. 2019-10-27 16:46:56 +01:00
Dennis Guse 33479cda11 Fixed TrackRecordingService tests: start service properly if needed. 2019-10-27 15:58:50 +01:00
Dennis Guse 385cee3dc7 Fixed Date parsing test (account for
rounding up microseconds).
2019-10-27 13:45:43 +01:00
Dennis Guse d0bbd7655b Ignore KML waypoints that do not have a type. 2019-10-27 13:40:06 +01:00
Dennis Guse a83b8f62fe Repaired Track importer tests. 2019-10-27 13:24:20 +01:00
Dennis Guse 0cd9176703 Heart rate must be larger than zero to be shown while tracking. 2019-10-26 14:35:03 +02:00
Dennis Guse d8e526923d Removed RemoteSensorManager as abstraction was not used anymore. 2019-10-26 14:26:16 +02:00
Dennis Guse 3a6f417662 Added disconnect toast message. 2019-10-26 14:23:38 +02:00
Dennis Guse 7d807a7f11 Refactor Bluetooth LE stack to use autoReconnect.
Removed additional timerTask to reconnect (was not working anymore).

Fixed #41.
2019-10-26 14:11:55 +02:00
Dennis Guse e9d5718495 Cleanup. 2019-10-23 18:47:32 +02:00
Dennis Guse 0dd7f74a11 Removed temporary sensor manager (was not used anymore). 2019-10-22 21:15:11 +02:00
35 changed files with 473 additions and 1042 deletions
+38 -5
View File
@@ -1,12 +1,45 @@
#See https://docs.travis-ci.com/user/languages/android/
#See https://github.com/ankidroid/Anki-Android/blob/master/.travis.yml
language: android
dist: trusty
env:
global:
- ADB_INSTALL_TIMEOUT=8
- ABI=armeabi-v7a
- EMU_FLAVOR=default
# PATH order is incredibly important. e.g. the 'emulator' script exists in more than one place!
- ANDROID_HOME=/usr/local/android-sdk
- TOOLS=${ANDROID_HOME}/tools
- PATH=${ANDROID_HOME}:${ANDROID_HOME}/emulator:${TOOLS}:${TOOLS}/bin:${ANDROID_HOME}/platform-tools:${PATH}
matrix:
- API=27 ABI=x86_64
android:
components:
# installing tools to start, then use `sdkmanager` below to get the rest
- tools
- build-tools-28.0.3
- android-29
- android-28
# Emulator Management: Create, Start and Wait
install:
- echo y | sdkmanager "platform-tools"
- echo y | sdkmanager --channel=4 "emulator"
- echo y | sdkmanager "system-images;android-$API;$EMU_FLAVOR;$ABI"
- echo no | avdmanager create avd --force -n test -k "system-images;android-$API;$EMU_FLAVOR;$ABI" -c 10M
- emulator -verbose -avd test -no-accel -no-snapshot -no-window -camera-back none -camera-front none -selinux permissive -qemu -m 2048 &
- android-wait-for-emulator
- adb shell input keyevent 82 &
script:
- ./gradlew build
#TODO: When all tests are passing, CI should also execute them.
#- ./gradlew connectedCheck
- ./gradlew clean build
- ./gradlew connectedCheck
sbefore_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
- $HOME/.android/build-cache
@@ -0,0 +1,14 @@
v3.2.3: OpenTracks
Changes:
- Sharing: use Android Sharesheet
- Sharing: share multiple tracks as individual files
- Use Android ShareSheet
- Heart rate sensor (Bluetooth LE): automatic reconnect
Bugfix:
- Heart rate sensor (Bluetooth LE): disconnect properly
- Heart rate wasn't always shown while recording
Developer:
- Enabled TravisCI instrumentation tests
@@ -40,7 +40,6 @@ import de.dennisguse.opentracks.content.ContentProviderUtils.LocationIterator;
import de.dennisguse.opentracks.content.Waypoint.WaypointType;
import de.dennisguse.opentracks.stats.TripStatistics;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -214,7 +213,6 @@ public class CustomContentProviderUtilsImplTest {
Track track = providerUtils.createTrack(cursorMock);
Assert.assertEquals(trackId, track.getId());
Assert.assertEquals(name, track.getName());
verify(cursorMock);
}
/**
@@ -414,7 +412,6 @@ public class CustomContentProviderUtilsImplTest {
Assert.assertEquals(id, waypoint.getId());
Assert.assertEquals(name, waypoint.getName());
Assert.assertEquals(trackId, waypoint.getTrackId());
verify(cursorMock);
}
/**
@@ -709,7 +706,6 @@ public class CustomContentProviderUtilsImplTest {
Assert.assertEquals((double) latitude, location.getLatitude(), 0.01);
Assert.assertEquals(time, location.getTime(), 0.01);
Assert.assertEquals(speed, location.getSpeed(), 0.01);
verify(cursorMock);
}
/**
@@ -112,7 +112,7 @@ public abstract class AbstractTestFileTrackImporter {
* @param lastTrack true if it is the last track in the gpx
* @param trackId the track id
*/
protected void expectUpdateTrack(ArgumentCaptor<Track> trackCaptor, boolean lastTrack, long trackId) {
protected void expectTrackUpdate(ArgumentCaptor<Track> trackCaptor, boolean lastTrack, long trackId) {
contentProviderUtils.updateTrack(trackCaptor.capture());
when(contentProviderUtils.insertWaypoint((Waypoint) any())).thenReturn(WAYPOINT_ID_O_URI);
@@ -34,6 +34,7 @@ import de.dennisguse.opentracks.util.PreferencesUtils;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.anyLong;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -84,8 +85,7 @@ public class GpxFileTrackImporterTest extends AbstractTestFileTrackImporter {
@Test
public void testOneTrackOneSegment() throws Exception {
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
// given
Location location0 = createLocation(0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
Location location1 = createLocation(1, DATE_FORMAT_1.parse(TRACK_TIME_1).getTime());
@@ -96,24 +96,27 @@ public class GpxFileTrackImporterTest extends AbstractTestFileTrackImporter {
when(contentProviderUtils.bulkInsertTrackPoint((Location[]) any(), eq(1), eq(TRACK_ID_0))).thenReturn(1);
when(contentProviderUtils.getLastTrackPointId(TRACK_ID_0)).thenReturn(TRACK_POINT_ID_1);
when(contentProviderUtils.getTrack(PreferencesUtils.getLong(context, R.string.recording_track_id_key))).thenReturn(null);
expectUpdateTrack(trackCaptor, true, TRACK_ID_0);
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
expectTrackUpdate(trackCaptor, true, TRACK_ID_0);
// when
InputStream inputStream = new ByteArrayInputStream(VALID_ONE_TRACK_ONE_SEGMENT_GPX.getBytes());
GpxFileTrackImporter gpxFileTrackImporter = new GpxFileTrackImporter(context, contentProviderUtils);
long trackId = gpxFileTrackImporter.importFile(inputStream);
// then
Assert.assertEquals(TRACK_ID_0, trackId);
verify(contentProviderUtils, atLeastOnce()).updateTrack(trackCaptor.capture());
long time0 = DATE_FORMAT_0.parse(TRACK_TIME_0).getTime();
long time1 = DATE_FORMAT_1.parse(TRACK_TIME_1).getTime();
Assert.assertEquals(time1 - time0, trackCaptor.getValue().getTripStatistics().getTotalTime());
verify(contentProviderUtils);
verifyTrack(trackCaptor.getValue(), TRACK_NAME_0, TRACK_DESCRIPTION_0, time0);
}
@Test
public void testOneTrackTwoSegments() throws Exception {
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
// given
Location location0 = createLocation(0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
when(contentProviderUtils.insertTrack((Track) any())).thenReturn(TRACK_ID_0_URI);
@@ -122,11 +125,17 @@ public class GpxFileTrackImporterTest extends AbstractTestFileTrackImporter {
when(contentProviderUtils.bulkInsertTrackPoint((Location[]) any(), eq(5), eq(TRACK_ID_0))).thenReturn(5);
when(contentProviderUtils.getLastTrackPointId(TRACK_ID_0)).thenReturn(TRACK_POINT_ID_3);
when(contentProviderUtils.getTrack(PreferencesUtils.getLong(context, R.string.recording_track_id_key))).thenReturn(null);
expectUpdateTrack(trackCaptor, true, TRACK_ID_0);
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
expectTrackUpdate(trackCaptor, true, TRACK_ID_0);
// when
InputStream inputStream = new ByteArrayInputStream(VALID_ONE_TRACK_TWO_SEGMENTS_GPX.getBytes());
GpxFileTrackImporter gpxFileTrackImporter = new GpxFileTrackImporter(context, contentProviderUtils);
long trackId = gpxFileTrackImporter.importFile(inputStream);
// then
verify(contentProviderUtils, atLeastOnce()).updateTrack(trackCaptor.capture());
Assert.assertEquals(TRACK_ID_0, trackId);
long time0 = DATE_FORMAT_0.parse(TRACK_TIME_0).getTime();
@@ -135,14 +144,12 @@ public class GpxFileTrackImporterTest extends AbstractTestFileTrackImporter {
long time3 = DATE_FORMAT_1.parse(TRACK_TIME_3).getTime();
Assert.assertEquals(time1 - time0 + time3 - time2, trackCaptor.getValue().getTripStatistics().getTotalTime());
verify(contentProviderUtils);
verifyTrack(trackCaptor.getValue(), TRACK_NAME_0, TRACK_DESCRIPTION_0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
}
@Test
public void testOneTrackTwoSegmentsNoTime() {
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
// given
when(contentProviderUtils.insertTrack((Track) any())).thenReturn(TRACK_ID_0_URI);
expectFirstTrackPoint(null, TRACK_ID_0, TRACK_POINT_ID_0);
@@ -150,15 +157,20 @@ public class GpxFileTrackImporterTest extends AbstractTestFileTrackImporter {
when(contentProviderUtils.bulkInsertTrackPoint((Location[]) any(), eq(5), eq(TRACK_ID_0))).thenReturn(5);
when(contentProviderUtils.getLastTrackPointId(TRACK_ID_0)).thenReturn(TRACK_POINT_ID_3);
when(contentProviderUtils.getTrack(PreferencesUtils.getLong(context, R.string.recording_track_id_key))).thenReturn(null);
expectUpdateTrack(trackCaptor, true, TRACK_ID_0);
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
expectTrackUpdate(trackCaptor, true, TRACK_ID_0);
// when
InputStream inputStream = new ByteArrayInputStream(VALID_ONE_TRACK_TWO_SEGMENTS_NO_TIME_GPX.getBytes());
GpxFileTrackImporter gpxFileTrackImporter = new GpxFileTrackImporter(context, contentProviderUtils);
long trackId = gpxFileTrackImporter.importFile(inputStream);
Assert.assertEquals(TRACK_ID_0, trackId);
Assert.assertEquals(0, trackCaptor.getValue().getTripStatistics().getTotalTime());
verify(contentProviderUtils);
// then
verify(contentProviderUtils, atLeastOnce()).updateTrack(trackCaptor.capture());
Assert.assertEquals(TRACK_ID_0, trackId);
Assert.assertEquals(0, trackCaptor.getValue().getTripStatistics().getTotalTime());
verifyTrack(trackCaptor.getValue(), TRACK_NAME_0, TRACK_DESCRIPTION_0, -1L);
}
@@ -32,6 +32,7 @@ import de.dennisguse.opentracks.content.Track;
import de.dennisguse.opentracks.util.PreferencesUtils;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -68,8 +69,7 @@ public class KmlFileTrackImporterTest extends AbstractTestFileTrackImporter {
@Test
public void testOneTrackOneSegment() throws Exception {
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
// given
Location location0 = createLocation(0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
Location location1 = createLocation(1, DATE_FORMAT_1.parse(TRACK_TIME_1).getTime());
@@ -77,27 +77,31 @@ public class KmlFileTrackImporterTest extends AbstractTestFileTrackImporter {
expectFirstTrackPoint(location0, TRACK_ID_0, TRACK_POINT_ID_0);
// A flush happens at the end
when(contentProviderUtils.bulkInsertTrackPoint(new Location[]{location1}, eq(1), eq(TRACK_ID_0))).thenReturn(1);
when(contentProviderUtils.bulkInsertTrackPoint((Location[]) any(), eq(1), eq(TRACK_ID_0))).thenReturn(1);
when(contentProviderUtils.getLastTrackPointId(TRACK_ID_0)).thenReturn(TRACK_POINT_ID_1);
when(contentProviderUtils.getTrack(PreferencesUtils.getLong(context, R.string.recording_track_id_key))).thenReturn(null);
expectUpdateTrack(trackCaptor, true, TRACK_ID_0);
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
expectTrackUpdate(trackCaptor, true, TRACK_ID_0);
// when
InputStream inputStream = new ByteArrayInputStream(VALID_ONE_TRACK_ONE_SEGMENT_GPX.getBytes());
KmlFileTrackImporter kmlFileTrackImporter = new KmlFileTrackImporter(context, TRACK_ID_0, contentProviderUtils);
long trackId = kmlFileTrackImporter.importFile(inputStream);
// then
verify(contentProviderUtils, atLeastOnce()).updateTrack(trackCaptor.capture());
Assert.assertEquals(TRACK_ID_0, trackId);
long time0 = DATE_FORMAT_0.parse(TRACK_TIME_0).getTime();
long time1 = DATE_FORMAT_1.parse(TRACK_TIME_1).getTime();
Assert.assertEquals(time1 - time0, trackCaptor.getValue().getTripStatistics().getTotalTime());
verify(contentProviderUtils);
verifyTrack(trackCaptor.getValue(), TRACK_NAME_0, TRACK_DESCRIPTION_0, time0);
}
@Test
public void testOneTrackTwoSegments() throws Exception {
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
// given
Location location0 = createLocation(0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
contentProviderUtils.clearTrack(context, TRACK_ID_0);
@@ -107,11 +111,17 @@ public class KmlFileTrackImporterTest extends AbstractTestFileTrackImporter {
when(contentProviderUtils.bulkInsertTrackPoint((Location[]) any(), eq(5), eq(TRACK_ID_0))).thenReturn(5);
when(contentProviderUtils.getLastTrackPointId(TRACK_ID_0)).thenReturn(TRACK_POINT_ID_3);
when(contentProviderUtils.getTrack(PreferencesUtils.getLong(context, R.string.recording_track_id_key))).thenReturn(null);
expectUpdateTrack(trackCaptor, true, TRACK_ID_0);
ArgumentCaptor<Track> trackCaptor = ArgumentCaptor.forClass(Track.class);
expectTrackUpdate(trackCaptor, true, TRACK_ID_0);
// when
InputStream inputStream = new ByteArrayInputStream(VALID_ONE_TRACK_TWO_SEGMENTS_GPX.getBytes());
KmlFileTrackImporter kmlFileTrackImporter = new KmlFileTrackImporter(context, TRACK_ID_0, contentProviderUtils);
long trackId = kmlFileTrackImporter.importFile(inputStream);
// then
verify(contentProviderUtils, atLeastOnce()).updateTrack(trackCaptor.capture());
Assert.assertEquals(TRACK_ID_0, trackId);
long time0 = DATE_FORMAT_0.parse(TRACK_TIME_0).getTime();
@@ -120,7 +130,6 @@ public class KmlFileTrackImporterTest extends AbstractTestFileTrackImporter {
long time3 = DATE_FORMAT_1.parse(TRACK_TIME_3).getTime();
Assert.assertEquals(time1 - time0 + time3 - time2, trackCaptor.getValue().getTripStatistics().getTotalTime());
verify(contentProviderUtils);
verifyTrack(trackCaptor.getValue(), TRACK_NAME_0, TRACK_DESCRIPTION_0, DATE_FORMAT_0.parse(TRACK_TIME_0).getTime());
}
}
@@ -15,14 +15,12 @@
*/
package de.dennisguse.opentracks.services;
import android.content.BroadcastReceiver;
import android.content.ContentProvider;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.location.Location;
import android.os.IBinder;
import android.util.Log;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -38,7 +36,6 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@@ -77,9 +74,7 @@ public class TrackRecordingServiceTest {
private long trackId = Math.abs(new Random().nextLong());
static Intent createStartIntent(Context context) {
Intent startIntent = new Intent();
startIntent.setClass(context, TrackRecordingService.class);
return startIntent;
return new Intent(context, TrackRecordingService.class);
}
static void updateAutoResumePrefs(Context context, int attempts, int timeoutMins) {
@@ -145,7 +140,10 @@ public class TrackRecordingServiceTest {
// Start the service in "resume" mode (simulates the on-reboot action).
Intent startIntent = createStartIntent(context);
startIntent.putExtra(TrackRecordingService.RESUME_TRACK_EXTRA_NAME, true);
mServiceRule.startService(startIntent);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
// then
Assert.assertNotNull(service);
// We expect to resume the previous track.
@@ -168,12 +166,14 @@ public class TrackRecordingServiceTest {
Assert.assertEquals(id, PreferencesUtils.getLong(context, R.string.recording_track_id_key));
// Start the service in "resume" mode (simulates the on-reboot action).
Intent startIntent = createStartIntent(context);
startIntent.putExtra(TrackRecordingService.RESUME_TRACK_EXTRA_NAME, true);
mServiceRule.startService(startIntent);
service = ((ITrackRecordingService) mServiceRule.bindService(createStartIntent(context)));
Assert.assertNotNull(service);
// then
Assert.assertNotNull(service);
Assert.assertTrue(service.isRecording());
}
@@ -189,7 +189,10 @@ public class TrackRecordingServiceTest {
// Start the service in "resume" mode (simulates the on-reboot action).
Intent startIntent = createStartIntent(context);
startIntent.putExtra(TrackRecordingService.RESUME_TRACK_EXTRA_NAME, true);
mServiceRule.startService(startIntent);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
// then
Assert.assertNotNull(service);
// We don't expect to resume the previous track, because it was stopped.
@@ -201,7 +204,7 @@ public class TrackRecordingServiceTest {
@Test
public void testResumeAfterReboot_expiredTrack() throws Exception {
// Insert a dummy track last updated 20 min ago.
createDummyTrack(trackId, System.currentTimeMillis() - 20 * 60 * 1000, true);
createDummyTrack(trackId, System.currentTimeMillis() - 1500 * 60 * 1000, true);
// Clear the number of attempts and set the timeout to 10 min.
updateAutoResumePrefs(context, PreferencesUtils.AUTO_RESUME_TRACK_CURRENT_RETRY_DEFAULT, PreferencesUtils.AUTO_RESUME_TRACK_TIMEOUT_DEFAULT);
@@ -209,7 +212,10 @@ public class TrackRecordingServiceTest {
// Start the service in "resume" mode (simulates the on-reboot action).
Intent startIntent = createStartIntent(context);
startIntent.putExtra(TrackRecordingService.RESUME_TRACK_EXTRA_NAME, true);
mServiceRule.startService(startIntent);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
// then
Assert.assertNotNull(service);
// We don't expect to resume the previous track, because it has expired.
@@ -229,11 +235,10 @@ public class TrackRecordingServiceTest {
// Start the service in "resume" mode (simulates the on-reboot action).
Intent startIntent = createStartIntent(context);
startIntent.putExtra(TrackRecordingService.RESUME_TRACK_EXTRA_NAME, true);
//Explicit start service, so `startCommand()` is executed - which would be called by BootReceiver.
context.startService(startIntent);
mServiceRule.startService(startIntent);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
//then
Assert.assertNotNull(service);
// We don't expect to resume the previous track, because there were already too many attempts.
@@ -247,7 +252,10 @@ public class TrackRecordingServiceTest {
List<Track> tracks = providerUtils.getAllTracks();
Assert.assertTrue(tracks.isEmpty());
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(createStartIntent(context)));
Intent startIntent = createStartIntent(context);
mServiceRule.startService(startIntent);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
// Test if we start in no-recording mode by default.
Assert.assertFalse(service.isRecording());
Assert.assertEquals(-1L, service.getRecordingTrackId());
@@ -266,11 +274,14 @@ public class TrackRecordingServiceTest {
@MediumTest
@Test
public void testRecording_orphanedRecordingTrack() throws Exception {
Intent startIntent = createStartIntent(context);
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(startIntent));
// Just set recording track to a bogus value.
// Make sure that the service will not start recording and will clear the bogus track.
PreferencesUtils.setLong(context, R.string.recording_track_id_key, 123L);
// Make sure that the service will not start recording and will clear the bogus track.
ITrackRecordingService service = ((ITrackRecordingService) mServiceRule.bindService(createStartIntent(context)));
// then
Assert.assertFalse(service.isRecording());
Assert.assertEquals(PreferencesUtils.RECORDING_TRACK_ID_DEFAULT, service.getRecordingTrackId());
}
@@ -405,45 +416,4 @@ public class TrackRecordingServiceTest {
Assert.assertNotNull(wpt.getLocation());
Assert.assertNull(wpt.getTripStatistics());
}
/**
* Synchronous/waitable broadcast receiver to be used in testing.
*/
private class BlockingBroadcastReceiver extends BroadcastReceiver {
private static final long MAX_WAIT_TIME_MS = 3000;
private final List<Intent> receivedIntents = new ArrayList<>();
public List<Intent> getReceivedIntents() {
return receivedIntents;
}
@Override
public void onReceive(Context ctx, Intent intent) {
Log.d("Test", "Got broadcast: " + intent);
synchronized (receivedIntents) {
receivedIntents.add(intent);
receivedIntents.notifyAll();
}
}
public boolean waitUntilReceived(int receiveCount) {
long deadline = System.currentTimeMillis() + MAX_WAIT_TIME_MS;
synchronized (receivedIntents) {
while (receivedIntents.size() < receiveCount) {
try {
// Wait releases synchronized lock until it returns
receivedIntents.wait(500);
} catch (InterruptedException e) {
// Do nothing
}
if (System.currentTimeMillis() > deadline) {
return false;
}
}
}
return true;
}
}
}
@@ -1,557 +0,0 @@
/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package de.dennisguse.opentracks.services.tasks;
import android.content.Context;
import android.os.Looper;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.OnInitListener;
import android.speech.tts.TextToSpeech.OnUtteranceCompletedListener;
import android.telephony.PhoneStateListener;
import android.telephony.TelephonyManager;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.HashMap;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
import de.dennisguse.opentracks.stats.TripStatistics;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.same;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests for {@link AnnouncementPeriodicTask}.
*
* WARNING: I'm not responsible if your eyes start bleeding while reading this code.
* You have been warned.
* It's still better than no test, though.
*
* @author Rodrigo Damazio
*/
@RunWith(MockitoJUnitRunner.class)
public class AnnouncementPeriodicTaskTest {
// Use something other than our hardcoded value
private static final Locale DEFAULT_LOCALE = Locale.KOREAN;
private static final String ANNOUNCEMENT = "I can haz cheeseburger?";
private Locale oldDefaultLocale;
private Context context = ApplicationProvider.getApplicationContext();
private AnnouncementPeriodicTask task;
@Mock
private AnnouncementPeriodicTask mockTask;
private ArgumentCaptor<OnInitListener> initListenerCapture = ArgumentCaptor.forClass(OnInitListener.class);
private ArgumentCaptor<PhoneStateListener> phoneListenerCapture = ArgumentCaptor.forClass(PhoneStateListener.class);
private TextToSpeechDelegate ttsDelegate;
@Mock
private TextToSpeechInterface tts;
@BeforeClass
public static void preSetUp() {
// Prepare looper for Android's message queue
if (Looper.myLooper() == null) Looper.prepare();
}
@AfterClass
public static void finalTearDown() {
if (Looper.myLooper() != null) Looper.myLooper().quit();
}
@Before
public void setUp() throws Exception {
oldDefaultLocale = Locale.getDefault();
Locale.setDefault(DEFAULT_LOCALE);
// Eww, the effort required just to mock TextToSpeech is insane
final AtomicBoolean listenerCalled = new AtomicBoolean();
OnInitListener blockingListener = new OnInitListener() {
@Override
public void onInit(int status) {
synchronized (this) {
listenerCalled.set(true);
notify();
}
}
};
ttsDelegate = new TextToSpeechDelegate(context, blockingListener);
// Wait for all async operations done in the constructor to finish.
synchronized (blockingListener) {
while (!listenerCalled.get()) {
// Releases the synchronized lock until we're woken up.
blockingListener.wait();
}
}
// Create a partial forwarding mock
task = new AnnouncementPeriodicTask(context) {
@Override
protected TextToSpeech newTextToSpeech(Context ctx, OnInitListener onInitListener) {
return mockTask.newTextToSpeech(ctx, onInitListener);
}
@Override
protected String getAnnouncement(TripStatistics stats) {
return mockTask.getAnnouncement(stats);
}
@Override
protected void listenToPhoneState(PhoneStateListener listener, int events) {
mockTask.listenToPhoneState(listener, events);
}
};
}
@After
public void tearDown() {
Locale.setDefault(oldDefaultLocale);
}
@Test
public void testStart() {
doStart();
OnInitListener ttsInitListener = initListenerCapture.getValue();
Assert.assertNotNull(ttsInitListener);
ttsInitListener.onInit(TextToSpeech.SUCCESS);
//TODO
//verify(mockTask, tts);
}
@Test
public void testStart_notReady() {
doStart();
OnInitListener ttsInitListener = initListenerCapture.getValue();
Assert.assertNotNull(ttsInitListener);
ttsInitListener.onInit(TextToSpeech.ERROR);
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testShutdown() {
// First, start
doStart();
verify(mockTask);
reset(mockTask);
// Then, shut down
PhoneStateListener phoneListener = phoneListenerCapture.getValue();
mockTask.listenToPhoneState(
same(phoneListener), eq(PhoneStateListener.LISTEN_NONE));
tts.shutdown();
task.shutdown();
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testRun() {
// when service data calls
TripStatistics stats = new TripStatistics();
// when announcement building call
when(mockTask.getAnnouncement(same(stats))).thenReturn(ANNOUNCEMENT);
// Put task in "ready" state
startTask(TextToSpeech.SUCCESS);
when(tts.isLanguageAvailable(DEFAULT_LOCALE)).thenReturn(TextToSpeech.LANG_AVAILABLE);
when(tts.setLanguage(DEFAULT_LOCALE)).thenReturn(TextToSpeech.LANG_AVAILABLE);
when(tts.setSpeechRate(AnnouncementPeriodicTask.TTS_SPEECH_RATE)).thenReturn(TextToSpeech.SUCCESS);
when(tts.setOnUtteranceCompletedListener((OnUtteranceCompletedListener) any())).thenReturn(0);
// Run the announcement
task.announce(stats);
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testRun_notReady() {
// Put task in "not ready" state
startTask(TextToSpeech.ERROR);
// Run the announcement
task.run(null);
//TODO
//verify(mockTask, tts);
}
@Test
public void testRun_duringCall() {
startTask(TextToSpeech.SUCCESS);
when(tts.isSpeaking()).thenReturn(false);
// Run the announcement
PhoneStateListener phoneListener = phoneListenerCapture.getValue();
phoneListener.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK, null);
task.run(null);
//TODO
//verify(mockTask, tts);
}
@Test
public void testRun_ringWhileSpeaking() {
startTask(TextToSpeech.SUCCESS);
when(tts.isSpeaking()).thenReturn(true);
when(tts.stop()).thenReturn(TextToSpeech.SUCCESS);
// Update the state to ringing - this should stop the current announcement.
PhoneStateListener phoneListener = phoneListenerCapture.getValue();
phoneListener.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null);
// Run the announcement - this should do nothing.
task.run(null);
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testRun_whileRinging() {
startTask(TextToSpeech.SUCCESS);
when(tts.isSpeaking()).thenReturn(false);
// Run the announcement
PhoneStateListener phoneListener = phoneListenerCapture.getValue();
phoneListener.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null);
task.run(null);
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testRun_noService() {
startTask(TextToSpeech.SUCCESS);
// Run the announcement
task.run(null);
//TODO
//AndroidMock.verify(mockTask, tts);
}
@Test
public void testRun_noStats() {
// when service data calls
startTask(TextToSpeech.SUCCESS);
// Run the announcement
task.run(null);
//TODO
//AndroidMock.verify(mockTask, tts);
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with time zero.
*/
@Test
public void testGetAnnounceTime_time_zero() {
long time = 0; // 0 seconds
Assert.assertEquals("0 minutes 0 seconds", task.getAnnounceTime(time));
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with time one.
*/
@Test
public void testGetAnnounceTime_time_one() {
long time = 1000; // 1 second
Assert.assertEquals("0 minutes 1 second", task.getAnnounceTime(time));
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with singular
* numbers with the hour unit.
*/
@Test
public void testGetAnnounceTime_singular_has_hour() {
long time = (60 * 60 * 1000) + (60 * 1000) + (1000); // 1 hour 1 minute 1 second
Assert.assertEquals("1 hour 1 minute 1 second", task.getAnnounceTime(time));
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with plural numbers
* with the hour unit.
*/
@Test
public void testGetAnnounceTime_plural_has_hour() {
long time = (2 * 60 * 60 * 1000) + (2 * 60 * 1000) + (2 * 1000); // 2 hours 2 minutes 2 seconds
Assert.assertEquals("2 hours 2 minutes 2 seconds", task.getAnnounceTime(time));
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with singular
* numbers without the hour unit.
*/
@Test
public void testGetAnnounceTime_singular_no_hour() {
long time = (1 * 60 * 1000) + (1 * 1000); // 1 minute 1 second
Assert.assertEquals("1 minute 1 second", task.getAnnounceTime(time));
}
/**
* Tests {@link AnnouncementPeriodicTask#getAnnounceTime(long)} with plural numbers
* without the hour unit.
*/
@Test
public void testGetAnnounceTime_plural_no_hour() {
long time = (2 * 60 * 1000) + (2 * 1000); // 2 minutes 2 seconds
Assert.assertEquals("2 minutes 2 seconds", task.getAnnounceTime(time));
}
private void startTask(int state) {
reset(tts);
doStart();
OnInitListener ttsInitListener = initListenerCapture.getValue();
ttsInitListener.onInit(state);
//TODO
reset(tts);
//AndroidMock.resetToDefault(tts);
}
private void doStart() {
mockTask.listenToPhoneState(phoneListenerCapture.capture(), eq(PhoneStateListener.LISTEN_CALL_STATE));
when(mockTask.newTextToSpeech(same(context), initListenerCapture.capture())).thenReturn(ttsDelegate);
task.start();
}
/**
* Mockable interface that we delegate TTS calls to.
*/
interface TextToSpeechInterface {
int addEarcon(String earcon, String packagename, int resourceId);
int addEarcon(String earcon, String filename);
int addSpeech(String text, String packagename, int resourceId);
int addSpeech(String text, String filename);
boolean areDefaultsEnforced();
String getDefaultEngine();
Locale getLanguage();
int isLanguageAvailable(Locale loc);
boolean isSpeaking();
int playEarcon(String earcon, int queueMode,
HashMap<String, String> params);
int playSilence(long durationInMs, int queueMode, HashMap<String, String> params);
int setLanguage(Locale loc);
int setOnUtteranceCompletedListener(OnUtteranceCompletedListener listener);
int setPitch(float pitch);
int setSpeechRate(float speechRate);
void shutdown();
int speak(String text, int queueMode, HashMap<String, String> params);
int stop();
int synthesizeToFile(String text, HashMap<String, String> params, String filename);
}
/**
* Subclass of {@link TextToSpeech} which delegates calls to the interface
* above.
* The logic here is stupid and the author is ashamed of having to write it
* like this, but basically the issue is that TextToSpeech cannot be mocked
* without running its constructor, its constructor runs async operations
* which call other methods (and then if the methods are part of a mock we'd
* have to set a behavior, but we can't 'cause the object hasn't been fully
* built yet).
* The logic is that calls made during the constructor (when tts is not yet
* set) will go up to the original class, but after tts is set we'll forward
* them all to the mock.
*/
private class TextToSpeechDelegate extends TextToSpeech implements TextToSpeechInterface {
public TextToSpeechDelegate(Context context, OnInitListener listener) {
super(context, listener);
}
@Override
public int addEarcon(String earcon, String packagename, int resourceId) {
if (tts == null) {
return super.addEarcon(earcon, packagename, resourceId);
}
return tts.addEarcon(earcon, packagename, resourceId);
}
@Override
public int addEarcon(String earcon, String filename) {
if (tts == null) {
return super.addEarcon(earcon, filename);
}
return tts.addEarcon(earcon, filename);
}
@Override
public int addSpeech(String text, String packagename, int resourceId) {
if (tts == null) {
return super.addSpeech(text, packagename, resourceId);
}
return tts.addSpeech(text, packagename, resourceId);
}
@Override
public int addSpeech(String text, String filename) {
if (tts == null) {
return super.addSpeech(text, filename);
}
return tts.addSpeech(text, filename);
}
@Override
public Locale getLanguage() {
if (tts == null) {
return super.getLanguage();
}
return tts.getLanguage();
}
@Override
public int isLanguageAvailable(Locale loc) {
if (tts == null) {
return super.isLanguageAvailable(loc);
}
return tts.isLanguageAvailable(loc);
}
@Override
public boolean isSpeaking() {
if (tts == null) {
return super.isSpeaking();
}
return tts.isSpeaking();
}
@Override
public int playEarcon(String earcon, int queueMode, HashMap<String, String> params) {
if (tts == null) {
return super.playEarcon(earcon, queueMode, params);
}
return tts.playEarcon(earcon, queueMode, params);
}
@Override
public int playSilence(long durationInMs, int queueMode, HashMap<String, String> params) {
if (tts == null) {
return super.playSilence(durationInMs, queueMode, params);
}
return tts.playSilence(durationInMs, queueMode, params);
}
@Override
public int setLanguage(Locale loc) {
if (tts == null) {
return super.setLanguage(loc);
}
return tts.setLanguage(loc);
}
@Override
public int setOnUtteranceCompletedListener(OnUtteranceCompletedListener listener) {
if (tts == null) {
return super.setOnUtteranceCompletedListener(listener);
}
return tts.setOnUtteranceCompletedListener(listener);
}
@Override
public int setPitch(float pitch) {
if (tts == null) {
return super.setPitch(pitch);
}
return tts.setPitch(pitch);
}
@Override
public int setSpeechRate(float speechRate) {
if (tts == null) {
return super.setSpeechRate(speechRate);
}
return tts.setSpeechRate(speechRate);
}
@Override
public void shutdown() {
if (tts == null) {
super.shutdown();
return;
}
tts.shutdown();
}
@Override
public int speak(
String text, int queueMode, HashMap<String, String> params) {
if (tts == null) {
return super.speak(text, queueMode, params);
}
return tts.speak(text, queueMode, params);
}
@Override
public int stop() {
if (tts == null) {
return super.stop();
}
return tts.stop();
}
@Override
public int synthesizeToFile(String text, HashMap<String, String> params, String filename) {
if (tts == null) {
return super.synthesizeToFile(text, params, filename);
}
return tts.synthesizeToFile(text, params, filename);
}
}
}
@@ -131,7 +131,7 @@ public class StringUtilsTest {
assertGetTime("2010-05-04T03:02:01.3", 2010, 5, 4, 3, 2, 1, 300);
assertGetTime("2010-05-04T03:02:01.35", 2010, 5, 4, 3, 2, 1, 350);
assertGetTime("2010-05-04T03:02:01.352Z", 2010, 5, 4, 3, 2, 1, 352);
assertGetTime("2010-05-04T03:02:01.3525Z", 2010, 5, 4, 3, 2, 1, 352);
assertGetTime("2010-05-04T03:02:01.3529Z", 2010, 5, 4, 3, 2, 1, 353);
}
/**
@@ -199,4 +199,24 @@ public class StringUtilsTest {
Assert.assertEquals(-1, parts[1]);
Assert.assertEquals(0, parts[2]);
}
@Test
public void testFormatDecimal() {
Assert.assertEquals("0", StringUtils.formatDecimal(0.0, 0));
Assert.assertEquals("0", StringUtils.formatDecimal(0.1, 0));
Assert.assertEquals("1", StringUtils.formatDecimal(1.1, 0));
Assert.assertEquals("10", StringUtils.formatDecimal(10, 0));
Assert.assertEquals("10", StringUtils.formatDecimal(10.1, 0));
Assert.assertEquals("0", StringUtils.formatDecimal(-0.1, 0));
Assert.assertEquals("0", StringUtils.formatDecimal(0.0, 2));
Assert.assertEquals("0.1", StringUtils.formatDecimal(0.1, 2));
Assert.assertEquals("1.1", StringUtils.formatDecimal(1.1, 2));
Assert.assertEquals("10", StringUtils.formatDecimal(10, 2));
Assert.assertEquals("10.1", StringUtils.formatDecimal(10.1, 2));
Assert.assertEquals("10.11", StringUtils.formatDecimal(10.111, 2));
Assert.assertEquals("-0.1", StringUtils.formatDecimal(-0.1, 2));
Assert.assertEquals("1", StringUtils.formatDecimal(0.99, 1));
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ limitations under the License.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.dennisguse.opentracks"
android:installLocation="auto"
android:versionCode="3079"
android:versionName="v3.2.2"> <!-- For FDroid. -->
android:versionCode="3101"
android:versionName="v3.2.3"> <!-- For FDroid. -->
<!-- Permission to initialize services -->
<application
android:name=".Startup"
@@ -57,7 +57,6 @@ import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.ListItemUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.StringUtils;
import de.dennisguse.opentracks.util.ToolbarUtils;
import de.dennisguse.opentracks.util.TrackIconUtils;
/**
@@ -123,10 +122,6 @@ public class SearchListActivity extends AbstractListActivity implements DeleteMa
MenuItem shareMenuItem = menu.findItem(R.id.list_context_menu_share);
if (isSingleSelectionTrack) {
shareMenuItem.setVisible(!isRecording);
Map<String, Object> item = arrayAdapter.getItem(positions[0]);
Long trackId = (Long) item.get(TRACK_ID_FIELD);
ToolbarUtils.setupShareActionProvider(SearchListActivity.this, shareMenuItem, new long[]{trackId});
}
// One item, item is a marker
@@ -290,6 +285,11 @@ public class SearchListActivity extends AbstractListActivity implements DeleteMa
case R.id.list_context_menu_show_on_map:
IntentUtils.showCoordinateOnMap(this, (double) item.get(MARKER_LATITUDE_FIELD), (double) item.get(MARKER_LONGITUDE_FIELD), item.get(NAME_FIELD) + "");
return true;
case R.id.list_context_menu_share:
intent = IntentUtils.newShareFileIntent(this, new long[]{trackId});
intent = Intent.createChooser(intent, null);
startActivity(intent);
return true;
case R.id.list_context_menu_edit:
if (markerId != null) {
intent = IntentUtils.newIntent(this, MarkerEditActivity.class)
@@ -55,7 +55,6 @@ import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.util.FileUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.ToolbarUtils;
import de.dennisguse.opentracks.util.TrackIconUtils;
import de.dennisguse.opentracks.util.TrackUtils;
@@ -316,10 +315,7 @@ public class TrackDetailActivity extends AbstractListActivity implements ChooseA
insertMarkerMenuItem = menu.findItem(R.id.track_detail_insert_marker);
insertPhotoMenuItem = menu.findItem(R.id.track_detail_insert_photo);
insertPhotoMenuItem.setVisible(new Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(getPackageManager()) != null);
shareMenuItem = menu.findItem(R.id.track_detail_share);
ToolbarUtils.setupShareActionProviderCompat(this, shareMenuItem, new long[]{trackId});
voiceFrequencyMenuItem = menu.findItem(R.id.track_detail_voice_frequency);
splitFrequencyMenuItem = menu.findItem(R.id.track_detail_split_frequency);
markerListMenuItem = menu.findItem(R.id.track_detail_markers);
@@ -343,6 +339,11 @@ public class TrackDetailActivity extends AbstractListActivity implements ChooseA
.putExtra(MarkerEditActivity.EXTRA_TRACK_ID, trackId);
startActivity(intent);
return true;
case R.id.track_detail_share:
intent = IntentUtils.newShareFileIntent(this, new long[]{trackId});
intent = Intent.createChooser(intent, null);
startActivity(intent);
return true;
case R.id.track_detail_insert_photo:
if (!FileUtils.isExternalStorageWriteable() || ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, EXTERNAL_STORAGE_PERMISSION_REQUEST_CODE);
@@ -60,8 +60,8 @@ import de.dennisguse.opentracks.util.ActivityUtils;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.ListItemUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.ServiceUtils;
import de.dennisguse.opentracks.util.StringUtils;
import de.dennisguse.opentracks.util.ToolbarUtils;
import de.dennisguse.opentracks.util.TrackIconUtils;
import de.dennisguse.opentracks.util.TrackUtils;
@@ -115,9 +115,6 @@ public class TrackListActivity extends AbstractListActivity implements ConfirmDe
public void onPrepare(Menu menu, int[] positions, long[] trackIds, boolean showSelectAll) {
boolean isSingleSelection = trackIds.length == 1;
MenuItem shareMenuItem = menu.findItem(R.id.list_context_menu_share);
ToolbarUtils.setupShareActionProvider(TrackListActivity.this, shareMenuItem, trackIds);
menu.findItem(R.id.list_context_menu_edit).setVisible(isSingleSelection);
menu.findItem(R.id.list_context_menu_select_all).setVisible(showSelectAll);
}
@@ -386,7 +383,7 @@ public class TrackListActivity extends AbstractListActivity implements ConfirmDe
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
boolean isGpsStarted = TrackRecordingServiceConnection.isRecordingServiceRunning(this);
boolean isGpsStarted = ServiceUtils.isTrackRecordingServiceRunning(this);
boolean isRecording = PreferencesUtils.isRecording(recordingTrackId);
updateMenuItems(isGpsStarted, isRecording);
@@ -406,7 +403,7 @@ public class TrackListActivity extends AbstractListActivity implements ConfirmDe
intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS);
startActivity(intent);
} else {
startGps = !TrackRecordingServiceConnection.isRecordingServiceRunning(this);
startGps = !ServiceUtils.isTrackRecordingServiceRunning(this);
// Show toast
Toast toast = Toast.makeText(this, startGps ? R.string.gps_starting : R.string.gps_stopping, Toast.LENGTH_SHORT);
@@ -531,12 +528,18 @@ public class TrackListActivity extends AbstractListActivity implements ConfirmDe
* @return true if handled.
*/
private boolean handleContextItem(int itemId, long[] trackIds) {
Intent intent;
switch (itemId) {
case R.id.list_context_menu_show_on_map:
IntentUtils.showTrackOnMap(this, trackIds);
return true;
case R.id.list_context_menu_share:
intent = IntentUtils.newShareFileIntent(this, trackIds);
intent = Intent.createChooser(intent, null);
startActivity(intent);
return true;
case R.id.list_context_menu_edit:
Intent intent = IntentUtils.newIntent(this, TrackEditActivity.class)
intent = IntentUtils.newIntent(this, TrackEditActivity.class)
.putExtra(TrackEditActivity.EXTRA_TRACK_ID, trackIds[0]);
startActivity(intent);
return true;
@@ -40,7 +40,7 @@ public final class SensorDataSet {
}
public boolean hasHeartRate() {
return !Float.isNaN(heartRate);
return !Float.isNaN(heartRate) && heartRate > 0;
}
public float getHeartRate() {
@@ -8,6 +8,7 @@ public enum SensorState {
NONE,
CONNECTING,
CONNECTED,
DISCONNECTING,
DISCONNECTED;
public static String getStateAsString(SensorState state, Context c) {
@@ -18,6 +19,8 @@ public enum SensorState {
return c.getString(R.string.sensor_state_connecting);
case CONNECTED:
return c.getString(R.string.sensor_state_connected);
case DISCONNECTING:
return c.getString(R.string.sensor_state_disconnected);
case DISCONNECTED:
return c.getString(R.string.sensor_state_disconnected);
default:
@@ -43,7 +43,6 @@ import de.dennisguse.opentracks.content.Waypoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.ITrackRecordingService;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.services.sensors.RemoteSensorManager;
import de.dennisguse.opentracks.stats.TripStatistics;
import de.dennisguse.opentracks.util.LocationUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -59,7 +58,7 @@ import de.dennisguse.opentracks.util.UnitConversions;
*/
public class StatsFragment extends Fragment implements TrackDataListener {
public static final String STATS_FRAGMENT_TAG = "statsFragment";
public static final String STATS_FRAGMENT_TAG = StatsFragment.class.getSimpleName();
private TrackDataHub trackDataHub;
private Handler handlerUpdateUI;
@@ -94,8 +93,6 @@ public class StatsFragment extends Fragment implements TrackDataListener {
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
handlerUpdateUI = new Handler();
trackRecordingServiceConnection = new TrackRecordingServiceConnection(getContext(), null);
trackRecordingServiceConnection.startConnection(getContext());
Spinner activityTypeIcon = getView().findViewById(R.id.stats_activity_type_icon);
activityTypeIcon.setAdapter(TrackIconUtils.getIconSpinnerAdapter(getActivity(), ""));
@@ -123,6 +120,10 @@ public class StatsFragment extends Fragment implements TrackDataListener {
public void onResume() {
super.onResume();
resumeTrackDataHub();
trackRecordingServiceConnection = new TrackRecordingServiceConnection(getContext(), null);
trackRecordingServiceConnection.startConnection(getContext());
updateUi(getActivity());
if (isSelectedTrackRecording()) {
handlerUpdateUI.post(updateUIeachSecond);
@@ -140,6 +141,7 @@ public class StatsFragment extends Fragment implements TrackDataListener {
public void onStop() {
super.onStop();
trackRecordingServiceConnection.unbind();
trackRecordingServiceConnection = null;
}
@Override
@@ -298,7 +300,7 @@ public class StatsFragment extends Fragment implements TrackDataListener {
}
/**
* Tries to fetch most recent {@link SensorDataSet} {@link RemoteSensorManager}.
* Tries to fetch most recent {@link SensorDataSet} {@link de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager}.
*/
private void updateSensorDataUI() {
ITrackRecordingService trackRecordingService = trackRecordingServiceConnection.getServiceIfBound();
@@ -182,6 +182,10 @@ public class KmlFileTrackImporter extends AbstractFileTrackImporter {
* On waypoint end.
*/
private void onWaypointEnd() throws SAXException {
if (waypointType == null) {
return;
}
// Add a waypoint if the waypointType matches
WaypointType type;
switch (waypointType) {
@@ -57,7 +57,7 @@ import de.dennisguse.opentracks.content.Waypoint;
import de.dennisguse.opentracks.content.Waypoint.WaypointType;
import de.dennisguse.opentracks.content.WaypointCreationRequest;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.sensors.RemoteSensorManager;
import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.services.sensors.RemoteSensorManagerFactory;
import de.dennisguse.opentracks.services.tasks.AnnouncementPeriodicTaskFactory;
import de.dennisguse.opentracks.services.tasks.PeriodicTaskExecutor;
@@ -169,7 +169,7 @@ public class TrackRecordingService extends Service {
};
private TripStatisticsUpdater markerTripStatisticsUpdater;
private WakeLock wakeLock;
private RemoteSensorManager remoteSensorManager;
private BluetoothRemoteSensorManager remoteSensorManager;
private Location lastLocation;
private boolean currentSegmentHasLocation;
private boolean isIdle; // true if idle
@@ -446,7 +446,8 @@ public class TrackRecordingService extends Service {
*/
private void handleStartCommand(Intent intent, int startId) {
// Check if the service is called to resume track (from phone reboot)
if (intent != null && intent.getBooleanExtra(RESUME_TRACK_EXTRA_NAME, false) && !shouldResumeTrack()) {
boolean resumeTrackRequested = intent.getBooleanExtra(RESUME_TRACK_EXTRA_NAME, false);
if (resumeTrackRequested && !shouldResumeTrack()) {
Log.i(TAG, "Stop resume track.");
updateRecordingState(PreferencesUtils.RECORDING_TRACK_ID_DEFAULT, true);
stopSelfResult(startId);
@@ -16,7 +16,6 @@
package de.dennisguse.opentracks.services;
import android.app.ActivityManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@@ -29,14 +28,13 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import java.util.List;
import de.dennisguse.opentracks.BuildConfig;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackEditActivity;
import de.dennisguse.opentracks.content.WaypointCreationRequest;
import de.dennisguse.opentracks.util.IntentUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.ServiceUtils;
/**
* Wrapper for the track recording service.
@@ -154,7 +152,7 @@ public class TrackRecordingServiceConnection implements ServiceConnection, Death
return;
}
if (!startIfNeeded && !isRecordingServiceRunning(context)) {
if (!startIfNeeded && !ServiceUtils.isTrackRecordingServiceRunning(context)) {
Log.d(TAG, "Service is not started. Not binding it.");
return;
}
@@ -169,31 +167,6 @@ public class TrackRecordingServiceConnection implements ServiceConnection, Death
context.bindService(new Intent(context, TrackRecordingService.class), this, flags);
}
/**
* Returns true if the recording service is running.
*
* @param context the current context
*/
@Deprecated
public static boolean isRecordingServiceRunning(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
if (activityManager == null) {
return false;
}
//TODO This approach is deprecated as of API level 26 and should be replaced.
List<ActivityManager.RunningServiceInfo> services = activityManager.getRunningServices(Integer.MAX_VALUE);
for (ActivityManager.RunningServiceInfo serviceInfo : services) {
ComponentName componentName = serviceInfo.service;
String serviceName = componentName.getClassName();
if (TrackRecordingService.class.getName().equals(serviceName)) {
return true;
}
}
return false;
}
/**
* Resumes the track recording service connection.
*
@@ -201,7 +174,7 @@ public class TrackRecordingServiceConnection implements ServiceConnection, Death
*/
public void startConnection(@NonNull Context context) {
bindIfStarted();
if (!isRecordingServiceRunning(context)) {
if (!ServiceUtils.isTrackRecordingServiceRunning(context)) {
resetRecordingState(context);
}
}
@@ -23,9 +23,7 @@ import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -34,6 +32,7 @@ import java.util.UUID;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.content.sensor.SensorState;
import de.dennisguse.opentracks.util.BluetoothUtils;
/**
* Manages connection to Bluetooth LE heart rate monitor.
@@ -42,10 +41,10 @@ import de.dennisguse.opentracks.content.sensor.SensorState;
public class BluetoothConnectionManager {
// Message types sent to handler
static final int MESSAGE_DEVICE_NAME = 1;
static final int MESSAGE_READ = 2;
static final String KEY_DEVICE_NAME = "device_name";
static final int MESSAGE_CONNECTING = 1;
static final int MESSAGE_CONNECTED = 2;
static final int MESSAGE_READ = 3;
static final int MESSAGE_DISCONNECTED = 4;
private static final UUID HEART_RATE_SERVICE_UUID = new UUID(0x180D00001000L, 0x800000805f9b34fbL);
private static final UUID HEART_RATE_MEASUREMENT_CHAR_UUID = new UUID(0x2A3700001000L, 0x800000805f9b34fbL);
@@ -58,22 +57,36 @@ public class BluetoothConnectionManager {
private SensorState sensorState;
private BluetoothGatt bluetoothGatt;
private BluetoothDevice bluetoothDevice;
private BluetoothGattCallback connectCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices();
setState(SensorState.CONNECTED);
switch (newState) {
case BluetoothProfile.STATE_CONNECTING:
Log.d(TAG, "Connecting to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTING);
//Inform about status change
Message message = handler.obtainMessage(MESSAGE_DEVICE_NAME);
Bundle bundle = new Bundle();
bundle.putString(KEY_DEVICE_NAME, gatt.getDevice().getName());
message.setData(bundle);
handler.sendMessage(message);
return;
handler.obtainMessage(MESSAGE_CONNECTING, gatt.getDevice().getName()).sendToTarget();
case BluetoothProfile.STATE_CONNECTED:
Log.d(TAG, "Connected to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTED);
gatt.discoverServices();
handler.obtainMessage(MESSAGE_CONNECTED, gatt.getDevice().getName()).sendToTarget();
break;
case BluetoothProfile.STATE_DISCONNECTING:
Log.d(TAG, "Disconnecting from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTING);
case BluetoothProfile.STATE_DISCONNECTED:
Log.d(TAG, "Disconnected from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTED);
handler.obtainMessage(MESSAGE_DISCONNECTED, gatt.getDevice().getName()).sendToTarget();
}
Log.d(TAG, "Could not connect to bluetooth sensor: " + gatt.getDevice());
}
@Override
@@ -91,10 +104,11 @@ public class BluetoothConnectionManager {
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) {
int heartRate = BluetoothLEUtils.parseHeartRate(characteristic);
int heartRate = BluetoothUtils.parseHeartRate(characteristic);
String deviceName = gatt.getDevice().getName();
Log.d(TAG, "Received heart beat rate: " + heartRate);
SensorDataSet sensorDataSet = new SensorDataSet(heartRate, gatt.getDevice().getName(), gatt.getDevice().getAddress());
Log.d(TAG, "Received heart beat rate " + deviceName + ": " + heartRate);
SensorDataSet sensorDataSet = new SensorDataSet(heartRate, deviceName, gatt.getDevice().getAddress());
handler.obtainMessage(MESSAGE_READ, sensorDataSet).sendToTarget();
}
};
@@ -104,45 +118,42 @@ public class BluetoothConnectionManager {
*
* @param handler a handler for sending messages back to the UI activity
*/
BluetoothConnectionManager(Context context, Handler handler) {
BluetoothConnectionManager(@NonNull Context context, @NonNull BluetoothDevice bluetoothDevice, @NonNull Handler handler) {
this.context = context;
this.bluetoothDevice = bluetoothDevice;
this.handler = handler;
this.sensorState = SensorState.NONE;
}
/**
* Gets the sensor state.
*/
public synchronized void connect() {
if (bluetoothGatt != null) {
Log.w(TAG, "Already connected; ignoring.");
}
Log.d(TAG, "Connecting to: " + bluetoothDevice);
setState(SensorState.CONNECTING);
bluetoothGatt = bluetoothDevice.connectGatt(this.context, true, this.connectCallback);
}
public synchronized void disconnect() {
if (bluetoothGatt == null) {
Log.w(TAG, "Cannot disconnect if not connected.");
return;
}
bluetoothGatt.close();
bluetoothGatt = null;
}
public synchronized boolean isSameBluetoothDevice(String address) {
return this.bluetoothDevice.getAddress().equals(address);
}
synchronized SensorState getSensorState() {
return sensorState;
}
/**
* Sets the sensor state.
*
* @param sensorState the sensor state
*/
private synchronized void setState(SensorState sensorState) {
this.sensorState = sensorState;
}
/**
* Resets the bluetooth connection manager.
*/
public synchronized void reset() {
//TODO Disconnect
setState(SensorState.NONE);
}
/**
* Connects to a bluetooth device.
*
* @param bluetoothDevice the bluetooth device
*/
public synchronized void connect(BluetoothDevice bluetoothDevice) {
Log.d(TAG, "connect to: " + bluetoothDevice);
bluetoothDevice.connectGatt(this.context, false, this.connectCallback);
setState(SensorState.CONNECTING);
}
}
@@ -1,17 +0,0 @@
package de.dennisguse.opentracks.services.sensors;
import android.bluetooth.BluetoothGattCharacteristic;
final class BluetoothLEUtils {
private BluetoothLEUtils() {
}
static int parseHeartRate(BluetoothGattCharacteristic characteristic) {
//DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/
byte[] raw = characteristic.getValue();
int index = ((raw[0] & 0x1) == 1) ? 2 : 1;
int format = (index == 1) ? BluetoothGattCharacteristic.FORMAT_UINT8 : BluetoothGattCharacteristic.FORMAT_UINT16;
return characteristic.getIntValue(format, index);
}
}
@@ -19,26 +19,28 @@ package de.dennisguse.opentracks.services.sensors;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.widget.Toast;
import java.util.ArrayList;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.content.sensor.SensorState;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.UnitConversions;
/**
* Bluetooth sensor manager.
* TODO: Handle a BluetoothGatt.STATE_DISCONNECTED
*
* @author Sandor Dornbush
*/
public class BluetoothRemoteSensorManager extends RemoteSensorManager {
public class BluetoothRemoteSensorManager {
public static final long MAX_SENSOR_DATE_SET_AGE_MS = 5000;
private static final String TAG = BluetoothConnectionManager.class.getSimpleName();
@@ -46,17 +48,21 @@ public class BluetoothRemoteSensorManager extends RemoteSensorManager {
private final Context context;
private final BluetoothConnectionManager bluetoothConnectionManager;
private SensorDataSet sensorDataSet = null;
private final SharedPreferences sharedPreferences;
// Handler that gets information back from the bluetoothConnectionManager
private final Handler messageHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message message) {
String toastMessage;
switch (message.what) {
case BluetoothConnectionManager.MESSAGE_DEVICE_NAME:
String deviceName = message.getData().getString(BluetoothConnectionManager.KEY_DEVICE_NAME);
case BluetoothConnectionManager.MESSAGE_CONNECTING:
//Ignore for now.
toastMessage = context.getString(R.string.settings_sensor_connecting, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
case BluetoothConnectionManager.MESSAGE_CONNECTED:
toastMessage = context.getString(R.string.settings_sensor_connected, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
case BluetoothConnectionManager.MESSAGE_READ:
if (!(message.obj instanceof SensorDataSet)) {
@@ -66,18 +72,44 @@ public class BluetoothRemoteSensorManager extends RemoteSensorManager {
sensorDataSet = (SensorDataSet) message.obj;
}
break;
case BluetoothConnectionManager.MESSAGE_DISCONNECTED:
toastMessage = context.getString(R.string.settings_sensor_disconnected, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
default:
Log.e(TAG, "Got an undefined case. Please check.");
break;
}
}
};
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() {
@Override
public void onSharedPreferenceChanged(SharedPreferences preferences, String key) {
if (bluetoothConnectionManager != null && key == null || key.equals(PreferencesUtils.getKey(context, R.string.bluetooth_sensor_key))) {
String address = PreferencesUtils.getString(context, R.string.bluetooth_sensor_key, PreferencesUtils.BLUETOOTH_SENSOR_DEFAULT);
if (address.equals(PreferencesUtils.BLUETOOTH_SENSOR_DEFAULT)) {
stop();
return;
}
if (bluetoothConnectionManager.isSameBluetoothDevice(address)) {
return;
}
disconnect();
startCurrentSensor();
}
}
};
private SensorDataSet sensorDataSet = null;
private BluetoothConnectionManager bluetoothConnectionManager;
/**
* @param context the context
*/
BluetoothRemoteSensorManager(Context context) {
this.context = context;
bluetoothConnectionManager = new BluetoothConnectionManager(context, messageHandler);
sharedPreferences = PreferencesUtils.getSharedPreferences(context);
}
private static BluetoothAdapter getDefaultBluetoothAdapter() {
@@ -117,13 +149,34 @@ public class BluetoothRemoteSensorManager extends RemoteSensorManager {
return adapters.get(0);
}
@Override
public void start() {
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
startCurrentSensor();
}
public void stop() {
disconnect();
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
}
public boolean isEnabled() {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled();
}
@Override
protected void setUpChannel() {
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
public boolean isSensorDataSetValid() {
SensorDataSet sensorDataSet = getSensorDataSet();
if (sensorDataSet == null) {
return false;
}
return sensorDataSet.isRecent(MAX_SENSOR_DATE_SET_AGE_MS);
}
private void startCurrentSensor() {
if (!isEnabled()) {
Log.w(TAG, "Bluetooth not enabled.");
return;
@@ -134,30 +187,30 @@ public class BluetoothRemoteSensorManager extends RemoteSensorManager {
Log.w(TAG, "No bluetooth address.");
return;
}
Log.w(TAG, "Connecting to bluetooth address: " + address);
Log.i(TAG, "Connecting to bluetooth address: " + address);
BluetoothDevice device;
try {
device = bluetoothAdapter.getRemoteDevice(address);
} catch (IllegalArgumentException e) {
Log.d(TAG, "Unable to get remote device for: " + address, e);
Log.w(TAG, "Unable to get remote device for: " + address, e);
String toastMessage = context.getString(R.string.sensor_not_known, address);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
return;
}
bluetoothConnectionManager.connect(device);
disconnect();
bluetoothConnectionManager = new BluetoothConnectionManager(context, device, messageHandler);
bluetoothConnectionManager.connect();
}
@Override
protected void tearDownChannel() {
bluetoothConnectionManager.reset();
}
@Override
public SensorState getSensorState() {
return bluetoothConnectionManager.getSensorState();
}
@Override
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
private void disconnect() {
if (bluetoothConnectionManager != null) {
bluetoothConnectionManager.disconnect();
bluetoothConnectionManager = null;
}
}
}
@@ -1,135 +0,0 @@
/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package de.dennisguse.opentracks.services.sensors;
import android.util.Log;
import java.util.Timer;
import java.util.TimerTask;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.content.sensor.SensorState;
/**
* Manage the connection to a remote sensor.
*
* @author Sandor Dornbush
*/
public abstract class RemoteSensorManager {
public static final long MAX_SENSOR_DATE_SET_AGE = 5000;
public static final long MAX_SENSOR_STATE_AGE = 20000;
private static final String TAG = RemoteSensorManager.class.getSimpleName();
private static final int RETRY_PERIOD = 20000;
private SensorState sensorState = SensorState.NONE;
private long sensorStateTimestamp = System.currentTimeMillis();
private TimerTask timerTask;
private Timer timer;
/**
* Returns true if the sensor is enabled.
*/
public abstract boolean isEnabled();
/**
* Sets up the sensor channel.
*/
protected abstract void setUpChannel();
/**
* Tears down the sensor channel.
*/
protected abstract void tearDownChannel();
/**
* Gets the sensor data set.
*/
public abstract SensorDataSet getSensorDataSet();
/**
* Starts the sensor.
*/
public void startSensor() {
setUpChannel();
timerTask = new TimerTask() {
@Override
public void run() {
switch (getSensorState()) {
case CONNECTING:
if (System.currentTimeMillis() - sensorStateTimestamp > MAX_SENSOR_STATE_AGE) {
Log.i(TAG, "Retry setUpChannel");
setUpChannel();
}
break;
case DISCONNECTED:
setUpChannel();
break;
default: // NONE, CONNECTED or SENDING
}
}
};
timer = new Timer(RemoteSensorManager.class.getSimpleName());
timer.schedule(timerTask, RETRY_PERIOD, RETRY_PERIOD);
}
/**
* Stops the sensor.
*/
public void stopSensor() {
if (timerTask != null) {
timerTask.cancel();
timerTask = null;
}
if (timer != null) {
timer.cancel();
timer.purge();
timer = null;
}
tearDownChannel();
}
/**
* Gets the sensor state.
*/
public SensorState getSensorState() {
return sensorState;
}
/**
* Sets the sensor state.
*
* @param sensorState the sensor state
*/
public void setSensorState(SensorState sensorState) {
sensorStateTimestamp = System.currentTimeMillis();
this.sensorState = sensorState;
}
/**
* Returns true if the sensor data set is valid.
*/
public boolean isSensorDataSetValid() {
SensorDataSet sensorDataSet = getSensorDataSet();
if (sensorDataSet == null) {
return false;
}
return sensorDataSet.isRecent(MAX_SENSOR_DATE_SET_AGE);
}
}
@@ -19,16 +19,14 @@ package de.dennisguse.opentracks.services.sensors;
import android.content.Context;
/**
* A factory of {@link RemoteSensorManager}.
* A factory of {@link BluetoothRemoteSensorManager}.
*
* @author Sandor Dornbush
*/
public class RemoteSensorManagerFactory {
private static RemoteSensorManager remoteSensorManagerSystem = null;
private static BluetoothRemoteSensorManager remoteSensorManagerSystem = null;
//TODO Check if still needed? Is there a missing features?
private static RemoteSensorManager remoteSensorManagerTemporary = null;
private RemoteSensorManagerFactory() {
}
@@ -38,11 +36,10 @@ public class RemoteSensorManagerFactory {
*
* @param context the context
*/
public static RemoteSensorManager getSystemSensorManager(Context context) {
releaseSensorManagerTemporary();
public static BluetoothRemoteSensorManager getSystemSensorManager(Context context) {
releaseSystemSensorManager();
remoteSensorManagerSystem = getSensorManager(context);
remoteSensorManagerSystem.startSensor();
remoteSensorManagerSystem.start();
return remoteSensorManagerSystem;
}
@@ -51,40 +48,17 @@ public class RemoteSensorManagerFactory {
*/
public static void releaseSystemSensorManager() {
if (remoteSensorManagerSystem != null) {
remoteSensorManagerSystem.stopSensor();
remoteSensorManagerSystem.stop();
}
remoteSensorManagerSystem = null;
}
/**
* Gets the temp sensor manager.
*/
public static RemoteSensorManager getSensorManagerTemporary(Context context) {
releaseSensorManagerTemporary();
if (remoteSensorManagerSystem != null) {
return null;
}
remoteSensorManagerTemporary = getSensorManager(context);
remoteSensorManagerTemporary.startSensor();
return remoteSensorManagerTemporary;
}
/**
* Releases the temp sensor manager.
*/
private static void releaseSensorManagerTemporary() {
if (remoteSensorManagerTemporary != null) {
remoteSensorManagerTemporary.stopSensor();
}
remoteSensorManagerTemporary = null;
}
/**
* Gets the sensor manager.
*
* @param context the context
*/
private static RemoteSensorManager getSensorManager(Context context) {
private static BluetoothRemoteSensorManager getSensorManager(Context context) {
return new BluetoothRemoteSensorManager(context);
}
}
@@ -9,7 +9,7 @@ import java.util.ArrayList;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.util.BluetoothDeviceUtils;
import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.UnitConversions;
@@ -192,7 +192,7 @@ final class PreferenceHelper {
List<String> devicesAddressList = new ArrayList<>();
BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (bluetoothAdapter != null) {
BluetoothDeviceUtils.populateDeviceLists(bluetoothAdapter, devicesNameList, devicesAddressList);
BluetoothUtils.populateDeviceLists(bluetoothAdapter, devicesNameList, devicesAddressList);
}
// Was the previously configured device unpaired? Then forget it.
@@ -18,6 +18,7 @@ package de.dennisguse.opentracks.util;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothClass;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGattCharacteristic;
import java.util.List;
import java.util.Set;
@@ -27,9 +28,9 @@ import java.util.Set;
*
* @author Rodrigo Damazio
*/
public class BluetoothDeviceUtils {
public class BluetoothUtils {
private BluetoothDeviceUtils() {
private BluetoothUtils() {
}
/**
@@ -53,4 +54,13 @@ public class BluetoothDeviceUtils {
}
}
}
public static int parseHeartRate(BluetoothGattCharacteristic characteristic) {
//DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/
byte[] raw = characteristic.getValue();
int index = ((raw[0] & 0x1) == 1) ? 2 : 1;
int format = (index == 1) ? BluetoothGattCharacteristic.FORMAT_UINT8 : BluetoothGattCharacteristic.FORMAT_UINT16;
return characteristic.getIntValue(format, index);
}
}
@@ -29,6 +29,7 @@ import androidx.core.content.FileProvider;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import de.dennisguse.opentracks.R;
@@ -69,19 +70,32 @@ public class IntentUtils {
* @param context the context
* @param trackIds the track ids
*/
//TODO Share mulitple tracks in different files!
public static Intent newShareFileIntent(Context context, long[] trackIds) {
if (trackIds.length == 0) {
throw new RuntimeException("Need to share at least one track.");
}
String trackDescription = "";
if (trackIds.length == 1) {
Track track = ContentProviderUtils.Factory.get(context).getTrack(trackIds[0]);
trackDescription = track == null ? "" : new DescriptionGeneratorImpl(context).generateTrackDescription(track, false);
}
Pair<Uri, String> uriAndMime = ShareContentProvider.createURI(trackIds, TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES);
return new Intent(Intent.ACTION_SEND)
.putExtra(Intent.EXTRA_STREAM, uriAndMime.first)
String action = trackIds.length == 1 ? Intent.ACTION_SEND : Intent.ACTION_SEND_MULTIPLE;
String mime = "";
ArrayList<Uri> uris = new ArrayList<>();
for (long trackId : trackIds) {
Pair<Uri, String> uriAndMime = ShareContentProvider.createURI(new long[]{trackId}, TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES);
uris.add(uriAndMime.first);
mime = uriAndMime.second;
}
return new Intent(action)
.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.share_track_subject))
.putExtra(Intent.EXTRA_TEXT, context.getString(R.string.share_track_share_file_body, trackDescription))
.setType(uriAndMime.second)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
.setType(mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
@@ -0,0 +1,40 @@
package de.dennisguse.opentracks.util;
import android.app.ActivityManager;
import android.content.ComponentName;
import android.content.Context;
import java.util.List;
import de.dennisguse.opentracks.services.TrackRecordingService;
public class ServiceUtils {
private ServiceUtils() {
}
/**
* Returns true if the recording service is running.
*
* @param context the current context
*/
@Deprecated
public static boolean isTrackRecordingServiceRunning(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
if (activityManager == null) {
return false;
}
//TODO This approach is deprecated as of API level 26 and should be replaced.
List<ActivityManager.RunningServiceInfo> services = activityManager.getRunningServices(Integer.MAX_VALUE);
for (ActivityManager.RunningServiceInfo serviceInfo : services) {
ComponentName componentName = serviceInfo.service;
String serviceName = componentName.getClassName();
if (TrackRecordingService.class.getName().equals(serviceName)) {
return true;
}
}
return false;
}
}
@@ -17,7 +17,6 @@
package de.dennisguse.opentracks.util;
import android.app.Activity;
import android.content.Context;
import android.location.Location;
import android.util.Pair;
import android.view.View;
@@ -26,7 +25,7 @@ import android.widget.TextView;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.sensors.RemoteSensorManager;
import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.stats.TripStatistics;
/**
@@ -77,7 +76,7 @@ public class StatsUtils {
if (showGradeElevation) {
double altitude = location != null && location.hasAltitude() ? location.getAltitude() : Double.NaN;
Pair<String, String> parts = formatElevation(activity, altitude, metricUnits);
Pair<String, String> parts = StringUtils.formatElevation(activity, altitude, metricUnits);
TextView elevationValue = activity.findViewById(R.id.stats_elevation_current_value);
elevationValue.setText(parts.first);
@@ -119,9 +118,11 @@ public class StatsUtils {
TextView heartRateSensor = activity.findViewById(R.id.stats_sensor_heart_rate_sensor_value);
String heartRate = activity.getString(R.string.value_unknown);
String sensor = activity.getString(R.string.value_unknown);
if (sensorDataSet != null && sensorDataSet.hasHeartRate() && sensorDataSet.isRecent(RemoteSensorManager.MAX_SENSOR_DATE_SET_AGE)) {
heartRate = StringUtils.formatDecimal(sensorDataSet.getHeartRate(), 0);
if (sensorDataSet != null && sensorDataSet.isRecent(BluetoothRemoteSensorManager.MAX_SENSOR_DATE_SET_AGE_MS)) {
sensor = sensorDataSet.getSensorName();
if (sensorDataSet.hasHeartRate()) {
heartRate = StringUtils.formatDecimal(sensorDataSet.getHeartRate(), 0);
}
}
heartRateValue.setText(heartRate);
@@ -250,7 +251,7 @@ public class StatsUtils {
if (showElevation) {
{
double elevation = tripStatistics == null ? Double.NaN : tripStatistics.getMinElevation();
Pair<String, String> parts = formatElevation(activity, elevation, metricUnits);
Pair<String, String> parts = StringUtils.formatElevation(activity, elevation, metricUnits);
TextView elevationValue = activity.findViewById(R.id.stats_elevation_min_value);
elevationValue.setText(parts.first);
@@ -260,7 +261,7 @@ public class StatsUtils {
{
double elevation = tripStatistics == null ? Double.NaN : tripStatistics.getMaxElevation();
Pair<String, String> parts = formatElevation(activity, elevation, metricUnits);
Pair<String, String> parts = StringUtils.formatElevation(activity, elevation, metricUnits);
TextView elevationValue = activity.findViewById(R.id.stats_elevation_max_value);
elevationValue.setText(parts.first);
@@ -270,26 +271,4 @@ public class StatsUtils {
}
}
}
/**
* Sets an elevation value.
*
* @param context the context
* @param elevation the elevation in meters
* @param metricUnits true if metric units
* @return the formatted elevation (or null) and it's unit as {@link Pair}
*/
// TODO Move to StringUtils
private static Pair<String, String> formatElevation(Context context, double elevation, boolean metricUnits) {
String value = context.getString(R.string.value_unknown);
String unit = context.getString(metricUnits ? R.string.unit_meter : R.string.unit_feet);
if (!Double.isNaN(elevation) && !Double.isInfinite(elevation)) {
if (metricUnits) {
value = StringUtils.formatDecimal(elevation);
} else {
value = StringUtils.formatDecimal(elevation * UnitConversions.M_TO_FT);
}
}
return new Pair<>(value, unit);
}
}
@@ -21,6 +21,7 @@ import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Pair;
import java.text.DecimalFormat;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Date;
@@ -124,13 +125,20 @@ public class StringUtils {
}
}
static String formatDecimal(double value) {
private static String formatDecimal(double value) {
return StringUtils.formatDecimal(value, 2);
}
/**
* Format a decimal number while removing trailing zeros of the decimal part (if present).
*/
static String formatDecimal(double value, int decimalPlaces) {
String result = String.format(Locale.getDefault(), "%1$,." + decimalPlaces + "f", value);
return result.replaceAll("[0]*$", "").replaceAll("\\.$", "");
if (decimalPlaces < 1) {
return Long.toString(Math.round(value));
}
String format = "#." + new String(new char[decimalPlaces]).replace("\0", "#");
return new DecimalFormat(format).format(value);
}
/**
@@ -361,4 +369,25 @@ public class StringUtils {
}
return options;
}
/**
* Sets an elevation value.
*
* @param context the context
* @param elevation the elevation in meters
* @param metricUnits true if metric units
* @return the formatted elevation (or null) and it's unit as {@link Pair}
*/
public static Pair<String, String> formatElevation(Context context, double elevation, boolean metricUnits) {
String value = context.getString(R.string.value_unknown);
String unit = context.getString(metricUnits ? R.string.unit_meter : R.string.unit_feet);
if (!Double.isNaN(elevation) && !Double.isInfinite(elevation)) {
if (metricUnits) {
value = StringUtils.formatDecimal(elevation);
} else {
value = StringUtils.formatDecimal(elevation * UnitConversions.M_TO_FT);
}
}
return new Pair<>(value, unit);
}
}
@@ -1,32 +0,0 @@
package de.dennisguse.opentracks.util;
import android.content.Context;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.ShareActionProvider;
import androidx.core.internal.view.SupportMenuItem;
import androidx.core.view.MenuItemCompat;
public class ToolbarUtils {
private ToolbarUtils() {
}
//TODO Compat is not working as the AbsListView.MultiChoiceModeListener; instantiating it manually using the non-compat is a workaround.
@Deprecated
public static void setupShareActionProvider(@NonNull Context context, @NonNull MenuItem shareMenuItem, @NonNull long[] trackIds) {
android.widget.ShareActionProvider shareActionProvider = new android.widget.ShareActionProvider(context);
shareActionProvider.setShareIntent(trackIds.length == 0 ? null : IntentUtils.newShareFileIntent(context, trackIds));
shareMenuItem.setActionProvider(shareActionProvider);
}
/**
* @param shareMenuItem Should be a {@link SupportMenuItem}.
*/
public static void setupShareActionProviderCompat(@NonNull Context context, @NonNull MenuItem shareMenuItem, @NonNull long[] trackIds) {
ShareActionProvider shareActionProvider = (ShareActionProvider) MenuItemCompat.getActionProvider(shareMenuItem);
shareActionProvider.setShareIntent(trackIds.length == 0 ? null : IntentUtils.newShareFileIntent(context, trackIds));
MenuItemCompat.setActionProvider(shareMenuItem, shareActionProvider);
}
}
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>
+1 -1
View File
@@ -17,8 +17,8 @@ limitations under the License.
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/list_context_menu_share"
android:icon="@drawable/ic_share_24dp"
android:title="@string/menu_share"
app:actionProviderClass="androidx.appcompat.widget.ShareActionProvider"
app:showAsAction="ifRoom" />
<item
android:id="@+id/list_context_menu_show_on_map"
+1 -1
View File
@@ -34,7 +34,7 @@ limitations under the License.
android:title="@string/menu_show_on_map" />
<item
android:id="@+id/track_detail_share"
app:actionProviderClass="androidx.appcompat.widget.ShareActionProvider"
android:icon="@drawable/ic_share_24dp"
android:orderInCategory="4"
app:showAsAction="ifRoom"
android:title="@string/menu_share" />
+13 -2
View File
@@ -1054,7 +1054,9 @@ limitations under the License.
sending data.">
Sending
</string>
<string name="sensor_not_known" translation_description="Error message if sensor is not available in Android (e.g., pairing was removed in Bluetooth Settings).">
Sensor %1$s is not known. Please check the settings.
</string>
<!-- Settings -->
<string name="settings_not_while_recording" translation_description="In settings, the summary for an option that is not available during a
track recording.">
@@ -1271,11 +1273,20 @@ limitations under the License.
Character limit: ~23 (1 line)">
Bluetooth sensor
</string>
<string name="settings_sensor_connected" translation_description="A message when a sensor is connected.
<string name="settings_sensor_connecting" translation_description="A message when a sensor is connecting.
%1$s sensor name. E.g., 'Polar Bluetooth'.">
Connected to %1$s
</string>
<string name="settings_sensor_connected" translation_description="A message when a sensor is connected.
%1$s sensor name. E.g., 'Polar Bluetooth'.">
Connected to %1$s
</string>
<string name="settings_sensor_disconnected" translation_description="A message when a sensor is disconnected.
%1$s sensor name. E.g., 'Polar Bluetooth'.">
Disconnected from %1$s
</string>
<!-- Settings Stats -->
<string name="settings_stats_coordinate" translation_description="In the 'Stats settings, the option to show latitude and longitude.