diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/config/ConfigRepository.java b/client/app/src/main/java/com/jackpf/locationhistory/client/config/ConfigRepository.java index 095b49e7..6f0fea45 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/config/ConfigRepository.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/config/ConfigRepository.java @@ -4,6 +4,9 @@ import android.content.SharedPreferences; import android.provider.Settings; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.TimeUnit; public class ConfigRepository { @@ -25,6 +28,8 @@ public class ConfigRepository { public static final String LAST_RUN_TIMESTAMP_KEY = "last-run-timestamp"; + public static final String ENABLED_LOCATION_PROVIDERS_KEY = "enabled-location-providers"; + /** * How long do we stay in high accuracy mode after a trigger */ @@ -117,4 +122,25 @@ public long getLastRunTimestamp() { public void setLastRunTimestamp(long lastRunTime) { prefs.edit().putLong(LAST_RUN_TIMESTAMP_KEY, lastRunTime).apply(); } + + /** + * Get the list of enabled location providers in order. + * Returns an empty list if not set. + */ + public List getEnabledLocationProviders() { + String providersString = prefs.getString(ENABLED_LOCATION_PROVIDERS_KEY, ""); + if (providersString.isEmpty()) { + return new ArrayList<>(); + } + return new ArrayList<>(Arrays.asList(providersString.split(","))); + } + + /** + * Set the list of enabled location providers in order. + * The list is stored as a comma-separated string. + */ + public void setEnabledLocationProviders(List providers) { + String providersString = String.join(",", providers); + prefs.edit().putString(ENABLED_LOCATION_PROVIDERS_KEY, providersString).apply(); + } } diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconClient.java b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconClient.java index 8034db48..3a6db3b4 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconClient.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconClient.java @@ -96,7 +96,8 @@ public ListenableFuture sendLocation(String deviceId, Beaco beaconRequest.getLat(), beaconRequest.getLon(), (double) beaconRequest.getAccuracy(), - beaconRequest.getTimestamp() + beaconRequest.getTimestamp(), + beaconRequest.getMetadata() ); ListenableFuture future = createStub().setLocation(request); Futures.addCallback(future, callback, threadExecutor); diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconRequest.java b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconRequest.java index 505078c2..8e8ba95a 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconRequest.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/BeaconRequest.java @@ -2,21 +2,28 @@ import android.location.Location; +import java.util.HashMap; +import java.util.Map; + import lombok.Value; @Value public class BeaconRequest { - long timestamp; - double lat; - double lon; - float accuracy; + private long timestamp; + private double lat; + private double lon; + private float accuracy; + private Map metadata; public static BeaconRequest fromLocation(Location location) { return new BeaconRequest( System.currentTimeMillis(), location.getLatitude(), location.getLongitude(), - location.getAccuracy() + location.getAccuracy(), + new HashMap() {{ + put("provider", location.getProvider()); + }} ); } } diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/Requests.java b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/Requests.java index 7b0b58b1..356c6cea 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/Requests.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/grpc/Requests.java @@ -9,6 +9,8 @@ import com.jackpf.locationhistory.RegisterPushHandlerRequest; import com.jackpf.locationhistory.SetLocationRequest; +import java.util.Map; + public class Requests { private static Device device(String deviceId, String deviceName) { return Device.newBuilder() @@ -20,12 +22,14 @@ private static Device device(String deviceId, String deviceName) { private static Location location( Double lat, Double lon, - Double accuracy + Double accuracy, + Map metadata ) { return Location.newBuilder() .setLat(lat) .setLon(lon) .setAccuracy(accuracy) + .putAllMetadata(metadata) .build(); } @@ -54,12 +58,13 @@ public static SetLocationRequest setLocationRequest( Double lat, Double lon, Double accuracy, - Long timestamp + Long timestamp, + Map metadata ) { return SetLocationRequest .newBuilder() .setDeviceId(deviceId) - .setLocation(location(lat, lon, accuracy)) + .setLocation(location(lat, lon, accuracy, metadata)) .setTimestamp(timestamp) .build(); } diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/location/LocationService.java b/client/app/src/main/java/com/jackpf/locationhistory/client/location/LocationService.java index 7a8d6972..2f34cca3 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/location/LocationService.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/location/LocationService.java @@ -15,9 +15,11 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.ToString; public class LocationService { private final LocationManager locationManager; @@ -36,6 +38,10 @@ public class LocationService { * Network should be relatively fast (~5s for radio wakeup or near instant if using WiFi location) */ private static final int NETWORK_TIMEOUT = 10_000; + /** + * If we don't know what we're calling + */ + private static final int DEFAULT_TIMEOUT = 30_000; /** * Timeout doesn't apply to cache requests */ @@ -43,6 +49,7 @@ public class LocationService { @Getter @AllArgsConstructor + @ToString private static class ProviderRequest { private String source; private int timeout; @@ -74,6 +81,18 @@ public static LocationService create(Context context, ); } + public List getAvailableSources() { + return filterEnabledSources(locationManager.getAllProviders()).stream() + .filter(p -> !p.equals(LocationManager.PASSIVE_PROVIDER)) + .collect(Collectors.toList()); + } + + private List filterEnabledSources(List sources) { + return sources.stream() + .filter(locationManager::isProviderEnabled) + .collect(Collectors.toList()); + } + private void callSequentialProviders(Iterator providers, Consumer consumer) { if (!providers.hasNext()) { consumer.accept(null); // All providers invalid/failed @@ -123,49 +142,68 @@ private LocationData chooseBestLocation(List locations) { return best; } + private List createRequests(List sources, LocationProvider provider) { + List requests = new ArrayList<>(); + + for (String source : filterEnabledSources(sources)) { + int timeout = timeoutForSource(source); + requests.add(new ProviderRequest(source, timeout, provider)); + } + + return requests; + } + + private int timeoutForSource(String source) { + switch (source) { + case LocationManager.GPS_PROVIDER: + return GPS_TIMEOUT; + case LocationManager.NETWORK_PROVIDER: + return NETWORK_TIMEOUT; + default: + return DEFAULT_TIMEOUT; + } + } + /** * @param accuracy Defines priority of accuracy vs battery use (GPS v.s. network v.s. cached location sources) * @param consumer Callback for location data * @throws SecurityException If we're missing location permissions */ - public void getLocation(RequestedAccuracy accuracy, Consumer consumer) throws SecurityException { + public void getLocation(RequestedAccuracy accuracy, + List sources, + Consumer consumer) throws SecurityException { if (!permissionsManager.hasLocationPermissions()) { throw new SecurityException("No location permissions"); } - List providers = new ArrayList<>(); - if (optimisedProvider.isSupported()) { /* Optimised provider will automatically use the location cache if available * and return a fresh (< ~30s) location if available */ log.d("Using optimised location manager"); - ProviderRequest gpsRequest = new ProviderRequest(LocationManager.GPS_PROVIDER, GPS_TIMEOUT, optimisedProvider); - ProviderRequest networkRequest = new ProviderRequest(LocationManager.NETWORK_PROVIDER, NETWORK_TIMEOUT, optimisedProvider); + List requests = createRequests(sources, optimisedProvider); + log.d("Requesting location from providers: %s", Arrays.toString(requests.toArray())); if (accuracy == RequestedAccuracy.HIGH) { - providers.addAll(Arrays.asList(gpsRequest, networkRequest)); - callParallelProviders(providers.iterator(), this::chooseBestLocation, consumer); + callParallelProviders(requests.iterator(), this::chooseBestLocation, consumer); } else { - providers.addAll(Arrays.asList(networkRequest, gpsRequest)); - callSequentialProviders(providers.iterator(), consumer); + callSequentialProviders(requests.iterator(), consumer); } } else { /* The legacy provider will directly request location from the hardware, * so we've gotta implement our own cache checks */ log.d("Using legacy location manager"); - ProviderRequest highGps = new ProviderRequest(LocationManager.GPS_PROVIDER, GPS_TIMEOUT, legacyHighAccuracyProvider); - ProviderRequest highNetwork = new ProviderRequest(LocationManager.NETWORK_PROVIDER, NETWORK_TIMEOUT, legacyHighAccuracyProvider); - ProviderRequest cachedGps = new ProviderRequest(LocationManager.GPS_PROVIDER, CACHE_TIMEOUT, legacyCachedProvider); - ProviderRequest cachedNetwork = new ProviderRequest(LocationManager.NETWORK_PROVIDER, CACHE_TIMEOUT, legacyCachedProvider); + List cachedRequests = createRequests(sources, legacyCachedProvider); + List highAccuracyRequests = createRequests(sources, legacyHighAccuracyProvider); + List allRequests = Stream.concat(cachedRequests.stream(), highAccuracyRequests.stream()) + .collect(Collectors.toList()); + log.d("Requesting location from providers: %s", Arrays.toString(allRequests.toArray())); if (accuracy == RequestedAccuracy.HIGH) { - providers.addAll(Arrays.asList(highGps, highNetwork, cachedGps, cachedNetwork)); - callParallelProviders(providers.iterator(), this::chooseBestLocation, consumer); + callParallelProviders(allRequests.iterator(), this::chooseBestLocation, consumer); } else { - providers.addAll(Arrays.asList(cachedGps, cachedNetwork, highNetwork, highGps)); - callSequentialProviders(providers.iterator(), consumer); + callSequentialProviders(allRequests.iterator(), consumer); } } } diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderAdapter.java b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderAdapter.java new file mode 100644 index 00000000..58dce80f --- /dev/null +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderAdapter.java @@ -0,0 +1,133 @@ +package com.jackpf.locationhistory.client.ui; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.jackpf.locationhistory.client.R; + +import java.util.Collections; +import java.util.List; + +import lombok.Setter; + +public class LocationProviderAdapter extends RecyclerView.Adapter { + + private final List providers; + @Setter + private ItemTouchHelper itemTouchHelper; + + public LocationProviderAdapter(List providers) { + this.providers = providers; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_location_provider, parent, false); + return new ViewHolder(view); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + LocationProviderItem item = providers.get(position); + + holder.providerName.setText(item.getProviderName()); + holder.providerCheckbox.setChecked(item.isEnabled()); + + holder.providerCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + item.setEnabled(isChecked); + }); + + holder.dragHandle.setOnTouchListener((v, event) -> { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN && itemTouchHelper != null) { + itemTouchHelper.startDrag(holder); + } + return false; + }); + } + + @Override + public int getItemCount() { + return providers.size(); + } + + public List getProviders() { + return providers; + } + + public void onItemMove(int fromPosition, int toPosition) { + if (fromPosition < toPosition) { + for (int i = fromPosition; i < toPosition; i++) { + Collections.swap(providers, i, i + 1); + } + } else { + for (int i = fromPosition; i > toPosition; i--) { + Collections.swap(providers, i, i - 1); + } + } + notifyItemMoved(fromPosition, toPosition); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + final ImageView dragHandle; + final CheckBox providerCheckbox; + final TextView providerName; + + ViewHolder(View itemView) { + super(itemView); + dragHandle = itemView.findViewById(R.id.dragHandle); + providerCheckbox = itemView.findViewById(R.id.providerCheckbox); + providerName = itemView.findViewById(R.id.providerName); + } + } + + public static class DragCallback extends ItemTouchHelper.Callback { + private final LocationProviderAdapter adapter; + + public DragCallback(LocationProviderAdapter adapter) { + this.adapter = adapter; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; // We use the drag handle instead + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) { + int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + return makeMovementFlags(dragFlags, 0); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) { + adapter.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition()); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + // Not used + } + } +} diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderItem.java b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderItem.java new file mode 100644 index 00000000..0e0fbd14 --- /dev/null +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/LocationProviderItem.java @@ -0,0 +1,11 @@ +package com.jackpf.locationhistory.client.ui; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LocationProviderItem { + private String providerName; + private boolean enabled; +} diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsFragment.java b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsFragment.java index 3410feac..fb9d0ef8 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsFragment.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsFragment.java @@ -8,6 +8,8 @@ import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.jackpf.locationhistory.client.R; @@ -25,6 +27,7 @@ public class SettingsFragment extends Fragment { private FragmentSettingsBinding binding; private SettingsViewModel viewModel; + private LocationProviderAdapter providerAdapter; @Nullable private SSLPrompt sslPrompt; @@ -44,6 +47,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat sslPrompt = new SSLPrompt(requireActivity()); setupInputs(); + setupLocationProviders(); setupUnifiedPushListener(); observeEvents(); } @@ -74,11 +78,29 @@ private void setupInputs() { binding.serverPortInput.getText().toString() )); - binding.saveButton.setOnClickListener(v -> viewModel.saveSettings( - binding.serverHostInput.getText().toString(), - binding.serverPortInput.getText().toString(), - binding.updateFrequencyInput.getText().toString() - )); + binding.saveButton.setOnClickListener(v -> { + viewModel.saveSettings( + binding.serverHostInput.getText().toString(), + binding.serverPortInput.getText().toString(), + binding.updateFrequencyInput.getText().toString() + ); + if (providerAdapter != null) { + viewModel.saveEnabledLocationProviders(providerAdapter.getProviders()); + } + }); + } + + private void setupLocationProviders() { + List providerItems = viewModel.getLocationProviderItems(); + providerAdapter = new LocationProviderAdapter(providerItems); + + binding.providersRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + binding.providersRecyclerView.setAdapter(providerAdapter); + + ItemTouchHelper.Callback callback = new LocationProviderAdapter.DragCallback(providerAdapter); + ItemTouchHelper touchHelper = new ItemTouchHelper(callback); + touchHelper.attachToRecyclerView(binding.providersRecyclerView); + providerAdapter.setItemTouchHelper(touchHelper); } private void setupUnifiedPushListener() { diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsViewModel.java b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsViewModel.java index 8841a075..3af8869a 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsViewModel.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/ui/SettingsViewModel.java @@ -16,13 +16,19 @@ import com.jackpf.locationhistory.client.client.util.GrpcFutureWrapper; import com.jackpf.locationhistory.client.config.ConfigRepository; import com.jackpf.locationhistory.client.grpc.BeaconClient; +import com.jackpf.locationhistory.client.location.LocationService; import com.jackpf.locationhistory.client.push.UnifiedPushContext; +import com.jackpf.locationhistory.client.util.AppExecutors; import com.jackpf.locationhistory.client.util.Logger; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; public class SettingsViewModel extends AndroidViewModel { private final Logger log = new Logger(this); @@ -30,6 +36,7 @@ public class SettingsViewModel extends AndroidViewModel { private final ConfigRepository configRepository; private final TrustedCertStorage trustedCertStorage; private final UnifiedPushContext unifiedPushContext; + private final LocationService locationService; private final SingleLiveEvent events = new SingleLiveEvent<>(); @@ -38,6 +45,10 @@ public SettingsViewModel(@NonNull Application application) { this.configRepository = new ConfigRepository(application); this.trustedCertStorage = new TrustedCertStorage(application); this.unifiedPushContext = new UnifiedPushContext(application); + this.locationService = LocationService.create( + application, + AppExecutors.getInstance().background() + ); } public LiveData getEvents() { @@ -112,4 +123,47 @@ public void registerUnifiedPush(String distributor) { log.d("Registering with distributor: %s", distributor); unifiedPushContext.register(distributor); } + + /** + * Get the list of location provider items for the settings UI. + * Providers are returned in priority order based on saved preferences. + * Providers not in saved preferences are added at the end (disabled by default). + */ + public List getLocationProviderItems() { + List availableProviders = locationService.getAvailableSources(); + List enabledProviders = configRepository.getEnabledLocationProviders(); + Set enabledSet = new HashSet<>(enabledProviders); + Set availableSet = new HashSet<>(availableProviders); + + List items = new ArrayList<>(); + + // First add enabled providers in their saved order (if still available) + for (String provider : enabledProviders) { + if (availableSet.contains(provider)) { + items.add(new LocationProviderItem(provider, true)); + } + } + + // Then add remaining available providers (not enabled) at the end + for (String provider : availableProviders) { + if (!enabledSet.contains(provider)) { + items.add(new LocationProviderItem(provider, false)); + } + } + + return items; + } + + /** + * Save the enabled location providers in order. + */ + public void saveEnabledLocationProviders(List items) { + List enabledProviders = items.stream() + .filter(LocationProviderItem::isEnabled) + .map(LocationProviderItem::getProviderName) + .collect(Collectors.toList()); + + configRepository.setEnabledLocationProviders(enabledProviders); + log.d("Saved enabled providers: %s", enabledProviders); + } } diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/util/AppExecutors.java b/client/app/src/main/java/com/jackpf/locationhistory/client/util/AppExecutors.java new file mode 100644 index 00000000..5a5e0978 --- /dev/null +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/util/AppExecutors.java @@ -0,0 +1,41 @@ +package com.jackpf.locationhistory.client.util; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class AppExecutors { + private static final AppExecutors INSTANCE = new AppExecutors(); + + private final ExecutorService background; + private final Executor mainThread; + + private AppExecutors() { + this.background = Executors.newCachedThreadPool(); + this.mainThread = new MainThreadExecutor(); + } + + public static AppExecutors getInstance() { + return INSTANCE; + } + + public ExecutorService background() { + return background; + } + + public Executor mainThread() { + return mainThread; + } + + private static class MainThreadExecutor implements Executor { + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + + @Override + public void execute(Runnable command) { + mainThreadHandler.post(command); + } + } +} diff --git a/client/app/src/main/java/com/jackpf/locationhistory/client/worker/BeaconContext.java b/client/app/src/main/java/com/jackpf/locationhistory/client/worker/BeaconContext.java index f7bb62c2..bb2bda25 100644 --- a/client/app/src/main/java/com/jackpf/locationhistory/client/worker/BeaconContext.java +++ b/client/app/src/main/java/com/jackpf/locationhistory/client/worker/BeaconContext.java @@ -56,7 +56,7 @@ public ListenableFuture onDeviceStateReady() { } public void getLocation(RequestedAccuracy accuracy, Consumer consumer) throws SecurityException { - locationService.getLocation(accuracy, consumer); + locationService.getLocation(accuracy, configRepository.getEnabledLocationProviders(), consumer); } public ListenableFuture setLocation(LocationData locationData) { diff --git a/client/app/src/main/res/drawable/ic_drag_handle.xml b/client/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..b65f1da2 --- /dev/null +++ b/client/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/client/app/src/main/res/layout/fragment_settings.xml b/client/app/src/main/res/layout/fragment_settings.xml index 8938772c..d002dd38 100644 --- a/client/app/src/main/res/layout/fragment_settings.xml +++ b/client/app/src/main/res/layout/fragment_settings.xml @@ -18,7 +18,7 @@ android:layout_marginStart="4dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/topLink" - app:layout_constraintBottom_toBottomOf="@id/pushDesc" /> + app:layout_constraintBottom_toBottomOf="@id/providersRecyclerView" /> + + + + + + + + + + \ No newline at end of file diff --git a/client/app/src/main/res/layout/item_location_provider.xml b/client/app/src/main/res/layout/item_location_provider.xml new file mode 100644 index 00000000..a9f3cd16 --- /dev/null +++ b/client/app/src/main/res/layout/item_location_provider.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/client/app/src/main/res/values/strings.xml b/client/app/src/main/res/values/strings.xml index e7882e2b..ef33eb3b 100644 --- a/client/app/src/main/res/values/strings.xml +++ b/client/app/src/main/res/values/strings.xml @@ -74,4 +74,8 @@ Alarm Triggered Alarm triggered via Location History + Location Providers + Enable and reorder location providers. Drag to change priority. Note that the order will affect battery life (e.g. prioritising GPS over Network). + Drag to reorder + \ No newline at end of file diff --git a/client/app/src/test/java/com/jackpf/locationhistory/client/location/LocationServiceTest.java b/client/app/src/test/java/com/jackpf/locationhistory/client/location/LocationServiceTest.java index ce34f6ab..efcde2a3 100644 --- a/client/app/src/test/java/com/jackpf/locationhistory/client/location/LocationServiceTest.java +++ b/client/app/src/test/java/com/jackpf/locationhistory/client/location/LocationServiceTest.java @@ -22,6 +22,8 @@ import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -38,6 +40,16 @@ public class LocationServiceTest { private LocationData mockGpsLocation; private LocationData mockNetworkLocation; + private static final List DEFAULT_SOURCES = Arrays.asList( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER + ); + + private static final List NETWORK_FIRST_SOURCES = Arrays.asList( + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER + ); + @Before public void setUp() { locationManager = mock(LocationManager.class); @@ -73,7 +85,7 @@ public void setUp() { public void getLocation_throwsSecurityException_whenNoPermissions() { when(permissionsManager.hasLocationPermissions()).thenReturn(false); - locationService.getLocation(RequestedAccuracy.HIGH, data -> { + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, data -> { }); } @@ -86,7 +98,7 @@ public void getLocation_usesOptimisedProvider_whenSupported() { stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); verify(optimisedProvider).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); verify(optimisedProvider).provide(eq(LocationManager.NETWORK_PROVIDER), anyInt(), any()); @@ -99,7 +111,7 @@ public void getLocation_optimised_highAccuracy_callsBothProvidersInParallel() { stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // Both providers should be called for HIGH accuracy (parallel) verify(optimisedProvider).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); @@ -113,7 +125,7 @@ public void getLocation_optimised_highAccuracy_selectsBestAccuracy() { stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // GPS has better accuracy (5.0f vs 50.0f), so it should be selected assertNotNull(result.get()); @@ -133,7 +145,7 @@ public void getLocation_optimised_highAccuracy_selectsNetworkWhenMoreAccurate() stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, accurateNetwork); // 3.0f accuracy AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // Network has better accuracy, so it should be selected assertEquals(LocationManager.NETWORK_PROVIDER, result.get().getSource()); @@ -145,9 +157,9 @@ public void getLocation_optimised_balanced_callsSequentially() { stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.BALANCED, result::set); + locationService.getLocation(RequestedAccuracy.BALANCED, NETWORK_FIRST_SOURCES, result::set); - // Network is first for BALANCED, returns successfully, so GPS shouldn't be called + // Network is first in sources, returns successfully, so GPS shouldn't be called verify(optimisedProvider).provide(eq(LocationManager.NETWORK_PROVIDER), anyInt(), any()); verify(optimisedProvider, never()).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); assertEquals(LocationManager.NETWORK_PROVIDER, result.get().getSource()); @@ -160,7 +172,7 @@ public void getLocation_optimised_balanced_fallsBackToGps_whenNetworkFails() { stubProviderToReturnForSource(optimisedProvider, LocationManager.GPS_PROVIDER, mockGpsLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.BALANCED, result::set); + locationService.getLocation(RequestedAccuracy.BALANCED, DEFAULT_SOURCES, result::set); assertEquals(LocationManager.GPS_PROVIDER, result.get().getSource()); } @@ -172,7 +184,7 @@ public void getLocation_optimised_highAccuracy_returnsNonNullWhenOneProviderFail stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // Even though GPS failed, network succeeded assertNotNull(result.get()); @@ -189,7 +201,7 @@ public void getLocation_usesLegacyProviders_whenOptimisedNotSupported() { stubProviderToReturn(legacyHighAccuracyProvider, mockGpsLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); verify(legacyHighAccuracyProvider).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); verify(optimisedProvider, never()).provide(any(), anyInt(), any()); @@ -204,7 +216,7 @@ public void getLocation_legacy_highAccuracy_callsAllProvidersInParallel() { stubProviderToReturnForSource(legacyCachedProvider, LocationManager.NETWORK_PROVIDER, null); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // All 4 providers should be called in parallel for HIGH accuracy verify(legacyHighAccuracyProvider).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); @@ -222,7 +234,7 @@ public void getLocation_legacy_highAccuracy_selectsBestAccuracy() { stubProviderToReturnForSource(legacyCachedProvider, LocationManager.NETWORK_PROVIDER, null); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); // GPS has better accuracy, should be selected assertNotNull(result.get()); @@ -235,7 +247,7 @@ public void getLocation_legacy_balanced_prioritizesCachedGps() { stubProviderToReturnForSource(legacyCachedProvider, LocationManager.GPS_PROVIDER, mockGpsLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.BALANCED, result::set); + locationService.getLocation(RequestedAccuracy.BALANCED, DEFAULT_SOURCES, result::set); verify(legacyCachedProvider).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); // High accuracy provider shouldn't be called since cached succeeded @@ -248,11 +260,13 @@ public void getLocation_legacy_balanced_fallsBackThroughProviders() { // All cached providers return null stubProviderToReturnForSource(legacyCachedProvider, LocationManager.GPS_PROVIDER, null); stubProviderToReturnForSource(legacyCachedProvider, LocationManager.NETWORK_PROVIDER, null); + // High accuracy GPS also returns null + stubProviderToReturnForSource(legacyHighAccuracyProvider, LocationManager.GPS_PROVIDER, null); // High accuracy network returns a result stubProviderToReturnForSource(legacyHighAccuracyProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.BALANCED, result::set); + locationService.getLocation(RequestedAccuracy.BALANCED, DEFAULT_SOURCES, result::set); assertEquals(LocationManager.NETWORK_PROVIDER, result.get().getSource()); } @@ -268,7 +282,7 @@ public void getLocation_skipsDisabledProvider_highAccuracy() { stubProviderToReturnForSource(optimisedProvider, LocationManager.NETWORK_PROVIDER, mockNetworkLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); verify(optimisedProvider, never()).provide(eq(LocationManager.GPS_PROVIDER), anyInt(), any()); assertEquals(LocationManager.NETWORK_PROVIDER, result.get().getSource()); @@ -281,7 +295,7 @@ public void getLocation_skipsDisabledProvider_balanced() { stubProviderToReturnForSource(optimisedProvider, LocationManager.GPS_PROVIDER, mockGpsLocation); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.BALANCED, result::set); + locationService.getLocation(RequestedAccuracy.BALANCED, DEFAULT_SOURCES, result::set); verify(optimisedProvider, never()).provide(eq(LocationManager.NETWORK_PROVIDER), anyInt(), any()); assertEquals(LocationManager.GPS_PROVIDER, result.get().getSource()); @@ -293,7 +307,7 @@ public void getLocation_returnsNull_whenAllProvidersFail() { stubProviderToReturn(optimisedProvider, null); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); assertNull(result.get()); } @@ -305,7 +319,7 @@ public void getLocation_returnsNull_whenAllProvidersDisabled() { when(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)).thenReturn(false); AtomicReference result = new AtomicReference<>(); - locationService.getLocation(RequestedAccuracy.HIGH, result::set); + locationService.getLocation(RequestedAccuracy.HIGH, DEFAULT_SOURCES, result::set); assertNull(result.get()); } diff --git a/server/.idea/scala_settings.xml b/server/.idea/scala_settings.xml index 1b970c73..7f02b866 100644 --- a/server/.idea/scala_settings.xml +++ b/server/.idea/scala_settings.xml @@ -3,5 +3,6 @@ \ No newline at end of file