From 1c95950002c13370db7c2ed3e579cf68d86c9b1d Mon Sep 17 00:00:00 2001 From: Dennis Arndt Date: Sun, 24 Sep 2023 12:59:51 +0200 Subject: [PATCH 001/154] improve LocationProvider, can handle COARSE_LOCATION, works with any Location Provider, improve readability and efficiency --- .../detection/LocationProvider.kt | 197 +++++++++--------- .../util/ble/OpportunisticBLEScanner.kt | 44 ++-- 2 files changed, 112 insertions(+), 129 deletions(-) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/LocationProvider.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/LocationProvider.kt index 35412bd4..fe5eb3a9 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/LocationProvider.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/LocationProvider.kt @@ -1,7 +1,6 @@ package de.seemoo.at_tracking_detection.detection import android.Manifest -import android.annotation.SuppressLint import android.content.pm.PackageManager import android.location.Location import android.location.LocationListener @@ -24,8 +23,16 @@ open class LocationProvider @Inject constructor( private val locationRequesters = ArrayList() - open fun getLastLocation(checkRequirements: Boolean = true): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + fun getLastLocation(checkRequirements: Boolean = true): Location? { + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } @@ -33,12 +40,19 @@ open class LocationProvider @Inject constructor( } /** - * Fetches the most recent location from network and gps and returns the one that has been recveived more recently + * Fetches the most recent location from network and gps and returns the one that has been received more recently * @return the most recent location across multiple providers */ - @SuppressLint("InlinedApi") // Suppressed, because we use a custom version provider which is injectable for testing private fun getLastLocationFromAnyProvider(checkRequirements: Boolean): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } @@ -61,53 +75,46 @@ open class LocationProvider @Inject constructor( } private fun legacyGetLastLocationFromAnyProvider(checkRequirements: Boolean): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } - // On older versions we use both providers to get the best location signal + // Get the last known locations from both providers val networkLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - - if (gpsLocation != null && networkLocation != null) { - // Got to past locations, lets check which passes our requirements - val gpsRequirements = locationMatchesMinimumRequirements(gpsLocation) - val networkRequirements = locationMatchesMinimumRequirements(networkLocation) - if (gpsRequirements && networkRequirements) { - // Check which one is more current - if (gpsLocation.time > networkLocation.time) { - return gpsLocation - }else { - return networkLocation - } - }else if (gpsRequirements) { - // Only GPS satisfies the requirements. Return it - return gpsLocation - }else if (networkRequirements) { - // Only network satisfies. Return it - return networkLocation - }else if (!checkRequirements) { - if (gpsLocation.time > networkLocation.time) { - return gpsLocation - } - return networkLocation - } - }else if (gpsLocation != null && locationMatchesMinimumRequirements(gpsLocation)) { - // Only gps satisfies and network does not exist - return gpsLocation + // If both locations are available, return the one that is more current and meets the minimum requirements + if (networkLocation != null && gpsLocation != null) { + val bestLocation = if (gpsLocation.time > networkLocation.time) gpsLocation else networkLocation + if (locationMatchesMinimumRequirements(bestLocation)) { + return bestLocation } } + // If only one location is available, return it if it meets the minimum requirements if (networkLocation != null && locationMatchesMinimumRequirements(networkLocation)) { return networkLocation - }else if (!checkRequirements) { - return networkLocation + } + if (gpsLocation != null && locationMatchesMinimumRequirements(gpsLocation)) { + return gpsLocation } - Timber.d("No last know location matched the requirements") - return null + // If neither location meets the minimum requirements, return null + if (checkRequirements) { + return null + } + + // If no location requirements are specified, return the last known location from either provider, or null if none are available + return networkLocation ?: gpsLocation } private fun getSecondsSinceLocation(location: Location): Long { @@ -130,27 +137,38 @@ open class LocationProvider @Inject constructor( * @param timeoutMillis: After the timeout the last location will be returned no matter if it matches the requirements or not * @return the last known location if this already satisfies our requirements */ - @SuppressLint("InlinedApi") // Suppressed, because we use a custom version provider which is injectable for testing - open fun lastKnownOrRequestLocationUpdates(locationRequester: LocationRequester, timeoutMillis: Long?): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + open fun lastKnownOrRequestLocationUpdates( + locationRequester: LocationRequester, + timeoutMillis: Long? = null + ): Location? { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED) { return null } + // Get the last known location val lastLocation = getLastLocation() + + // If the last location is available and meets the minimum requirements, return it if (lastLocation != null && locationMatchesMinimumRequirements(lastLocation)) { return lastLocation } + // Add the location requester to the list of active requesters this.locationRequesters.add(locationRequester) - // The fused location provider does not work reliably with Samsung + Android 12 - // We just stay with the legacy location, because this just works + // Request location updates from all enabled providers requestLocationUpdatesFromAnyProvider() + // If a timeout is specified, set a timeout for the location update if (timeoutMillis != null) { - setTimeoutForLocationUpdate(requester = locationRequester, timeoutMillis= timeoutMillis) + setTimeoutForLocationUpdate(requester = locationRequester, timeoutMillis = timeoutMillis) } + // Return null, since we don't have a location immediately available return null } @@ -162,45 +180,58 @@ open class LocationProvider @Inject constructor( * @param timeoutMillis milliseconds after which the timeout will be executed */ private fun setTimeoutForLocationUpdate(requester: LocationRequester, timeoutMillis: Long) { - val handler = Handler(Looper.getMainLooper()) - - val runnable = kotlinx.coroutines.Runnable { - if (this@LocationProvider.locationRequesters.size == 0) { - // The location was already returned + // Create a runnable to handle the timeout + val runnable = Runnable { + // If the location requester list is empty, the location has already been returned + if (this@LocationProvider.locationRequesters.isEmpty()) { return@Runnable } + // Log the timeout and get the last known location, regardless of whether it meets the requirements Timber.d("Location request timed out") val lastLocation = this@LocationProvider.getLastLocation(checkRequirements = false) + + // If the last location is available, notify the requester lastLocation?.let { - requester.receivedAccurateLocationUpdate(location = lastLocation) + requester.receivedAccurateLocationUpdate(location = it) } + + // If there is only one requester left, stop location updates and clear the list if (this@LocationProvider.locationRequesters.size == 1) { this@LocationProvider.stopLocationUpdates() this@LocationProvider.locationRequesters.clear() - }else { + } else { + // Otherwise, remove the requester from the list this@LocationProvider.locationRequesters.remove(requester) } } + // Schedule the runnable to be executed after the timeout period + val handler = Handler(Looper.getMainLooper()) handler.postDelayed(runnable, timeoutMillis) + + // Log the timeout settings Timber.d("Location request timeout set to $timeoutMillis") } - private fun requestLocationUpdatesFromAnyProvider() { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return } - Timber.d("Requesting location updates") - val gpsProviderEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) - val networkProviderEnabled = - locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + // Get the list of enabled location providers + val enabledProviders = locationManager.allProviders + .filter { locationManager.isProviderEnabled(it) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.isProviderEnabled(LocationManager.FUSED_PROVIDER)) { + // Request location updates from all enabled providers + enabledProviders.forEach { locationManager.requestLocationUpdates( - LocationManager.FUSED_PROVIDER, + it, MIN_UPDATE_TIME_MS, MIN_DISTANCE_METER, this, @@ -208,43 +239,14 @@ open class LocationProvider @Inject constructor( ) } - if (networkProviderEnabled) { - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - MIN_UPDATE_TIME_MS, - MIN_DISTANCE_METER, - this, - handler.looper - ) - } - - if (gpsProviderEnabled) { - // Using GPS and Network provider, because the GPS provider does notwork indoors (it will never call the callback) - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - MIN_UPDATE_TIME_MS, - MIN_DISTANCE_METER, - this, - handler.looper - ) - } - - if (!networkProviderEnabled && !gpsProviderEnabled) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (!locationManager.isProviderEnabled(LocationManager.FUSED_PROVIDER)) { - // Error - Timber.e("ERROR: No location provider available") - stopLocationUpdates() - } - }else { - //Error - Timber.e("ERROR: No location provider available") - stopLocationUpdates() - } + // If no location providers are enabled, log an error and stop location updates + if (enabledProviders.isEmpty()) { + Timber.e("ERROR: No location provider available") + stopLocationUpdates() } } - fun stopLocationUpdates() { + private fun stopLocationUpdates() { locationManager.removeUpdates(this) } @@ -274,9 +276,6 @@ open class LocationProvider @Inject constructor( } } - @Deprecated("Deprecated in Java") - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - // Android Phones with SDK < 30 need these methods override fun onProviderEnabled(provider: String) {} diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/OpportunisticBLEScanner.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/OpportunisticBLEScanner.kt index 570015ef..f9f3d5c8 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/OpportunisticBLEScanner.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/OpportunisticBLEScanner.kt @@ -8,7 +8,6 @@ import android.bluetooth.le.ScanSettings import android.content.Context import android.location.Location import android.location.LocationManager -import android.os.Build import android.os.SystemClock import androidx.core.content.getSystemService import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication @@ -17,7 +16,6 @@ import de.seemoo.at_tracking_detection.database.models.device.DeviceManager import de.seemoo.at_tracking_detection.detection.LocationProvider import de.seemoo.at_tracking_detection.detection.LocationRequester import de.seemoo.at_tracking_detection.notifications.NotificationService -import de.seemoo.at_tracking_detection.util.DefaultBuildVersionProvider import de.seemoo.at_tracking_detection.util.SharedPrefs import de.seemoo.at_tracking_detection.util.Utility import timber.log.Timber @@ -39,7 +37,7 @@ class OpportunisticBLEScanner(var notificationService: NotificationService?) { val context = ATTrackingDetectionApplication.getAppContext() val locationManager = context.getSystemService() if (locationManager != null) { - locationProvider = LocationProvider(locationManager, DefaultBuildVersionProvider()) + locationProvider = LocationProvider(locationManager) } } @@ -78,27 +76,13 @@ class OpportunisticBLEScanner(var notificationService: NotificationService?) { this.bluetoothAdapter?.bluetoothLeScanner?.let { BLEScanCallback.stopScanning(it) } } - fun scanSettings(): ScanSettings? { - if (Build.VERSION.SDK_INT >= 23) { - if (Build.VERSION.SDK_INT >= 26) { - val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) - .setLegacy(true) - .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - .setReportDelay(0) - .build() - return scanSettings - } - - val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) - .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) -// .setReportDelay(0) - .build() - return scanSettings - } - - return null + private fun scanSettings(): ScanSettings? { + return ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) + .setLegacy(true) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setReportDelay(0) + .build() } private val leScanCallback: ScanCallback = object : ScanCallback() { @@ -108,10 +92,10 @@ class OpportunisticBLEScanner(var notificationService: NotificationService?) { if (SharedPrefs.isScanningInBackground) { Timber.d("Scan received during background scan") }else { - Timber.d("Scan outside of background scan ${scanResult}") + Timber.d("Scan outside of background scan $scanResult") scanResult.timestampNanos - val milisecondsSinceEvent = (SystemClock.elapsedRealtimeNanos() - scanResult.timestampNanos) / 1000000L - val timeOfEvent = System.currentTimeMillis() - milisecondsSinceEvent + val millisecondsSinceEvent = (SystemClock.elapsedRealtimeNanos() - scanResult.timestampNanos) / 1000000L + val timeOfEvent = System.currentTimeMillis() - millisecondsSinceEvent val eventDate = Instant.ofEpochMilli(timeOfEvent).atZone(ZoneId.systemDefault()).toLocalDateTime() Timber.d("Scan received at ${eventDate.toString()}") if (BuildConfig.DEBUG) { @@ -140,8 +124,8 @@ class OpportunisticBLEScanner(var notificationService: NotificationService?) { if (lastLocation != null) { val millisecondsSinceLocation = (SystemClock.elapsedRealtimeNanos() - lastLocation.elapsedRealtimeNanos) / 1000000L - val timeOfLocationevent = System.currentTimeMillis() - millisecondsSinceLocation - val locationDate = Instant.ofEpochMilli(timeOfLocationevent).atZone(ZoneId.systemDefault()).toLocalDateTime() + val timeOfLocationEvent = System.currentTimeMillis() - millisecondsSinceLocation + val locationDate = Instant.ofEpochMilli(timeOfLocationEvent).atZone(ZoneId.systemDefault()).toLocalDateTime() val timeDiff = ChronoUnit.SECONDS.between(locationDate, LocalDateTime.now()) if (timeDiff <= LocationProvider.MAX_AGE_SECONDS && lastLocation.accuracy <= LocationProvider.MIN_ACCURACY_METER) { @@ -166,7 +150,7 @@ class OpportunisticBLEScanner(var notificationService: NotificationService?) { } } - fun fetchCurrentLocation() { + private fun fetchCurrentLocation() { //Getting the most accurate location here isUpdatingLocation = true val loc = locationProvider?.lastKnownOrRequestLocationUpdates(locationRequester, 45_000L) From 50fc255140ba38775b19b0767dbb41d7dd9a4884 Mon Sep 17 00:00:00 2001 From: Dennis Arndt Date: Wed, 27 Sep 2023 11:16:42 +0200 Subject: [PATCH 002/154] small improvements to map rendering --- .../ui/dashboard/DeviceMapFragment.kt | 3 --- .../ui/tracking/TrackingFragment.kt | 18 +++++++++--------- app/src/main/res/layout/fragment_tracking.xml | 5 +---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt index 1463b427..7c4e7226 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt @@ -98,8 +98,5 @@ class DeviceMapFragment : Fragment() { } } } - - } - } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt index 064e77a0..fe063e7b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt @@ -55,14 +55,14 @@ class TrackingFragment : Fragment() { binding.lifecycleOwner = viewLifecycleOwner binding.vm = trackingViewModel - val notifId = safeArgs.notificationId + val notificationId = safeArgs.notificationId // This is called deviceAddress but contains the ID val deviceAddress = safeArgs.deviceAddress - trackingViewModel.notificationId.postValue(notifId) + trackingViewModel.notificationId.postValue(notificationId) trackingViewModel.deviceAddress.postValue(deviceAddress) trackingViewModel.loadDevice(safeArgs.deviceAddress) trackingViewModel.notificationId.observe(viewLifecycleOwner) { - notificationId = it + this.notificationId = it } sharedElementEnterTransition = @@ -106,12 +106,12 @@ class TrackingFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val feedbackButton = view.findViewById(R.id.tracking_feedback) - val playSoundCard = view.findViewById(R.id.tracking_play_sound) - val trackingDetailButton = view.findViewById(R.id.tracking_detail_scan) + val feedbackButton: CardView = view.findViewById(R.id.tracking_feedback) + val playSoundCard: CardView = view.findViewById(R.id.tracking_play_sound) + val trackingDetailButton: CardView = view.findViewById(R.id.tracking_detail_scan) // TODO: include when finished - // val observeTrackerButton = view.findViewById(R.id.tracking_observation) - val map = view.findViewById(R.id.map) + // val observeTrackerButton: CardView = view.findViewById(R.id.tracking_observation) + val map: MapView = view.findViewById(R.id.map) feedbackButton.setOnClickListener { val directions: NavDirections = @@ -190,7 +190,7 @@ class TrackingFragment : Fragment() { addInteractions(view) } - fun addInteractions(view: View) { + private fun addInteractions(view: View) { val button = view.findViewById(R.id.open_map_button) diff --git a/app/src/main/res/layout/fragment_tracking.xml b/app/src/main/res/layout/fragment_tracking.xml index 87c1f4ac..d2656377 100644 --- a/app/src/main/res/layout/fragment_tracking.xml +++ b/app/src/main/res/layout/fragment_tracking.xml @@ -71,8 +71,6 @@ - - + app:layout_constraintTop_toBottomOf="@id/tracking_tiles" /> \ No newline at end of file From 5cca04d4054667f86988f7ef199d44b04a5dfc0f Mon Sep 17 00:00:00 2001 From: Dennis Arndt Date: Wed, 27 Sep 2023 11:21:25 +0200 Subject: [PATCH 003/154] make ObserveTracker functionality visible again --- .../ui/tracking/TrackingFragment.kt | 16 ++++------ app/src/main/res/layout/fragment_tracking.xml | 32 +++++++++---------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt index fe063e7b..83332cba 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingFragment.kt @@ -109,8 +109,7 @@ class TrackingFragment : Fragment() { val feedbackButton: CardView = view.findViewById(R.id.tracking_feedback) val playSoundCard: CardView = view.findViewById(R.id.tracking_play_sound) val trackingDetailButton: CardView = view.findViewById(R.id.tracking_detail_scan) - // TODO: include when finished - // val observeTrackerButton: CardView = view.findViewById(R.id.tracking_observation) + val observeTrackerButton: CardView = view.findViewById(R.id.tracking_observation) val map: MapView = view.findViewById(R.id.map) feedbackButton.setOnClickListener { @@ -126,13 +125,12 @@ class TrackingFragment : Fragment() { findNavController().navigate(directions) } - // TODO: include when finished -// observeTrackerButton.setOnClickListener { -// val deviceAddress: String = trackingViewModel.deviceAddress.value ?: return@setOnClickListener -// val directions: NavDirections = -// TrackingFragmentDirections.actionTrackingToObserveTracker(deviceAddress) -// findNavController().navigate(directions) -// } + observeTrackerButton.setOnClickListener { + val deviceAddress: String = trackingViewModel.deviceAddress.value ?: return@setOnClickListener + val directions: NavDirections = + TrackingFragmentDirections.actionTrackingToObserveTracker(deviceAddress) + findNavController().navigate(directions) + } playSoundCard.setOnClickListener { if (!Utility.checkAndRequestPermission(android.Manifest.permission.BLUETOOTH_CONNECT)) { diff --git a/app/src/main/res/layout/fragment_tracking.xml b/app/src/main/res/layout/fragment_tracking.xml index d2656377..40ff5600 100644 --- a/app/src/main/res/layout/fragment_tracking.xml +++ b/app/src/main/res/layout/fragment_tracking.xml @@ -223,22 +223,22 @@ bind:title="@{@string/tracking_detail_scan_title}" bind:vm="@{vm}" /> - - - - - - - - - - - - - - - - + From 76b2c50391e31fdc2007f797bde4c04b20078d21 Mon Sep 17 00:00:00 2001 From: Dennis Arndt Date: Wed, 27 Sep 2023 11:32:10 +0200 Subject: [PATCH 004/154] improve Observation Screen Design --- .../main/res/layout/fragment_observe_tracker.xml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/fragment_observe_tracker.xml b/app/src/main/res/layout/fragment_observe_tracker.xml index bf7421b5..3a0c4efe 100644 --- a/app/src/main/res/layout/fragment_observe_tracker.xml +++ b/app/src/main/res/layout/fragment_observe_tracker.xml @@ -13,10 +13,12 @@ + + + + + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + bind:layout_constraintBottom_toTopOf="@+id/start_observation_button" /> +