Add background polling service, push notifications, and CPDLC MIN tracking
- AcarsPollingService: foreground service that survives backgrounding, polls at 45-75s jitter, shows persistent status notification with Stop action - NotificationHelper: two-channel setup (high-importance for messages, low-importance for service status); fires per-message alerts on new ACARS - MainActivity: requests POST_NOTIFICATIONS permission at runtime (API 33+) - AcarsViewModel: delegates polling start/stop to the service; tracks incoming CPDLC ATC MIN and outgoing MIN counter for template auto-fill - ComposeScreen: CPDLC templates auto-fill OUR_MIN and REFMIN from live state - SharedComponents: trim message body to everything after last / - MessagesScreen: fixed START/STOP button width stability (defaultMinSize) - local.properties: corrected sdk.dir username path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
13a51517c1
commit
0f1705255f
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
|
@ -26,6 +27,11 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".util.AcarsPollingService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- WorkManager initializer -->
|
<!-- WorkManager initializer -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.hoppieacars
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
|
import com.hoppieacars.util.NotificationHelper
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -12,6 +13,11 @@ class HoppieApp : Application(), Configuration.Provider {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
NotificationHelper.createChannel(this)
|
||||||
|
}
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package com.hoppieacars
|
package com.hoppieacars
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -41,8 +44,14 @@ data class NavItem(
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val requestNotificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
HoppieACARSTheme {
|
HoppieACARSTheme {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.hoppieacars.ui
|
package com.hoppieacars.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.hoppieacars.data.model.AcarsMessage
|
import com.hoppieacars.data.model.AcarsMessage
|
||||||
|
|
@ -11,9 +12,10 @@ import com.hoppieacars.data.model.SendMessageState
|
||||||
import com.hoppieacars.data.repository.AcarsRepository
|
import com.hoppieacars.data.repository.AcarsRepository
|
||||||
import com.hoppieacars.data.repository.AcarsResult
|
import com.hoppieacars.data.repository.AcarsResult
|
||||||
import com.hoppieacars.data.repository.SettingsRepository
|
import com.hoppieacars.data.repository.SettingsRepository
|
||||||
|
import com.hoppieacars.util.AcarsPollingService
|
||||||
|
import com.hoppieacars.util.NotificationHelper
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -21,11 +23,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AcarsViewModel @Inject constructor(
|
class AcarsViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val acarsRepo: AcarsRepository,
|
private val acarsRepo: AcarsRepository,
|
||||||
private val settingsRepo: SettingsRepository
|
private val settingsRepo: SettingsRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -37,14 +39,9 @@ class AcarsViewModel @Inject constructor(
|
||||||
// ── Messages ───────────────────────────────────────────────────────────
|
// ── Messages ───────────────────────────────────────────────────────────
|
||||||
val messages: StateFlow<List<AcarsMessage>> = acarsRepo.messages
|
val messages: StateFlow<List<AcarsMessage>> = acarsRepo.messages
|
||||||
|
|
||||||
// ── Polling ────────────────────────────────────────────────────────────
|
// ── Polling — driven by the foreground service ─────────────────────────
|
||||||
private val _pollingState = MutableStateFlow<PollingState>(PollingState.Idle)
|
val pollingState: StateFlow<PollingState> = AcarsPollingService.pollingState
|
||||||
val pollingState: StateFlow<PollingState> = _pollingState.asStateFlow()
|
val isPollingActive: StateFlow<Boolean> = AcarsPollingService.isRunning
|
||||||
|
|
||||||
private val _isPollingActive = MutableStateFlow(false)
|
|
||||||
val isPollingActive: StateFlow<Boolean> = _isPollingActive.asStateFlow()
|
|
||||||
|
|
||||||
private var pollingJob: Job? = null
|
|
||||||
|
|
||||||
// ── Send ───────────────────────────────────────────────────────────────
|
// ── Send ───────────────────────────────────────────────────────────────
|
||||||
private val _sendState = MutableStateFlow(SendMessageState())
|
private val _sendState = MutableStateFlow(SendMessageState())
|
||||||
|
|
@ -61,10 +58,39 @@ class AcarsViewModel @Inject constructor(
|
||||||
private val _activeCpdlcFacility = MutableStateFlow<String>("")
|
private val _activeCpdlcFacility = MutableStateFlow<String>("")
|
||||||
val activeCpdlcFacility: StateFlow<String> = _activeCpdlcFacility.asStateFlow()
|
val activeCpdlcFacility: StateFlow<String> = _activeCpdlcFacility.asStateFlow()
|
||||||
|
|
||||||
|
// ── ATC's MIN from LOGON ACCEPTED — used as REFMIN in replies ──────────
|
||||||
|
private val _activeCpdlcMin = MutableStateFlow("")
|
||||||
|
val activeCpdlcMin: StateFlow<String> = _activeCpdlcMin.asStateFlow()
|
||||||
|
|
||||||
|
// ── Our own outgoing MIN counter — increments on each CPDLC send ───────
|
||||||
|
private val _outgoingMin = MutableStateFlow(1)
|
||||||
|
val outgoingMin: StateFlow<Int> = _outgoingMin.asStateFlow()
|
||||||
|
|
||||||
// ── Snackbar ───────────────────────────────────────────────────────────
|
// ── Snackbar ───────────────────────────────────────────────────────────
|
||||||
private val _snackbarMessage = MutableStateFlow<String?>(null)
|
private val _snackbarMessage = MutableStateFlow<String?>(null)
|
||||||
val snackbarMessage: StateFlow<String?> = _snackbarMessage.asStateFlow()
|
val snackbarMessage: StateFlow<String?> = _snackbarMessage.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Watch all inbound CPDLC messages to keep MIN/facility up to date
|
||||||
|
viewModelScope.launch {
|
||||||
|
var knownSize = 0
|
||||||
|
acarsRepo.messages.collect { msgs ->
|
||||||
|
val newMsgs = if (msgs.size > knownSize) msgs.drop(knownSize) else emptyList()
|
||||||
|
knownSize = msgs.size
|
||||||
|
newMsgs.filter { it.type == MessageType.CPDLC && it.direction == AcarsMessage.Direction.INBOUND }
|
||||||
|
.forEach { msg ->
|
||||||
|
val parts = msg.packet.split("/")
|
||||||
|
if (parts.size > 2 && parts[2].isNotBlank()) {
|
||||||
|
_activeCpdlcMin.value = parts[2]
|
||||||
|
}
|
||||||
|
if (msg.packet.contains("LOGON ACCEPTED", ignoreCase = true)) {
|
||||||
|
_activeCpdlcFacility.value = msg.from
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveConfig(newConfig: ConnectionConfig) {
|
fun saveConfig(newConfig: ConnectionConfig) {
|
||||||
|
|
@ -76,46 +102,19 @@ class AcarsViewModel @Inject constructor(
|
||||||
// ── Polling control ────────────────────────────────────────────────────
|
// ── Polling control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun startPolling() {
|
fun startPolling() {
|
||||||
if (_isPollingActive.value) return
|
AcarsPollingService.start(context)
|
||||||
_isPollingActive.value = true
|
|
||||||
|
|
||||||
pollingJob = viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
doPoll()
|
|
||||||
// Random delay between 45-75s (per Hoppie spec)
|
|
||||||
val jitter = (45..75).random().toLong()
|
|
||||||
val intervalSec = config.value.pollingIntervalSeconds.toLong()
|
|
||||||
delay((intervalSec.coerceAtLeast(jitter)) * 1_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPolling() {
|
fun stopPolling() {
|
||||||
pollingJob?.cancel()
|
AcarsPollingService.stop(context)
|
||||||
pollingJob = null
|
|
||||||
_isPollingActive.value = false
|
|
||||||
_pollingState.value = PollingState.Idle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pollOnce() {
|
fun pollOnce() {
|
||||||
viewModelScope.launch { doPoll() }
|
viewModelScope.launch {
|
||||||
}
|
acarsRepo.poll(config.value).let { result ->
|
||||||
|
if (result is AcarsResult.Success && result.data.isNotEmpty()) {
|
||||||
private suspend fun doPoll() {
|
NotificationHelper.notifyNewMessages(context, result.data)
|
||||||
_pollingState.value = PollingState.Polling
|
}
|
||||||
when (val result = acarsRepo.poll(config.value)) {
|
|
||||||
is AcarsResult.Success -> {
|
|
||||||
_pollingState.value = PollingState.LastPoll(
|
|
||||||
at = Instant.now(),
|
|
||||||
messageCount = result.data.size
|
|
||||||
)
|
|
||||||
result.data
|
|
||||||
.filter { it.type == MessageType.CPDLC && it.packet.contains("LOGON ACCEPTED", ignoreCase = true) }
|
|
||||||
.lastOrNull()
|
|
||||||
?.let { _activeCpdlcFacility.value = it.from }
|
|
||||||
}
|
|
||||||
is AcarsResult.Error -> {
|
|
||||||
_pollingState.value = PollingState.Error(result.message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,13 +137,8 @@ class AcarsViewModel @Inject constructor(
|
||||||
_sendState.update { it.copy(isSending = true, error = null) }
|
_sendState.update { it.copy(isSending = true, error = null) }
|
||||||
when (val result = acarsRepo.sendTelex(config.value, to, message)) {
|
when (val result = acarsRepo.sendTelex(config.value, to, message)) {
|
||||||
is AcarsResult.Success -> {
|
is AcarsResult.Success -> {
|
||||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
_sendState.update { it.copy(isSending = false) }
|
||||||
showSnackbar("Telex sent to $to")
|
showSnackbar("Telex sent to $to")
|
||||||
// Poll sooner after sending (per Hoppie spec: up to once/20s after a send)
|
|
||||||
if (_isPollingActive.value) {
|
|
||||||
delay(3_000L)
|
|
||||||
doPoll()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is AcarsResult.Error -> {
|
is AcarsResult.Error -> {
|
||||||
_sendState.update { it.copy(isSending = false, error = result.message) }
|
_sendState.update { it.copy(isSending = false, error = result.message) }
|
||||||
|
|
@ -158,10 +152,13 @@ class AcarsViewModel @Inject constructor(
|
||||||
_sendState.update { it.copy(isSending = true, error = null) }
|
_sendState.update { it.copy(isSending = true, error = null) }
|
||||||
when (val result = acarsRepo.sendCpdlc(config.value, to, packet)) {
|
when (val result = acarsRepo.sendCpdlc(config.value, to, packet)) {
|
||||||
is AcarsResult.Success -> {
|
is AcarsResult.Success -> {
|
||||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
_sendState.update { it.copy(isSending = false) }
|
||||||
showSnackbar("CPDLC sent to $to")
|
showSnackbar("CPDLC sent to $to")
|
||||||
|
_outgoingMin.value++
|
||||||
if (packet.contains("LOGOFF", ignoreCase = true)) {
|
if (packet.contains("LOGOFF", ignoreCase = true)) {
|
||||||
_activeCpdlcFacility.value = ""
|
_activeCpdlcFacility.value = ""
|
||||||
|
_activeCpdlcMin.value = ""
|
||||||
|
_outgoingMin.value = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is AcarsResult.Error -> {
|
is AcarsResult.Error -> {
|
||||||
|
|
@ -176,7 +173,7 @@ class AcarsViewModel @Inject constructor(
|
||||||
_sendState.update { it.copy(isSending = true, error = null) }
|
_sendState.update { it.copy(isSending = true, error = null) }
|
||||||
when (val result = acarsRepo.sendMessage(config.value, to, type, packet)) {
|
when (val result = acarsRepo.sendMessage(config.value, to, type, packet)) {
|
||||||
is AcarsResult.Success -> {
|
is AcarsResult.Success -> {
|
||||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
_sendState.update { it.copy(isSending = false) }
|
||||||
showSnackbar("Message sent to $to")
|
showSnackbar("Message sent to $to")
|
||||||
}
|
}
|
||||||
is AcarsResult.Error -> {
|
is AcarsResult.Error -> {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ fun MessageCard(message: AcarsMessage, modifier: Modifier = Modifier) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
// Packet body
|
// Packet body
|
||||||
Text(
|
Text(
|
||||||
text = message.packet.ifBlank { "—" },
|
text = message.packet.substringAfterLast('/').ifBlank { "—" },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ fun ComposeScreen(viewModel: AcarsViewModel) {
|
||||||
val sendState by viewModel.sendState.collectAsState()
|
val sendState by viewModel.sendState.collectAsState()
|
||||||
val config by viewModel.config.collectAsState()
|
val config by viewModel.config.collectAsState()
|
||||||
val activeCpdlcFacility by viewModel.activeCpdlcFacility.collectAsState()
|
val activeCpdlcFacility by viewModel.activeCpdlcFacility.collectAsState()
|
||||||
|
val activeCpdlcMin by viewModel.activeCpdlcMin.collectAsState()
|
||||||
|
val outgoingMin by viewModel.outgoingMin.collectAsState()
|
||||||
|
|
||||||
var activeTab by remember { mutableStateOf(ComposeTab.TELEX) }
|
var activeTab by remember { mutableStateOf(ComposeTab.TELEX) }
|
||||||
|
|
||||||
|
|
@ -200,13 +202,15 @@ fun ComposeScreen(viewModel: AcarsViewModel) {
|
||||||
// ── Quick-insert CPDLC templates ───────────────────────────────
|
// ── Quick-insert CPDLC templates ───────────────────────────────
|
||||||
if (activeTab == ComposeTab.CPDLC) {
|
if (activeTab == ComposeTab.CPDLC) {
|
||||||
SectionLabel("QUICK TEMPLATES")
|
SectionLabel("QUICK TEMPLATES")
|
||||||
|
val min = outgoingMin.toString()
|
||||||
|
val ref = activeCpdlcMin
|
||||||
val templates = listOf(
|
val templates = listOf(
|
||||||
"WILCO" to "/data2/13/37/N/WILCO",
|
"WILCO" to "/data2/$min/$ref/N/WILCO",
|
||||||
"UNABLE" to "/data2/1//UN/UNABLE",
|
"UNABLE" to "/data2/$min/$ref/N/UNABLE",
|
||||||
"ROGER" to "/data2/1//NE/ROGER",
|
"ROGER" to "/data2/$min/$ref/N/ROGER",
|
||||||
"REQ FL350" to "/data2/9//Y/REQUEST FL350",
|
"REQ FL350" to "/data2/$min/$ref/Y/REQUEST FL350",
|
||||||
"LOGON" to "/data2/3//Y/REQUEST LOGON",
|
"LOGON" to "/data2/1//Y/REQUEST LOGON",
|
||||||
"LOGOFF" to "/data2/10//N/LOGOFF",
|
"LOGOFF" to "/data2/$min/$ref/N/LOGOFF",
|
||||||
)
|
)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
templates.chunked(3).forEach { row ->
|
templates.chunked(3).forEach { row ->
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ fun MessagesScreen(viewModel: AcarsViewModel) {
|
||||||
else viewModel.startPolling()
|
else viewModel.startPolling()
|
||||||
},
|
},
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
modifier = Modifier.defaultMinSize(minWidth = 80.dp),
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = if (isPollingActive)
|
containerColor = if (isPollingActive)
|
||||||
AcarsRed.copy(alpha = 0.2f)
|
AcarsRed.copy(alpha = 0.2f)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.work.*
|
||||||
import com.hoppieacars.data.repository.AcarsRepository
|
import com.hoppieacars.data.repository.AcarsRepository
|
||||||
import com.hoppieacars.data.repository.AcarsResult
|
import com.hoppieacars.data.repository.AcarsResult
|
||||||
import com.hoppieacars.data.repository.SettingsRepository
|
import com.hoppieacars.data.repository.SettingsRepository
|
||||||
|
import com.hoppieacars.util.NotificationHelper
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -29,8 +30,13 @@ class AcarsPollWorker @AssistedInject constructor(
|
||||||
if (config.logonCode.isBlank() || config.callsign.isBlank()) {
|
if (config.logonCode.isBlank() || config.callsign.isBlank()) {
|
||||||
return Result.success() // not configured yet
|
return Result.success() // not configured yet
|
||||||
}
|
}
|
||||||
return when (acarsRepo.poll(config)) {
|
return when (val result = acarsRepo.poll(config)) {
|
||||||
is AcarsResult.Success -> Result.success()
|
is AcarsResult.Success -> {
|
||||||
|
if (result.data.isNotEmpty()) {
|
||||||
|
NotificationHelper.notifyNewMessages(applicationContext, result.data)
|
||||||
|
}
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
is AcarsResult.Error -> Result.retry()
|
is AcarsResult.Error -> Result.retry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
package com.hoppieacars.util
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.hoppieacars.MainActivity
|
||||||
|
import com.hoppieacars.data.model.PollingState
|
||||||
|
import com.hoppieacars.data.repository.AcarsRepository
|
||||||
|
import com.hoppieacars.data.repository.AcarsResult
|
||||||
|
import com.hoppieacars.data.repository.SettingsRepository
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AcarsPollingService : Service() {
|
||||||
|
|
||||||
|
@Inject lateinit var acarsRepo: AcarsRepository
|
||||||
|
@Inject lateinit var settingsRepo: SettingsRepository
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
_isRunning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
startForeground(FOREGROUND_NOTIFICATION_ID, buildNotification("Starting…"))
|
||||||
|
startPollingLoop()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPollingLoop() {
|
||||||
|
pollingJob?.cancel()
|
||||||
|
pollingJob = serviceScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
_pollingState.value = PollingState.Polling
|
||||||
|
updateNotification("Polling…")
|
||||||
|
|
||||||
|
val config = settingsRepo.config.first()
|
||||||
|
when (val result = acarsRepo.poll(config)) {
|
||||||
|
is AcarsResult.Success -> {
|
||||||
|
val now = Instant.now()
|
||||||
|
_pollingState.value = PollingState.LastPoll(now, result.data.size)
|
||||||
|
val statusText = if (result.data.isNotEmpty())
|
||||||
|
"${result.data.size} new message(s) · ${TIME_FMT.format(now)}"
|
||||||
|
else
|
||||||
|
"Last poll ${TIME_FMT.format(now)} · no new messages"
|
||||||
|
updateNotification(statusText)
|
||||||
|
if (result.data.isNotEmpty()) {
|
||||||
|
NotificationHelper.notifyNewMessages(applicationContext, result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AcarsResult.Error -> {
|
||||||
|
_pollingState.value = PollingState.Error(result.message)
|
||||||
|
updateNotification("Error: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoppie spec: 45-75s jitter between polls
|
||||||
|
delay((45..75).random() * 1_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
pollingJob?.cancel()
|
||||||
|
serviceScope.cancel()
|
||||||
|
_isRunning.value = false
|
||||||
|
_pollingState.value = PollingState.Idle
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(status: String): Notification {
|
||||||
|
val openIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this, 1,
|
||||||
|
Intent(this, AcarsPollingService::class.java).apply { action = ACTION_STOP },
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, NotificationHelper.SERVICE_CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("Hoppie ACARS — Polling")
|
||||||
|
.setContentText(status)
|
||||||
|
.setContentIntent(openIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop", stopIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(status: String) {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_STOP = "com.hoppieacars.STOP_POLLING"
|
||||||
|
const val FOREGROUND_NOTIFICATION_ID = 1000
|
||||||
|
|
||||||
|
private val _isRunning = MutableStateFlow(false)
|
||||||
|
val isRunning: StateFlow<Boolean> = _isRunning
|
||||||
|
|
||||||
|
private val _pollingState = MutableStateFlow<PollingState>(PollingState.Idle)
|
||||||
|
val pollingState: StateFlow<PollingState> = _pollingState
|
||||||
|
|
||||||
|
private val TIME_FMT: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm'Z'").withZone(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startForegroundService(
|
||||||
|
Intent(context, AcarsPollingService::class.java)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, AcarsPollingService::class.java).apply { action = ACTION_STOP }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.hoppieacars.util
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.hoppieacars.MainActivity
|
||||||
|
import com.hoppieacars.data.model.AcarsMessage
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "acars_messages"
|
||||||
|
private const val CHANNEL_NAME = "ACARS Messages"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
const val SERVICE_CHANNEL_ID = "acars_polling_service"
|
||||||
|
private const val SERVICE_CHANNEL_NAME = "ACARS Polling Service"
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
// High-importance channel for incoming message alerts
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH)
|
||||||
|
.apply { description = "Incoming Hoppie ACARS messages" }
|
||||||
|
)
|
||||||
|
// Low-importance channel for the persistent polling status notification
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(SERVICE_CHANNEL_ID, SERVICE_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
|
||||||
|
.apply { description = "Background polling status" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyNewMessages(context: Context, messages: List<AcarsMessage>) {
|
||||||
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0,
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val title = if (messages.size == 1)
|
||||||
|
"${messages[0].type.wire.uppercase()} from ${messages[0].from}"
|
||||||
|
else
|
||||||
|
"${messages.size} new ACARS messages"
|
||||||
|
|
||||||
|
val body = messages.joinToString("\n") { msg ->
|
||||||
|
"[${msg.type.wire.uppercase()}] ${msg.from}: ${
|
||||||
|
msg.packet.substringAfterLast('/').take(80)
|
||||||
|
}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(if (messages.size == 1) body else "${messages.size} new messages")
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
# For customization when using a Version Control System, please read the
|
# For customization when using a Version Control System, please read the
|
||||||
# header note.
|
# header note.
|
||||||
#Wed Apr 15 08:33:21 CDT 2026
|
#Wed Apr 15 08:33:21 CDT 2026
|
||||||
sdk.dir=C\:\\Users\\Justin\\AppData\\Local\\Android\\Sdk
|
sdk.dir=C\:\\Users\\justi\\AppData\\Local\\Android\\Sdk
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue