summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-13 23:04:02 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-13 23:04:12 +0000
commit7f89b6d4d0bc4996c0f1802f81abcc23ce47c221 (patch)
tree2725b968ef4a15dee09f51ce676b5481fba35705
parent3c4e18b94db15fc0d012e12aa3be0d0557f6ad3c (diff)
refactor: update package name to org.terst.nav and setup CI/CD with Firebase App Distribution
-rw-r--r--.github/workflows/android.yml43
-rw-r--r--android-app/app/build.gradle8
-rw-r--r--android-app/app/src/main/AndroidManifest.xml5
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt142
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt254
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt)186
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt)2
-rw-r--r--android-app/app/src/main/res/drawable/ic_anchor.xml9
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml2
-rw-r--r--android-app/build.gradle2
13 files changed, 435 insertions, 224 deletions
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..ee85881
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,43 @@
+name: Android CI/CD
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x android-app/gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew assembleDebug
+ working-directory: android-app
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: app-debug
+ path: android-app/app/build/outputs/apk/debug/app-debug.apk
+
+ - name: upload artifact to Firebase App Distribution
+ if: github.ref == 'refs/heads/main'
+ uses: willydouglas/firebase-distribution-action@v1
+ with:
+ appId: ${{secrets.FIREBASE_APP_ID}}
+ serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
+ groups: testers
+ file: android-app/app/build/outputs/apk/debug/app-debug.apk
diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle
index bd903a0..564aa81 100644
--- a/android-app/app/build.gradle
+++ b/android-app/app/build.gradle
@@ -1,14 +1,16 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
+ id 'com.google.gms.google-services'
+ id 'com.google.firebase.appdistribution'
}
android {
- namespace 'com.example.androidapp'
+ namespace 'org.terst.nav'
compileSdk 34
defaultConfig {
- applicationId "com.example.androidapp"
+ applicationId "org.terst.nav"
minSdk 24
targetSdk 34
versionCode 1
@@ -47,6 +49,8 @@ android {
}
dependencies {
+ implementation platform('com.google.firebase:firebase-bom:32.7.2')
+ implementation 'com.google.firebase:firebase-analytics-ktx'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 2fce535..7c2c02d 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
@@ -11,6 +13,9 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidApp">
+ <service
+ android:name=".LocationService"
+ android:foregroundServiceType="location" />
<activity
android:name=".MainActivity"
android:exported="true">
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt
deleted file mode 100644
index ca73397..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package com.example.androidapp
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.location.Location
-import android.os.Looper
-import com.google.android.gms.location.*
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.update
-import android.util.Log // Import Log for logging
-import kotlinx.coroutines.tasks.await // Import await for Task conversion
-
-data class GpsData(
- val latitude: Double,
- val longitude: Double,
- val speedOverGround: Float, // m/s
- val courseOverGround: Float // degrees
-) {
- fun toLocation(): Location {
- val location = Location("GpsData")
- location.latitude = latitude
- location.longitude = longitude
- location.speed = speedOverGround
- location.bearing = courseOverGround
- return location
- }
-}
-
-class LocationService(private val context: Context) {
-
- private val fusedLocationClient: FusedLocationProviderClient =
- LocationServices.getFusedLocationProviderClient(context)
-
- // StateFlow to hold the current anchor watch state
- private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- val anchorWatchState: StateFlow<AnchorWatchState> = _anchorWatchState
-
- // Anchor alarm manager
- private val anchorAlarmManager = AnchorAlarmManager(context)
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
-
- @SuppressLint("MissingPermission") // Permissions handled by the calling component (Activity/Fragment)
- fun getLocationUpdates(): Flow<GpsData> = callbackFlow {
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
- .setMinUpdateIntervalMillis(500)
- .build()
-
- val locationCallback = object : LocationCallback() {
- override fun onLocationResult(locationResult: LocationResult) {
- locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
- latitude = location.latitude,
- longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
- )
- trySend(gpsData)
-
- // Check for anchor drag if anchor watch is active
- _anchorWatchState.update { currentState ->
- if (currentState.isActive && currentState.anchorLocation != null) {
- val isDragging = currentState.isDragging(location)
- if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
- if (!isAlarmTriggered) {
- anchorAlarmManager.startAlarm()
- isAlarmTriggered = true
- }
- } else {
- Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
- }
- } else {
- // If anchor watch is not active, ensure alarm is stopped
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
- }
- currentState // Return the current state (no change unless we explicitly want to update something here)
- }
- }
- }
- }
-
- fusedLocationClient.requestLocationUpdates(
- locationRequest,
- locationCallback,
- Looper.getMainLooper()
- )
-
- awaitClose {
- fusedLocationClient.removeLocationUpdates(locationCallback)
- }
- }
-
- /**
- * Starts the anchor watch with the current location as the anchor point.
- * @param radiusMeters The watch circle radius in meters.
- */
- @SuppressLint("MissingPermission")
- suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
- val lastLocation = fusedLocationClient.lastLocation.await() // Using await() from kotlinx.coroutines.tasks
- lastLocation?.let { location ->
- _anchorWatchState.value = AnchorWatchState(
- anchorLocation = location,
- watchCircleRadiusMeters = radiusMeters,
- setTimeMillis = System.currentTimeMillis(),
- isActive = true
- )
- Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
- } ?: run {
- Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
- // Handle error, e.g., show a toast to the user
- }
- }
-
- /**
- * Stops the anchor watch.
- */
- fun stopAnchorWatch() {
- _anchorWatchState.value = AnchorWatchState(isActive = false)
- Log.i("AnchorWatch", "Anchor watch stopped.")
- anchorAlarmManager.stopAlarm() // Ensure alarm is stopped when anchor watch is explicitly stopped
- isAlarmTriggered = false
- }
-
- /**
- * Updates the watch circle radius.
- */
- fun updateWatchCircleRadius(radiusMeters: Double) {
- _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
- Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
- }
-}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt
index 4b31719..d4423db 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp
+package org.terst.nav
import android.app.NotificationChannel
import android.app.NotificationManager
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
index c7c13fd..03e6a2f 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp
+package org.terst.nav
import android.location.Location
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
new file mode 100644
index 0000000..4b59139
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -0,0 +1,254 @@
+package org.terst.nav
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.location.Location
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationCompat
+import com.google.android.gms.location.*
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import android.util.Log
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+data class GpsData(
+ val latitude: Double,
+ val longitude: Double,
+ val speedOverGround: Float, // m/s
+ val courseOverGround: Float // degrees
+) {
+ fun toLocation(): Location {
+ val location = Location("GpsData")
+ location.latitude = latitude
+ location.longitude = longitude
+ location.speed = speedOverGround
+ location.bearing = courseOverGround
+ return location
+ }
+}
+
+class LocationService : Service() {
+
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var locationCallback: LocationCallback
+ private lateinit var anchorAlarmManager: AnchorAlarmManager
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
+ private val NOTIFICATION_ID = 123
+
+ private var isAlarmTriggered = false // To prevent repeated alarm triggering
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d("LocationService", "Service created")
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ createNotificationChannel()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult) {
+ locationResult.lastLocation?.let { location ->
+ val gpsData = GpsData(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedOverGround = location.speed,
+ courseOverGround = location.bearing
+ )
+ serviceScope.launch {
+ _locationFlow.emit(gpsData) // Emit to shared flow
+ }
+
+
+ // Check for anchor drag if anchor watch is active
+ _anchorWatchState.update { currentState ->
+ if (currentState.isActive && currentState.anchorLocation != null) {
+ val isDragging = currentState.isDragging(location)
+ if (isDragging) {
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (!isAlarmTriggered) {
+ anchorAlarmManager.startAlarm()
+ isAlarmTriggered = true
+ }
+ } else {
+ Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ } else {
+ // If anchor watch is not active, ensure alarm is stopped
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ currentState
+ }
+ }
+ }
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START_FOREGROUND_SERVICE -> {
+ Log.d("LocationService", "Starting foreground service")
+ startForeground(NOTIFICATION_ID, createNotification())
+ startLocationUpdatesInternal()
+ }
+ ACTION_STOP_FOREGROUND_SERVICE -> {
+ Log.d("LocationService", "Stopping foreground service")
+ stopLocationUpdatesInternal()
+ stopSelf()
+ }
+ ACTION_START_ANCHOR_WATCH -> {
+ Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
+ val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
+ serviceScope.launch { startAnchorWatch(radius) }
+ }
+ ACTION_STOP_ANCHOR_WATCH -> {
+ Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
+ stopAnchorWatch()
+ }
+ ACTION_UPDATE_WATCH_RADIUS -> {
+ Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
+ val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
+ updateWatchCircleRadius(radius)
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null // Not a bound service
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.d("LocationService", "Service destroyed")
+ stopLocationUpdatesInternal()
+ anchorAlarmManager.stopAlarm()
+ _anchorWatchState.value = AnchorWatchState(isActive = false)
+ isAlarmTriggered = false // Reset alarm trigger state
+ serviceScope.cancel() // Cancel the coroutine scope
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun startLocationUpdatesInternal() {
+ Log.d("LocationService", "Requesting location updates")
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
+ .setMinUpdateIntervalMillis(500)
+ .build()
+ fusedLocationClient.requestLocationUpdates(
+ locationRequest,
+ locationCallback,
+ Looper.getMainLooper()
+ )
+ }
+
+ private fun stopLocationUpdatesInternal() {
+ Log.d("LocationService", "Removing location updates")
+ fusedLocationClient.removeLocationUpdates(locationCallback)
+ }
+
+ private fun createNotificationChannel() {
+ val serviceChannel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ "Location Service Channel",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(NotificationManager::class.java) as NotificationManager
+ manager.createNotificationChannel(serviceChannel)
+ }
+
+ private fun createNotification(): Notification {
+ val notificationIntent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Sailing Companion")
+ .setContentText("Tracking your location in the background...")
+ .setSmallIcon(R.drawable.ic_anchor)
+ .setContentIntent(pendingIntent)
+ .build()
+ }
+
+ /**
+ * Starts the anchor watch with the current location as the anchor point.
+ * @param radiusMeters The watch circle radius in meters.
+ */
+ @SuppressLint("MissingPermission")
+ suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
+ val lastLocation = fusedLocationClient.lastLocation.await()
+ lastLocation?.let { location ->
+ _anchorWatchState.update { AnchorWatchState(
+ anchorLocation = location,
+ watchCircleRadiusMeters = radiusMeters,
+ setTimeMillis = System.currentTimeMillis(),
+ isActive = true
+ ) }
+ Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
+ } ?: run {
+ Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
+ // Handle error, e.g., show a toast to the user
+ }
+ }
+
+ /**
+ * Stops the anchor watch.
+ */
+ fun stopAnchorWatch() {
+ _anchorWatchState.update { AnchorWatchState(isActive = false) }
+ Log.i("AnchorWatch", "Anchor watch stopped.")
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+
+ /**
+ * Updates the watch circle radius.
+ */
+ fun updateWatchCircleRadius(radiusMeters: Double) {
+ _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
+ Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
+ }
+
+ companion object {
+ const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
+ const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
+ const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
+ const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
+ const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
+ const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
+
+ // Publicly accessible flows
+ val locationFlow: SharedFlow<GpsData>
+ get() = _locationFlow
+ val anchorWatchState: StateFlow<AnchorWatchState>
+ get() = _anchorWatchState
+
+ private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
+ private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index f1f8c4d..ccdf32f 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -1,11 +1,13 @@
-package com.example.androidapp
+package org.terst.nav
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.location.Location
import android.media.MediaPlayer
+import android.os.Build
import android.os.Bundle
+import android.content.Intent
import android.util.Log
import android.view.View
import android.widget.Button
@@ -35,11 +37,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import java.util.concurrent.TimeUnit
-import kotlinx.coroutines.tasks.await
+//import kotlinx.coroutines.tasks.await // Removed as we're no longer directly accessing FusedLocationProviderClient
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@@ -79,7 +82,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var mobValueElapsedTime: TextView
private lateinit var mobRecoveredButton: Button
- private lateinit var locationService: LocationService
+ // Removed direct locationService instance
+ // private lateinit var locationService: LocationService
// MOB State
private var mobActivated: Boolean = false
@@ -116,11 +120,16 @@ class MainActivity : AppCompatActivity() {
// system permissions dialog.
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true &&
- permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) {
- // Permissions granted, initialize location service and start updates
+ val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
+ val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
+ val backgroundLocationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions[Manifest.permission.ACCESS_BACKGROUND_LOCATION] == true
+ } else true // Not needed below Android 10
+
+ if (fineLocationGranted && coarseLocationGranted && backgroundLocationGranted) {
+ // Permissions granted, start location service and observe updates
Toast.makeText(this, "Location permissions granted", Toast.LENGTH_SHORT).show()
- locationService = LocationService(this)
+ startLocationService()
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
} else {
@@ -136,16 +145,24 @@ class MainActivity : AppCompatActivity() {
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
+ val permissionsToRequest = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissionsToRequest.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ }
+
// Check and request location permissions
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ||
- ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
- requestPermissionLauncher.launch(arrayOf(
- Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.PERMISSION_ACCESS_COARSE_LOCATION
- ))
+ val allPermissionsGranted = permissionsToRequest.all {
+ ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ if (!allPermissionsGranted) {
+ requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
} else {
- // Permissions already granted, initialize location service
- locationService = LocationService(this)
+ // Permissions already granted, start location service
+ startLocationService()
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
}
@@ -267,35 +284,38 @@ class MainActivity : AppCompatActivity() {
buttonDecreaseRadius.setOnClickListener {
currentWatchCircleRadius = (currentWatchCircleRadius - 5).coerceAtLeast(10.0) // Minimum 10m
anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- if (::locationService.isInitialized) {
- locationService.updateWatchCircleRadius(currentWatchCircleRadius)
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_UPDATE_WATCH_RADIUS
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
}
+ startService(intent)
}
buttonIncreaseRadius.setOnClickListener {
currentWatchCircleRadius = (currentWatchCircleRadius + 5).coerceAtMost(200.0) // Maximum 200m
anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- if (::locationService.isInitialized) {
- locationService.updateWatchCircleRadius(currentWatchCircleRadius)
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_UPDATE_WATCH_RADIUS
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
}
+ startService(intent)
}
buttonSetAnchor.setOnClickListener {
- if (::locationService.isInitialized) {
- lifecycleScope.launch {
- locationService.startAnchorWatch(currentWatchCircleRadius)
- Toast.makeText(this@MainActivity, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
- } else {
- Toast.makeText(this, "Location service not initialized. Grant permissions first.", Toast.LENGTH_LONG).show()
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_ANCHOR_WATCH
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
}
+ startService(intent)
+ Toast.makeText(this@MainActivity, "Anchor watch set!", Toast.LENGTH_SHORT).show()
}
buttonStopAnchor.setOnClickListener {
- if (::locationService.isInitialized) {
- locationService.stopAnchorWatch()
- Toast.makeText(this@MainActivity, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_STOP_ANCHOR_WATCH
}
+ startService(intent)
+ Toast.makeText(this@MainActivity, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
}
mobRecoveredButton.setOnClickListener {
@@ -303,6 +323,24 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun startLocationService() {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_FOREGROUND_SERVICE
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+ }
+
+ private fun stopLocationService() {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_STOP_FOREGROUND_SERVICE
+ }
+ stopService(intent)
+ }
+
private fun createMockPolarTable(): PolarTable {
// Example polar data for a hypothetical boat
// TWS 6 knots
@@ -416,14 +454,15 @@ class MainActivity : AppCompatActivity() {
val angle = 2 * Math.PI * i / steps
val lat = center.latitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * cos(angle)
val lon = center.longitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * sin(angle) / cos(toRadians(center.latitude()))
- coordinates.add(Point.fromLngLat(lon, lat))
+ coordinates.add(Point.fromLngLats(lon, lat))
}
return Polygon.fromLngLats(listOf(coordinates))
}
private fun observeLocationUpdates() {
lifecycleScope.launch {
- locationService.getLocationUpdates().distinctUntilChanged().collect { gpsData ->
+ // Observe from the static locationFlow in LocationService
+ LocationService.locationFlow.distinctUntilChanged().collect { gpsData ->
if (mobActivated && activeMobWaypoint != null) {
val mobLocation = Location("").apply {
latitude = activeMobWaypoint!!.latitude
@@ -449,14 +488,17 @@ class MainActivity : AppCompatActivity() {
private fun observeAnchorWatchState() {
lifecycleScope.launch {
- locationService.anchorWatchState.collect { state ->
+ // Observe from the static anchorWatchState in LocationService
+ LocationService.anchorWatchState.collect { state ->
withContext(Dispatchers.Main) {
updateAnchorMapLayers(state) // Update map layers
if (state.isActive && state.anchorLocation != null) {
currentWatchCircleRadius = state.watchCircleRadiusMeters
anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- locationService.fusedLocationClient.lastLocation.await()?.let { currentLocation ->
+ // Get the current location from the static flow
+ val currentLocation = LocationService.locationFlow.firstOrNull()?.toLocation()
+ if (currentLocation != null) {
val distance = state.anchorLocation.distanceTo(currentLocation)
val distanceDiff = distance - state.watchCircleRadiusMeters
if (distanceDiff > 0) {
@@ -482,6 +524,9 @@ class MainActivity : AppCompatActivity() {
)
anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
}
+ } else {
+ anchorStatusText.text = "Anchor watch active (waiting for location...)"
+ anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
}
} else {
anchorStatusText.text = getString(R.string.anchor_inactive)
@@ -493,50 +538,41 @@ class MainActivity : AppCompatActivity() {
}
private fun activateMob() {
- if (::locationService.isInitialized) {
- CoroutineScope(Dispatchers.Main).launch {
- try {
- val lastLocation: Location? = locationService.fusedLocationClient.lastLocation.await()
- if (lastLocation != null) {
- activeMobWaypoint = MobWaypoint(
- latitude = lastLocation.latitude,
- longitude = lastLocation.longitude,
- timestamp = System.currentTimeMillis()
- )
- mobActivated = true
- Log.d("MainActivity", "MOB Activated! Location: ${activeMobWaypoint!!.latitude}, ${activeMobWaypoint!!.longitude} at ${activeMobWaypoint!!.timestamp}")
- Toast.makeText(this@MainActivity, "MOB Activated!", Toast.LENGTH_SHORT).show()
-
- // Switch display to MOB navigation view
- mapView?.visibility = View.GONE
- instrumentDisplayContainer.visibility = View.GONE
- fabToggleInstruments.visibility = View.GONE
- fabMob.visibility = View.GONE
- anchorConfigContainer.visibility = View.GONE // Hide anchor config
- fabAnchor.visibility = View.GONE // Hide anchor FAB
- mobNavigationContainer.visibility = View.VISIBLE
-
-
- // Sound continuous alarm
- mobMediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm).apply {
- isLooping = true
- start()
- }
+ // Get last known location from the static flow
+ lifecycleScope.launch {
+ val lastGpsData: GpsData? = LocationService.locationFlow.firstOrNull()
+ if (lastGpsData != null) {
+ activeMobWaypoint = MobWaypoint(
+ latitude = lastGpsData.latitude,
+ longitude = lastGpsData.longitude,
+ timestamp = System.currentTimeMillis()
+ )
+ mobActivated = true
+ Log.d("MainActivity", "MOB Activated! Location: ${activeMobWaypoint!!.latitude}, ${activeMobWaypoint!!.longitude} at ${activeMobWaypoint!!.timestamp}")
+ Toast.makeText(this@MainActivity, "MOB Activated!", Toast.LENGTH_SHORT).show()
- // Log event to logbook
- logMobEvent(activeMobWaypoint!!)
- } else {
- Toast.makeText(this@MainActivity, "Could not get current location for MOB", Toast.LENGTH_SHORT).show()
- Log.e("MainActivity", "Last known location is null, cannot activate MOB.")
- }
- } catch (e: Exception) {
- Toast.makeText(this@MainActivity, "Error getting location for MOB: ${e.message}", Toast.LENGTH_LONG).show()
- Log.e("MainActivity", "Error getting location for MOB", e)
+ // Switch display to MOB navigation view
+ mapView?.visibility = View.GONE
+ instrumentDisplayContainer.visibility = View.GONE
+ fabToggleInstruments.visibility = View.GONE
+ fabMob.visibility = View.GONE
+ anchorConfigContainer.visibility = View.GONE // Hide anchor config
+ fabAnchor.visibility = View.GONE // Hide anchor FAB
+ mobNavigationContainer.visibility = View.VISIBLE
+
+
+ // Sound continuous alarm
+ mobMediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm).apply {
+ isLooping = true
+ start()
}
+
+ // Log event to logbook
+ logMobEvent(activeMobWaypoint!!)
+ } else {
+ Toast.makeText(this@MainActivity, "Could not get current location for MOB", Toast.LENGTH_SHORT).show()
+ Log.e("MainActivity", "Last known location is null, cannot activate MOB.")
}
- } else {
- Toast.makeText(this, "Location service not initialized. Grant permissions first.", Toast.LENGTH_LONG).show()
- Log.e("MainActivity", "Location service not initialized when trying to activate MOB.")
}
}
@@ -634,4 +670,4 @@ class MainActivity : AppCompatActivity() {
mapView?.onDestroy()
mobMediaPlayer?.release() // Ensure media player is released on destroy
}
-} \ No newline at end of file
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt
index 395b80f..9624607 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp
+package org.terst.nav
import kotlin.math.abs
import kotlin.math.cos
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt
index 36e7071..a794ed5 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp
+package org.terst.nav
import android.content.Context
import android.graphics.Canvas
diff --git a/android-app/app/src/main/res/drawable/ic_anchor.xml b/android-app/app/src/main/res/drawable/ic_anchor.xml
new file mode 100644
index 0000000..2389c93
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_anchor.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2S13.1,14 12,14z" />
+</vector>
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 4f38772..88944b8 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -210,7 +210,7 @@
app:layout_constraintHorizontal_bias="0.5" />
<!-- Polar Diagram View -->
- <com.example.androidapp.PolarDiagramView
+ <org.terst.nav.PolarDiagramView
android:id="@+id/polar_diagram_view"
android:layout_width="0dp"
android:layout_height="0dp"
diff --git a/android-app/build.gradle b/android-app/build.gradle
index beeb68d..bdc050c 100644
--- a/android-app/build.gradle
+++ b/android-app/build.gradle
@@ -3,4 +3,6 @@ plugins {
id 'com.android.application' version '8.3.2' apply false
id 'com.android.library' version '8.3.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
+ id 'com.google.gms.google-services' version '4.4.1' apply false
+ id 'com.google.firebase.appdistribution' version '4.0.1' apply false
}