Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ android {
applicationId = "com.radiacode.ble"
minSdk = 26
targetSdk = 34
versionCode = 29
versionName = "0.41"
versionCode = 30
versionName = "0.42"
}

buildTypes {
Expand All @@ -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")
}
8 changes: 7 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
<!-- Legacy (Android 11 and below) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- Android 12+ -->
<uses-permission
Expand All @@ -29,6 +30,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.RadiaCode">

<!-- Google Maps API Key - Uses default unrestricted key for development -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyDUm7nN1Z8fM6fV3z4z9gR8q9aZ2V8E2Zo" />

<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
95 changes: 92 additions & 3 deletions app/src/main/java/com/radiacode/ble/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
Expand All @@ -23,6 +24,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
Expand Down Expand Up @@ -91,6 +94,9 @@ class MainActivity : AppCompatActivity() {
private lateinit var cpsChartGoRealtime: android.widget.ImageButton
private lateinit var cpsStats: StatRowView

// Dashboard - Live Map
private lateinit var mapCard: com.radiacode.ble.ui.MapCardView

// Isotope detection panel
private lateinit var isotopePanel: LinearLayout
private lateinit var isotopeChartTitle: TextView
Expand Down Expand Up @@ -321,12 +327,14 @@ class MainActivity : AppCompatActivity() {

private val requiredPermissions: Array<String>
get() {
val perms = ArrayList<String>(3)
val perms = ArrayList<String>(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
Expand All @@ -348,6 +356,7 @@ class MainActivity : AppCompatActivity() {
setupLogsPanel()
setupMetricCards()
setupCharts()
setupMapCard(savedInstanceState)
setupIsotopePanel()
setupToolbarDeviceSelector()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1326,6 +1368,7 @@ class MainActivity : AppCompatActivity() {

override fun onResume() {
super.onResume()
mapCard.onResume()
registerReadingReceiver()
reloadChartHistoryForSelectedDevice()
startUiLoop()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
73 changes: 73 additions & 0 deletions app/src/main/java/com/radiacode/ble/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<MapReading> {
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()
}
}
Loading