Skip to content

Commit 43e08c3

Browse files
committed
STDIN
1 feat: Phase 2 complete — wiring, map, agriculture, HUD, localization 2 3 23 files: 6 new + 17 modified, completing all Phase 2A-2E work. 4 5 Phase 2A — Critical Wiring: 6 - AppLifecycleManager: auto-detect mode, init video/MAVLink/WiFi/GS 7 - GcsViewModel: commands wired to MavLinkCommandSender 8 - GroundStationViewModel: wired to repository, 2s polling active 9 - VideoViewModel: wired to VideoStreamManager 10 - PermissionManager: location + notification checks 11 - VideoStreamManager: pause/resume lifecycle methods 12 13 Phase 2B — Map Integration: 14 - DroneMapView: dark compass-rose map with drone/home markers 15 - GcsScreen: placeholder replaced with real map view 16 - GcsViewModel: exposes position + homePosition StateFlow 17 18 Phase 2C — Agriculture Completion: 19 - FieldMapper: real FusedLocationProviderClient GPS tracking 20 - SprayConfigSheet: bottom sheet with crop/chemical/rate/alt/speed 21 - MissionGenerator: lawn-mower pattern from boundary polygon 22 - AgricultureViewModel: spray config + mission gen wired 23 - AgricultureScreen: spray sheet + mission summary dialog 24 25 Phase 2D — HUD + Ground Station Fixes: 26 - HudOverlay: haversine distance-to-home, conditional rendering 27 - HomePosition: TelemetryStore field + MavLinkParser handler 28 - GroundStationScreen: recording timer, SoC temp with color 29 30 Phase 2E — Localization: 31 - strings.xml: 155 strings covering all screens 32 - values-hi/strings.xml: ~95 Hindi translations (agriculture focus)
1 parent 69a9c21 commit 43e08c3

23 files changed

Lines changed: 1471 additions & 89 deletions

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ dependencies {
7373
// Core
7474
implementation("androidx.core:core-ktx:1.15.0")
7575
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
76+
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
7677

7778
// Hilt
7879
implementation("com.google.dagger:hilt-android:2.51.1")

app/src/main/java/com/altnautica/gcs/ADOSApplication.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ package com.altnautica.gcs
22

33
import android.app.Application
44
import dagger.hilt.android.HiltAndroidApp
5+
import javax.inject.Inject
56

67
@HiltAndroidApp
7-
class ADOSApplication : Application()
8+
class ADOSApplication : Application() {
9+
10+
@Inject lateinit var lifecycleManager: AppLifecycleManager
11+
12+
override fun onCreate() {
13+
super.onCreate()
14+
lifecycleManager.initialize()
15+
}
16+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.altnautica.gcs
2+
3+
import android.app.Application
4+
import androidx.lifecycle.DefaultLifecycleObserver
5+
import androidx.lifecycle.LifecycleOwner
6+
import androidx.lifecycle.ProcessLifecycleOwner
7+
import com.altnautica.gcs.data.mavlink.MavLinkRepository
8+
import com.altnautica.gcs.data.video.ModeDetector
9+
import com.altnautica.gcs.data.video.VideoMode
10+
import com.altnautica.gcs.data.video.VideoStreamManager
11+
import com.altnautica.gcs.data.wifi.WifiConnectionManager
12+
import com.altnautica.gcs.data.groundstation.GroundStationRepository
13+
import kotlinx.coroutines.*
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
17+
@Singleton
18+
class AppLifecycleManager @Inject constructor(
19+
private val modeDetector: ModeDetector,
20+
private val wifiManager: WifiConnectionManager,
21+
private val mavLinkRepository: MavLinkRepository,
22+
private val videoStreamManager: VideoStreamManager,
23+
private val groundStationRepository: GroundStationRepository,
24+
) : DefaultLifecycleObserver {
25+
26+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
27+
28+
fun initialize() {
29+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
30+
scope.launch {
31+
val mode = modeDetector.detect()
32+
if (mode is VideoMode.GroundStation) {
33+
wifiManager.requestGroundStationNetwork()
34+
groundStationRepository.startPolling()
35+
}
36+
mavLinkRepository.connect()
37+
}
38+
}
39+
40+
override fun onStop(owner: LifecycleOwner) {
41+
// App going to background — pause video, keep MAVLink alive
42+
videoStreamManager.pause()
43+
}
44+
45+
override fun onStart(owner: LifecycleOwner) {
46+
// App coming to foreground — resume video
47+
videoStreamManager.resume()
48+
}
49+
50+
fun shutdown() {
51+
scope.cancel()
52+
videoStreamManager.stop()
53+
mavLinkRepository.disconnect()
54+
wifiManager.releaseNetwork()
55+
groundStationRepository.stopPolling()
56+
}
57+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package com.altnautica.gcs.data.agriculture
2+
3+
import com.altnautica.gcs.ui.agriculture.LatLon
4+
import com.altnautica.gcs.ui.agriculture.SprayConfig
5+
import kotlin.math.abs
6+
import kotlin.math.cos
7+
import kotlin.math.max
8+
import kotlin.math.min
9+
10+
data class Waypoint(val lat: Double, val lon: Double, val alt: Float, val speed: Float)
11+
12+
/**
13+
* Generates a lawn-mower spray pattern from a field boundary polygon
14+
* and spray configuration. The pattern consists of parallel passes
15+
* spaced at swathWidth intervals, alternating direction on each row.
16+
*/
17+
object MissionGenerator {
18+
19+
private const val METERS_PER_DEG_LAT = 111_320.0
20+
21+
fun generateSprayMission(
22+
boundary: List<LatLon>,
23+
config: SprayConfig,
24+
): List<Waypoint> {
25+
if (boundary.size < 3) return emptyList()
26+
27+
val minLat = boundary.minOf { it.lat }
28+
val maxLat = boundary.maxOf { it.lat }
29+
val minLon = boundary.minOf { it.lon }
30+
val maxLon = boundary.maxOf { it.lon }
31+
32+
val refLat = (minLat + maxLat) / 2.0
33+
val metersPerDegLon = METERS_PER_DEG_LAT * cos(Math.toRadians(refLat))
34+
35+
val latSpanM = (maxLat - minLat) * METERS_PER_DEG_LAT
36+
val lonSpanM = (maxLon - minLon) * metersPerDegLon
37+
38+
val waypoints = mutableListOf<Waypoint>()
39+
40+
// Takeoff: first boundary point at configured altitude
41+
waypoints.add(
42+
Waypoint(
43+
lat = boundary[0].lat,
44+
lon = boundary[0].lon,
45+
alt = config.altitude,
46+
speed = config.speed,
47+
)
48+
)
49+
50+
// Determine sweep axis: sweep along the longer dimension
51+
val sweepAlongLon = lonSpanM >= latSpanM
52+
val swathDeg: Double
53+
val sweepLines: Int
54+
55+
if (sweepAlongLon) {
56+
// Lines run east-west, step north-south
57+
swathDeg = config.swathWidth / METERS_PER_DEG_LAT
58+
sweepLines = max(1, (latSpanM / config.swathWidth).toInt())
59+
60+
for (i in 0..sweepLines) {
61+
val lat = minLat + i * swathDeg
62+
if (lat > maxLat) break
63+
64+
// Find lon intersection with bounding box (simplified)
65+
val startLon: Double
66+
val endLon: Double
67+
if (i % 2 == 0) {
68+
startLon = minLon
69+
endLon = maxLon
70+
} else {
71+
startLon = maxLon
72+
endLon = minLon
73+
}
74+
75+
// Clip to polygon using ray-cast intersection with boundary edges
76+
val clipped = clipLineToBoundary(lat, minLon, maxLon, boundary)
77+
if (clipped != null) {
78+
val (cMinLon, cMaxLon) = clipped
79+
val wp1Lon = if (i % 2 == 0) cMinLon else cMaxLon
80+
val wp2Lon = if (i % 2 == 0) cMaxLon else cMinLon
81+
waypoints.add(Waypoint(lat, wp1Lon, config.altitude, config.speed))
82+
waypoints.add(Waypoint(lat, wp2Lon, config.altitude, config.speed))
83+
}
84+
}
85+
} else {
86+
// Lines run north-south, step east-west
87+
swathDeg = config.swathWidth / metersPerDegLon
88+
sweepLines = max(1, (lonSpanM / config.swathWidth).toInt())
89+
90+
for (i in 0..sweepLines) {
91+
val lon = minLon + i * swathDeg
92+
if (lon > maxLon) break
93+
94+
val clipped = clipVerticalLineToBoundary(lon, minLat, maxLat, boundary)
95+
if (clipped != null) {
96+
val (cMinLat, cMaxLat) = clipped
97+
val wp1Lat = if (i % 2 == 0) cMinLat else cMaxLat
98+
val wp2Lat = if (i % 2 == 0) cMaxLat else cMinLat
99+
waypoints.add(Waypoint(wp1Lat, lon, config.altitude, config.speed))
100+
waypoints.add(Waypoint(wp2Lat, lon, config.altitude, config.speed))
101+
}
102+
}
103+
}
104+
105+
// RTL: return to first boundary point
106+
waypoints.add(
107+
Waypoint(
108+
lat = boundary[0].lat,
109+
lon = boundary[0].lon,
110+
alt = config.altitude,
111+
speed = config.speed,
112+
)
113+
)
114+
115+
return waypoints
116+
}
117+
118+
/**
119+
* Clip a horizontal line (constant lat) to the boundary polygon.
120+
* Returns the min/max lon range where the line is inside the polygon,
121+
* or null if the line doesn't intersect.
122+
*/
123+
private fun clipLineToBoundary(
124+
lat: Double,
125+
rangeLonMin: Double,
126+
rangeLonMax: Double,
127+
boundary: List<LatLon>,
128+
): Pair<Double, Double>? {
129+
val intersections = mutableListOf<Double>()
130+
val n = boundary.size
131+
for (i in 0 until n) {
132+
val a = boundary[i]
133+
val b = boundary[(i + 1) % n]
134+
if ((a.lat <= lat && b.lat > lat) || (b.lat <= lat && a.lat > lat)) {
135+
val t = (lat - a.lat) / (b.lat - a.lat)
136+
val lon = a.lon + t * (b.lon - a.lon)
137+
if (lon in rangeLonMin..rangeLonMax) {
138+
intersections.add(lon)
139+
}
140+
}
141+
}
142+
if (intersections.size < 2) return null
143+
return Pair(intersections.min(), intersections.max())
144+
}
145+
146+
/**
147+
* Clip a vertical line (constant lon) to the boundary polygon.
148+
* Returns the min/max lat range where the line is inside the polygon,
149+
* or null if the line doesn't intersect.
150+
*/
151+
private fun clipVerticalLineToBoundary(
152+
lon: Double,
153+
rangeLatMin: Double,
154+
rangeLatMax: Double,
155+
boundary: List<LatLon>,
156+
): Pair<Double, Double>? {
157+
val intersections = mutableListOf<Double>()
158+
val n = boundary.size
159+
for (i in 0 until n) {
160+
val a = boundary[i]
161+
val b = boundary[(i + 1) % n]
162+
if ((a.lon <= lon && b.lon > lon) || (b.lon <= lon && a.lon > lon)) {
163+
val t = (lon - a.lon) / (b.lon - a.lon)
164+
val lat = a.lat + t * (b.lat - a.lat)
165+
if (lat in rangeLatMin..rangeLatMax) {
166+
intersections.add(lat)
167+
}
168+
}
169+
}
170+
if (intersections.size < 2) return null
171+
return Pair(intersections.min(), intersections.max())
172+
}
173+
}

app/src/main/java/com/altnautica/gcs/data/mavlink/MavLinkParser.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.dronefleet.mavlink.common.GlobalPositionInt
1515
import io.dronefleet.mavlink.common.GpsRawInt
1616
import io.dronefleet.mavlink.minimal.Heartbeat
1717
import io.dronefleet.mavlink.minimal.MavModeFlag
18+
import io.dronefleet.mavlink.common.HomePosition
1819
import io.dronefleet.mavlink.common.Statustext
1920
import io.dronefleet.mavlink.common.SysStatus
2021
import io.dronefleet.mavlink.common.VfrHud
@@ -42,6 +43,7 @@ class MavLinkParser @Inject constructor(
4243
is SysStatus -> handleSysStatus(payload)
4344
is GpsRawInt -> handleGps(payload)
4445
is BatteryStatus -> handleBattery(payload)
46+
is HomePosition -> handleHomePosition(payload)
4547
is Statustext -> handleStatusText(payload)
4648
}
4749
} catch (e: Exception) {
@@ -137,6 +139,18 @@ class MavLinkParser @Inject constructor(
137139
)
138140
}
139141

142+
private fun handleHomePosition(home: HomePosition) {
143+
telemetryStore.updateHomePosition(
144+
PositionState(
145+
lat = home.latitude() / 1e7,
146+
lon = home.longitude() / 1e7,
147+
altMsl = home.altitude() / 1000f,
148+
altRel = 0f,
149+
heading = 0,
150+
)
151+
)
152+
}
153+
140154
private fun handleStatusText(statusText: Statustext) {
141155
val text = "[${statusText.severity().value()}] ${statusText.text()}"
142156
telemetryStore.addStatusMessage(text)

app/src/main/java/com/altnautica/gcs/data/telemetry/TelemetryStore.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ class TelemetryStore @Inject constructor() {
3636
private val _connection = MutableStateFlow(ConnectionState())
3737
val connection: StateFlow<ConnectionState> = _connection.asStateFlow()
3838

39+
private val _homePosition = MutableStateFlow<PositionState?>(null)
40+
val homePosition: StateFlow<PositionState?> = _homePosition.asStateFlow()
41+
3942
private val _statusMessages = MutableStateFlow<List<String>>(emptyList())
4043
val statusMessages: StateFlow<List<String>> = _statusMessages.asStateFlow()
4144

@@ -75,6 +78,10 @@ class TelemetryStore @Inject constructor() {
7578
_connection.value = state
7679
}
7780

81+
fun updateHomePosition(pos: PositionState) {
82+
_homePosition.value = pos
83+
}
84+
7885
fun addStatusMessage(message: String) {
7986
val current = _statusMessages.value.toMutableList()
8087
current.add(message)
@@ -94,6 +101,7 @@ class TelemetryStore @Inject constructor() {
94101
_sysStatus.value = SysStatusState()
95102
_flightMode.value = null
96103
_armed.value = false
104+
_homePosition.value = null
97105
_connection.value = ConnectionState()
98106
_statusMessages.value = emptyList()
99107
}

app/src/main/java/com/altnautica/gcs/data/video/VideoStreamManager.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ class VideoStreamManager @Inject constructor(
206206
}
207207
}
208208

209+
fun pause() {
210+
videoTrack?.setEnabled(false)
211+
}
212+
213+
fun resume() {
214+
videoTrack?.setEnabled(true)
215+
}
216+
209217
fun stop() {
210218
videoTrack?.dispose()
211219
videoTrack = null

0 commit comments

Comments
 (0)