diff --git a/README.md b/README.md index 5e38c80..b8d3493 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ Key goal: appliance-style operation — once a preferred device is set, a foregr ### Dashboard - **Real-time charts** with zoom, pan, and configurable time windows (30s to 1h) +- **Live Map View** - GPS-tracked radiation readings on interactive map + - Color-coded markers (green → yellow → red gradient) + - Switch between dose rate and count rate display + - Dynamic legend showing min/max values + - Reset button to clear readings + - Persistent storage of readings with GPS coordinates - **Delta cards** showing change rates with statistical sparklines - **Spike detection** with configurable markers and percentage annotations - **Statistical analysis** with z-score highlighting and trend indicators diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa01c9a..81f8bde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.radiacode.ble" minSdk = 26 targetSdk = 34 - versionCode = 29 - versionName = "0.41" + versionCode = 30 + versionName = "0.42" } buildTypes { @@ -39,4 +39,6 @@ dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") + implementation("com.google.android.gms:play-services-maps:18.2.0") + implementation("com.google.android.gms:play-services-location:21.1.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f55cb1f..67322ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,8 @@ - + + + + + get() { - val perms = ArrayList(3) + val perms = ArrayList(5) + // Location permissions (always needed for map) + perms += Manifest.permission.ACCESS_FINE_LOCATION + perms += Manifest.permission.ACCESS_COARSE_LOCATION + if (Build.VERSION.SDK_INT >= 31) { perms += Manifest.permission.BLUETOOTH_SCAN perms += Manifest.permission.BLUETOOTH_CONNECT - } else { - perms += Manifest.permission.ACCESS_FINE_LOCATION } if (Build.VERSION.SDK_INT >= 33) { perms += Manifest.permission.POST_NOTIFICATIONS @@ -348,6 +356,7 @@ class MainActivity : AppCompatActivity() { setupLogsPanel() setupMetricCards() setupCharts() + setupMapCard(savedInstanceState) setupIsotopePanel() setupToolbarDeviceSelector() @@ -399,6 +408,9 @@ class MainActivity : AppCompatActivity() { cpsChartGoRealtime = findViewById(R.id.cpsChartGoRealtime) cpsStats = findViewById(R.id.cpsStats) + // Live Map + mapCard = findViewById(R.id.mapCard) + // Isotope detection panel isotopePanel = findViewById(R.id.isotopePanel) isotopeChartTitle = findViewById(R.id.isotopeChartTitle) @@ -777,6 +789,36 @@ class MainActivity : AppCompatActivity() { }) } + private fun setupMapCard(savedInstanceState: Bundle?) { + // Initialize map + mapCard.onCreate(savedInstanceState) + + // Setup reset callback + mapCard.onResetClickListener = { + val selectedDeviceId = Prefs.getSelectedDeviceId(this) + if (selectedDeviceId != null) { + Prefs.clearMapReadings(this, selectedDeviceId) + } + } + + // Load existing map readings for selected device + val selectedDeviceId = Prefs.getSelectedDeviceId(this) + if (selectedDeviceId != null) { + val mapReadings = Prefs.getMapReadings(this, selectedDeviceId) + for (reading in mapReadings) { + mapCard.addReading( + com.radiacode.ble.ui.MapCardView.MapReading( + latitude = reading.latitude, + longitude = reading.longitude, + doseRate = reading.uSvPerHour, + countRate = reading.cps, + timestampMs = reading.timestampMs + ) + ) + } + } + } + private fun setupIsotopePanel() { // Initialize the isotope detector with enabled isotopes val enabledIsotopes = Prefs.getEnabledIsotopes(this) @@ -1326,6 +1368,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + mapCard.onResume() registerReadingReceiver() reloadChartHistoryForSelectedDevice() startUiLoop() @@ -1375,15 +1418,37 @@ class MainActivity : AppCompatActivity() { override fun onPause() { super.onPause() + mapCard.onPause() unregisterReadingReceiver() stopUiLoop() } override fun onDestroy() { super.onDestroy() + mapCard.onDestroy() stopUiLoop() } + override fun onStart() { + super.onStart() + mapCard.onStart() + } + + override fun onStop() { + super.onStop() + mapCard.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + mapCard.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapCard.onSaveInstanceState(outState) + } + private fun hasAllPermissions(): Boolean { return requiredPermissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED @@ -1629,6 +1694,30 @@ class MainActivity : AppCompatActivity() { doseHistory.add(last.timestampMs, last.uSvPerHour) cpsHistory.add(last.timestampMs, last.cps) sampleCount++ + + // Add reading to map with GPS location + if (selectedDeviceId != null) { + mapCard.addReadingAtCurrentLocation(last.uSvPerHour, last.cps, last.timestampMs) + + // Store map reading with GPS to Prefs (for persistence) + if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + ) { + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(this@MainActivity) + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + location?.let { + val mapReading = Prefs.MapReading( + latitude = it.latitude, + longitude = it.longitude, + uSvPerHour = last.uSvPerHour, + cps = last.cps, + timestampMs = last.timestampMs + ) + Prefs.addMapReading(this@MainActivity, selectedDeviceId, mapReading) + } + } + } + } } if (paused && (pausedSnapshotDose == null || pausedSnapshotCps == null)) { diff --git a/app/src/main/java/com/radiacode/ble/Prefs.kt b/app/src/main/java/com/radiacode/ble/Prefs.kt index e1cc7c5..41de249 100644 --- a/app/src/main/java/com/radiacode/ble/Prefs.kt +++ b/app/src/main/java/com/radiacode/ble/Prefs.kt @@ -1291,4 +1291,77 @@ object Prefs { .putBoolean(KEY_ISOTOPE_HIDE_BACKGROUND, hide) .apply() } + + // ===== Map Readings with GPS ===== + + private const val KEY_MAP_READINGS_PREFIX = "map_readings_" + private const val MAX_MAP_READINGS = 1000 + + /** + * Reading with GPS coordinates for map display. + */ + data class MapReading( + val latitude: Double, + val longitude: Double, + val uSvPerHour: Float, + val cps: Float, + val timestampMs: Long + ) + + /** + * Add a map reading with GPS coordinates for a specific device. + */ + fun addMapReading(context: Context, deviceId: String, reading: MapReading) { + val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) + val key = KEY_MAP_READINGS_PREFIX + deviceId + val existing = prefs.getString(key, "") ?: "" + + val newEntry = "${reading.timestampMs},${reading.latitude},${reading.longitude},${reading.uSvPerHour},${reading.cps}" + + val entries = existing.split(";").filter { it.isNotBlank() }.toMutableList() + entries.add(newEntry) + while (entries.size > MAX_MAP_READINGS) { + entries.removeAt(0) + } + + prefs.edit() + .putString(key, entries.joinToString(";")) + .apply() + } + + /** + * Get map readings for a specific device. + */ + fun getMapReadings(context: Context, deviceId: String): List { + val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) + val raw = prefs.getString(KEY_MAP_READINGS_PREFIX + deviceId, "") ?: "" + if (raw.isBlank()) return emptyList() + + return raw.split(";") + .filter { it.isNotBlank() } + .mapNotNull { entry -> + val parts = entry.split(",") + if (parts.size == 5) { + try { + MapReading( + timestampMs = parts[0].toLong(), + latitude = parts[1].toDouble(), + longitude = parts[2].toDouble(), + uSvPerHour = parts[3].toFloat(), + cps = parts[4].toFloat() + ) + } catch (_: Exception) { null } + } else null + } + } + + /** + * Clear map readings for a specific device. + */ + fun clearMapReadings(context: Context, deviceId: String) { + context.getSharedPreferences(FILE, Context.MODE_PRIVATE) + .edit() + .remove(KEY_MAP_READINGS_PREFIX + deviceId) + .apply() + } } diff --git a/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt b/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt new file mode 100644 index 0000000..ba6efae --- /dev/null +++ b/app/src/main/java/com/radiacode/ble/ui/MapCardView.kt @@ -0,0 +1,335 @@ +package com.radiacode.ble.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.* +import android.location.Location +import android.util.AttributeSet +import android.view.View +import android.widget.* +import androidx.core.content.ContextCompat +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.model.* +import com.radiacode.ble.R +import java.util.Locale + +/** + * Live map card view that shows radiation readings on a map. + * Features: + * - Google Maps integration with user location + * - Metric selector (dose rate / count rate) + * - Color-coded dots based on readings + * - Dynamic legend showing min/max values + * - Reset button to clear readings + */ +class MapCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + // Data class for map readings + data class MapReading( + val latitude: Double, + val longitude: Double, + val doseRate: Float, // μSv/h + val countRate: Float, // cps + val timestampMs: Long + ) + + enum class MetricType { + DOSE_RATE, + COUNT_RATE + } + + // Views + private val containerLayout: LinearLayout + private val headerLayout: LinearLayout + private val titleText: TextView + private val metricSelector: Spinner + private val resetButton: ImageButton + private val contentLayout: LinearLayout + private val mapView: MapView + private val legendLayout: LinearLayout + private val legendBar: View + private val legendMinLabel: TextView + private val legendMaxLabel: TextView + + // Map and location + private var googleMap: GoogleMap? = null + private val fusedLocationClient: FusedLocationProviderClient + private var currentLocation: Location? = null + private val DEFAULT_ZOOM = 16f + + // Data + private val readings = mutableListOf() + private val markers = mutableListOf() + private var currentMetric = MetricType.DOSE_RATE + private var minValue = 0f + private var maxValue = 0f + private var hasInitialLocation = false + + // Callbacks + var onResetClickListener: (() -> Unit)? = null + + init { + // Inflate the card layout + inflate(context, R.layout.view_map_card, this) + + // Bind views + containerLayout = findViewById(R.id.mapCardContainer) + headerLayout = findViewById(R.id.mapCardHeader) + titleText = findViewById(R.id.mapCardTitle) + metricSelector = findViewById(R.id.mapMetricSelector) + resetButton = findViewById(R.id.mapResetButton) + contentLayout = findViewById(R.id.mapCardContent) + mapView = findViewById(R.id.mapView) + legendLayout = findViewById(R.id.mapLegendLayout) + legendBar = findViewById(R.id.mapLegendBar) + legendMinLabel = findViewById(R.id.mapLegendMin) + legendMaxLabel = findViewById(R.id.mapLegendMax) + + // Setup location client + fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + // Setup metric selector + setupMetricSelector() + + // Setup reset button + resetButton.setOnClickListener { + clearReadings() + onResetClickListener?.invoke() + } + + // Initialize title + titleText.text = "LIVE MAP" + } + + private fun setupMetricSelector() { + val adapter = ArrayAdapter( + context, + android.R.layout.simple_spinner_item, + arrayOf("Dose Rate", "Count Rate") + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + metricSelector.adapter = adapter + + metricSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + currentMetric = if (position == 0) MetricType.DOSE_RATE else MetricType.COUNT_RATE + updateMarkersAndLegend() + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + /** + * Initialize the map. Should be called from Activity lifecycle methods. + */ + fun onCreate(savedInstanceState: android.os.Bundle?) { + mapView.onCreate(savedInstanceState) + mapView.getMapAsync { map -> + googleMap = map + setupMap(map) + getCurrentLocation() + } + } + + private fun setupMap(map: GoogleMap) { + map.uiSettings.apply { + isZoomControlsEnabled = true + isZoomGesturesEnabled = true + isScrollGesturesEnabled = true + isRotateGesturesEnabled = false + isTiltGesturesEnabled = false + } + + // Enable location if permission granted + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + ) { + map.isMyLocationEnabled = true + } + + // Set map style to dark theme + try { + map.setMapStyle(MapStyleOptions.loadRawResourceStyle(context, R.raw.map_style_dark)) + } catch (_: Exception) { + // Dark style not available, use default + } + } + + private fun getCurrentLocation() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + ) { + return + } + + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + location?.let { + currentLocation = it + if (!hasInitialLocation) { + moveToLocation(it.latitude, it.longitude) + hasInitialLocation = true + } + } + } + } + + private fun moveToLocation(latitude: Double, longitude: Double) { + googleMap?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(latitude, longitude), + DEFAULT_ZOOM + ) + ) + } + + /** + * Add a new reading to the map. + */ + fun addReading(reading: MapReading) { + readings.add(reading) + updateMarkersAndLegend() + } + + /** + * Add a reading with current location (if available). + */ + fun addReadingAtCurrentLocation(doseRate: Float, countRate: Float, timestampMs: Long) { + currentLocation?.let { loc -> + addReading(MapReading(loc.latitude, loc.longitude, doseRate, countRate, timestampMs)) + } + } + + /** + * Clear all readings and markers from the map. + */ + fun clearReadings() { + readings.clear() + markers.forEach { it.remove() } + markers.clear() + minValue = 0f + maxValue = 0f + updateLegend() + } + + private fun updateMarkersAndLegend() { + // Remove existing markers + markers.forEach { it.remove() } + markers.clear() + + if (readings.isEmpty()) { + minValue = 0f + maxValue = 0f + updateLegend() + return + } + + // Calculate min/max for current metric + val values = readings.map { + when (currentMetric) { + MetricType.DOSE_RATE -> it.doseRate + MetricType.COUNT_RATE -> it.countRate + } + } + + minValue = values.minOrNull() ?: 0f + maxValue = values.maxOrNull() ?: 0f + + // Add markers + val map = googleMap ?: return + for (reading in readings) { + val value = when (currentMetric) { + MetricType.DOSE_RATE -> reading.doseRate + MetricType.COUNT_RATE -> reading.countRate + } + + val color = getColorForValue(value, minValue, maxValue) + val circle = map.addCircle( + CircleOptions() + .center(LatLng(reading.latitude, reading.longitude)) + .radius(10.0) // 10 meters + .fillColor(color) + .strokeColor(Color.WHITE) + .strokeWidth(2f) + ) + markers.add(circle) + } + + updateLegend() + } + + private fun getColorForValue(value: Float, min: Float, max: Float): Int { + if (max <= min) return Color.argb(180, 0, 255, 0) + + val normalized = ((value - min) / (max - min)).coerceIn(0f, 1f) + + // Color gradient: green (low) -> yellow (medium) -> red (high) + val red: Int + val green: Int + if (normalized < 0.5f) { + // Green to yellow + red = (normalized * 2f * 255).toInt() + green = 255 + } else { + // Yellow to red + red = 255 + green = ((1f - normalized) * 2f * 255).toInt() + } + + return Color.argb(180, red, green, 0) + } + + private fun updateLegend() { + val unit = when (currentMetric) { + MetricType.DOSE_RATE -> "μSv/h" + MetricType.COUNT_RATE -> "cps" + } + + if (maxValue > minValue) { + legendMinLabel.text = String.format(Locale.US, "%.2f %s", minValue, unit) + legendMaxLabel.text = String.format(Locale.US, "%.2f %s", maxValue, unit) + } else { + legendMinLabel.text = "0 $unit" + legendMaxLabel.text = "0 $unit" + } + } + + // Lifecycle methods to forward to MapView + fun onStart() { + mapView.onStart() + } + + fun onResume() { + mapView.onResume() + getCurrentLocation() // Refresh location on resume + } + + fun onPause() { + mapView.onPause() + } + + fun onStop() { + mapView.onStop() + } + + fun onDestroy() { + mapView.onDestroy() + } + + fun onLowMemory() { + mapView.onLowMemory() + } + + fun onSaveInstanceState(outState: android.os.Bundle) { + mapView.onSaveInstanceState(outState) + } +} diff --git a/app/src/main/res/drawable/legend_gradient.xml b/app/src/main/res/drawable/legend_gradient.xml new file mode 100644 index 0000000..f2ac46e --- /dev/null +++ b/app/src/main/res/drawable/legend_gradient.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c4793e5..c82135f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -646,6 +646,13 @@ android:layout_marginTop="8dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/raw/map_style_dark.json b/app/src/main/res/raw/map_style_dark.json new file mode 100644 index 0000000..c3b27fe --- /dev/null +++ b/app/src/main/res/raw/map_style_dark.json @@ -0,0 +1,80 @@ +[ + { + "elementType": "geometry", + "stylers": [ + { + "color": "#1a1a1e" + } + ] + }, + { + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#9e9ea8" + } + ] + }, + { + "elementType": "labels.text.stroke", + "stylers": [ + { + "color": "#0d0d0f" + } + ] + }, + { + "featureType": "administrative", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#2a2a2e" + } + ] + }, + { + "featureType": "poi", + "elementType": "geometry", + "stylers": [ + { + "color": "#242428" + } + ] + }, + { + "featureType": "poi.park", + "elementType": "geometry", + "stylers": [ + { + "color": "#1e3a1e" + } + ] + }, + { + "featureType": "road", + "elementType": "geometry", + "stylers": [ + { + "color": "#2a2a2e" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry", + "stylers": [ + { + "color": "#3a3a3e" + } + ] + }, + { + "featureType": "water", + "elementType": "geometry", + "stylers": [ + { + "color": "#0a0a1e" + } + ] + } +] diff --git a/docs/MAP_FEATURE.md b/docs/MAP_FEATURE.md new file mode 100644 index 0000000..0e54263 --- /dev/null +++ b/docs/MAP_FEATURE.md @@ -0,0 +1,175 @@ +# Live Map View Implementation + +This document describes the live map view feature implementation for the Open RadiaCode Android app. + +## Overview + +The live map view displays radiation readings on an interactive Google Map, allowing users to visualize spatial distribution of dose/count rate measurements as they collect data. + +## Components + +### 1. MapCardView (`ui/MapCardView.kt`) + +Custom Android view that integrates Google Maps with radiation data visualization. + +**Features:** +- Interactive Google Map with user location +- Color-coded markers for readings (green → yellow → red gradient) +- Metric selector: Dose Rate (μSv/h) or Count Rate (cps/cpm) +- Dynamic legend showing min/max values +- Reset button to clear all markers +- Dark theme map styling +- Persistent storage of readings with GPS coordinates + +**Key Methods:** +- `onCreate(Bundle)` - Initialize map view (call from Activity lifecycle) +- `addReading(MapReading)` - Add a reading with GPS coordinates +- `addReadingAtCurrentLocation(...)` - Add reading using current device location +- `clearReadings()` - Remove all markers from map +- `onResume()`, `onPause()`, etc. - Lifecycle methods to forward to MapView + +### 2. Data Storage (`Prefs.kt`) + +Extended Prefs object with map reading storage. + +**New Types:** +```kotlin +data class MapReading( + val latitude: Double, + val longitude: Double, + val uSvPerHour: Float, + val cps: Float, + val timestampMs: Long +) +``` + +**New Methods:** +- `addMapReading(Context, String, MapReading)` - Store reading with GPS +- `getMapReadings(Context, String): List` - Retrieve readings for device +- `clearMapReadings(Context, String)` - Clear all map readings for device + +Storage format: Semicolon-separated entries of `timestamp,lat,lon,dose,cps` + +### 3. Integration (`MainActivity.kt`) + +Map card integrated into dashboard panel. + +**Changes:** +- Added location permissions (ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) +- Added map card view binding +- Added `setupMapCard(Bundle)` initialization +- Added map lifecycle forwarding (onStart, onResume, onPause, onStop, etc.) +- Added automatic reading updates with GPS location in UI loop +- Readings automatically saved to map when GPS location is available + +## Layout Structure + +```xml + + +``` + +The card includes: +- Header with title, metric selector dropdown, and reset button +- Google MapView (300dp height by default) +- Legend bar with color gradient and min/max labels + +## Permissions + +Required permissions added to AndroidManifest.xml: +```xml + + +``` + +Google Maps API key: +```xml + +``` + +## Dependencies + +Added to `app/build.gradle.kts`: +```kotlin +implementation("com.google.android.gms:play-services-maps:18.2.0") +implementation("com.google.android.gms:play-services-location:21.1.0") +``` + +## Color Coding + +Readings are color-coded based on normalized value (min to max): +- **Green** (low): Values near minimum +- **Yellow** (medium): Mid-range values +- **Red** (high): Values near maximum + +Formula: +```kotlin +normalized = (value - min) / (max - min) +if (normalized < 0.5) { + // Green to yellow transition + red = normalized * 2 * 255 + green = 255 +} else { + // Yellow to red transition + red = 255 + green = (1 - normalized) * 2 * 255 +} +``` + +## Map Styling + +Dark theme map style applied via `map_style_dark.json` to match app design: +- Dark background (#1a1a1e) +- Muted labels and features +- Low-contrast roads and boundaries +- Dark water bodies + +## Usage Flow + +1. User launches app → Location permissions requested +2. App connects to RadiaCode device +3. New readings arrive → GPS location captured +4. Reading + GPS stored and displayed on map as colored circle +5. Legend updates to show current min/max range +6. User can switch between dose rate and count rate views +7. User can reset map to clear all markers +8. Readings persist across app restarts (per device) + +## Testing Checklist + +- [ ] Location permission prompt appears on first launch +- [ ] Map displays with user location +- [ ] Readings appear as colored circles when device connected +- [ ] Colors change appropriately based on value range +- [ ] Metric selector switches between dose/count rate +- [ ] Legend shows correct min/max values +- [ ] Reset button clears all markers +- [ ] Map retains readings after app restart +- [ ] Map works with multiple devices (separate data per device) +- [ ] Dark theme applied correctly + +## Known Limitations + +1. **API Key**: Uses unrestricted key suitable for development. For production, restrict key to app package and SHA-1 fingerprint. + +2. **GPS Accuracy**: Readings require GPS fix. Indoor or poor GPS conditions may result in missing/inaccurate locations. + +3. **Memory**: Large numbers of markers (>1000) may impact performance. Current limit: 1000 readings per device. + +4. **Network**: Map tiles require internet connectivity. Cached tiles used when offline. + +## Future Enhancements + +- [ ] Heatmap overlay option +- [ ] Export map as image +- [ ] Clustering for high-density areas +- [ ] Time-based filtering (show last hour/day/week) +- [ ] Route replay animation +- [ ] Offline map tile caching +- [ ] Custom marker shapes/sizes based on value +- [ ] Map bounds auto-adjustment to show all markers diff --git a/gradlew b/gradlew old mode 100644 new mode 100755