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.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.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
|
|
@ -26,6 +27,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".util.AcarsPollingService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- WorkManager initializer -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.hoppieacars
|
|||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import com.hoppieacars.util.NotificationHelper
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -12,6 +13,11 @@ class HoppieApp : Application(), Configuration.Provider {
|
|||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NotificationHelper.createChannel(this)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package com.hoppieacars
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -41,8 +44,14 @@ data class NavItem(
|
|||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
HoppieACARSTheme {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.hoppieacars.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.AcarsResult
|
||||
import com.hoppieacars.data.repository.SettingsRepository
|
||||
import com.hoppieacars.util.AcarsPollingService
|
||||
import com.hoppieacars.util.NotificationHelper
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -21,11 +23,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AcarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val acarsRepo: AcarsRepository,
|
||||
private val settingsRepo: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
|
@ -37,14 +39,9 @@ class AcarsViewModel @Inject constructor(
|
|||
// ── Messages ───────────────────────────────────────────────────────────
|
||||
val messages: StateFlow<List<AcarsMessage>> = acarsRepo.messages
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────
|
||||
private val _pollingState = MutableStateFlow<PollingState>(PollingState.Idle)
|
||||
val pollingState: StateFlow<PollingState> = _pollingState.asStateFlow()
|
||||
|
||||
private val _isPollingActive = MutableStateFlow(false)
|
||||
val isPollingActive: StateFlow<Boolean> = _isPollingActive.asStateFlow()
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
// ── Polling — driven by the foreground service ─────────────────────────
|
||||
val pollingState: StateFlow<PollingState> = AcarsPollingService.pollingState
|
||||
val isPollingActive: StateFlow<Boolean> = AcarsPollingService.isRunning
|
||||
|
||||
// ── Send ───────────────────────────────────────────────────────────────
|
||||
private val _sendState = MutableStateFlow(SendMessageState())
|
||||
|
|
@ -61,10 +58,39 @@ class AcarsViewModel @Inject constructor(
|
|||
private val _activeCpdlcFacility = MutableStateFlow<String>("")
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
private val _snackbarMessage = MutableStateFlow<String?>(null)
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
fun saveConfig(newConfig: ConnectionConfig) {
|
||||
|
|
@ -76,46 +102,19 @@ class AcarsViewModel @Inject constructor(
|
|||
// ── Polling control ────────────────────────────────────────────────────
|
||||
|
||||
fun startPolling() {
|
||||
if (_isPollingActive.value) return
|
||||
_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)
|
||||
}
|
||||
}
|
||||
AcarsPollingService.start(context)
|
||||
}
|
||||
|
||||
fun stopPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
_isPollingActive.value = false
|
||||
_pollingState.value = PollingState.Idle
|
||||
AcarsPollingService.stop(context)
|
||||
}
|
||||
|
||||
fun pollOnce() {
|
||||
viewModelScope.launch { doPoll() }
|
||||
viewModelScope.launch {
|
||||
acarsRepo.poll(config.value).let { result ->
|
||||
if (result is AcarsResult.Success && result.data.isNotEmpty()) {
|
||||
NotificationHelper.notifyNewMessages(context, result.data)
|
||||
}
|
||||
|
||||
private suspend fun doPoll() {
|
||||
_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) }
|
||||
when (val result = acarsRepo.sendTelex(config.value, to, message)) {
|
||||
is AcarsResult.Success -> {
|
||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
||||
_sendState.update { it.copy(isSending = false) }
|
||||
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 -> {
|
||||
_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) }
|
||||
when (val result = acarsRepo.sendCpdlc(config.value, to, packet)) {
|
||||
is AcarsResult.Success -> {
|
||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
||||
_sendState.update { it.copy(isSending = false) }
|
||||
showSnackbar("CPDLC sent to $to")
|
||||
_outgoingMin.value++
|
||||
if (packet.contains("LOGOFF", ignoreCase = true)) {
|
||||
_activeCpdlcFacility.value = ""
|
||||
_activeCpdlcMin.value = ""
|
||||
_outgoingMin.value = 1
|
||||
}
|
||||
}
|
||||
is AcarsResult.Error -> {
|
||||
|
|
@ -176,7 +173,7 @@ class AcarsViewModel @Inject constructor(
|
|||
_sendState.update { it.copy(isSending = true, error = null) }
|
||||
when (val result = acarsRepo.sendMessage(config.value, to, type, packet)) {
|
||||
is AcarsResult.Success -> {
|
||||
_sendState.update { it.copy(isSending = false, lastSentAt = Instant.now()) }
|
||||
_sendState.update { it.copy(isSending = false) }
|
||||
showSnackbar("Message sent to $to")
|
||||
}
|
||||
is AcarsResult.Error -> {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ fun MessageCard(message: AcarsMessage, modifier: Modifier = Modifier) {
|
|||
Spacer(Modifier.height(6.dp))
|
||||
// Packet body
|
||||
Text(
|
||||
text = message.packet.ifBlank { "—" },
|
||||
text = message.packet.substringAfterLast('/').ifBlank { "—" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ fun ComposeScreen(viewModel: AcarsViewModel) {
|
|||
val sendState by viewModel.sendState.collectAsState()
|
||||
val config by viewModel.config.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) }
|
||||
|
||||
|
|
@ -200,13 +202,15 @@ fun ComposeScreen(viewModel: AcarsViewModel) {
|
|||
// ── Quick-insert CPDLC templates ───────────────────────────────
|
||||
if (activeTab == ComposeTab.CPDLC) {
|
||||
SectionLabel("QUICK TEMPLATES")
|
||||
val min = outgoingMin.toString()
|
||||
val ref = activeCpdlcMin
|
||||
val templates = listOf(
|
||||
"WILCO" to "/data2/13/37/N/WILCO",
|
||||
"UNABLE" to "/data2/1//UN/UNABLE",
|
||||
"ROGER" to "/data2/1//NE/ROGER",
|
||||
"REQ FL350" to "/data2/9//Y/REQUEST FL350",
|
||||
"LOGON" to "/data2/3//Y/REQUEST LOGON",
|
||||
"LOGOFF" to "/data2/10//N/LOGOFF",
|
||||
"WILCO" to "/data2/$min/$ref/N/WILCO",
|
||||
"UNABLE" to "/data2/$min/$ref/N/UNABLE",
|
||||
"ROGER" to "/data2/$min/$ref/N/ROGER",
|
||||
"REQ FL350" to "/data2/$min/$ref/Y/REQUEST FL350",
|
||||
"LOGON" to "/data2/1//Y/REQUEST LOGON",
|
||||
"LOGOFF" to "/data2/$min/$ref/N/LOGOFF",
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
templates.chunked(3).forEach { row ->
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ fun MessagesScreen(viewModel: AcarsViewModel) {
|
|||
else viewModel.startPolling()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
modifier = Modifier.defaultMinSize(minWidth = 80.dp),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isPollingActive)
|
||||
AcarsRed.copy(alpha = 0.2f)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.work.*
|
|||
import com.hoppieacars.data.repository.AcarsRepository
|
||||
import com.hoppieacars.data.repository.AcarsResult
|
||||
import com.hoppieacars.data.repository.SettingsRepository
|
||||
import com.hoppieacars.util.NotificationHelper
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -29,8 +30,13 @@ class AcarsPollWorker @AssistedInject constructor(
|
|||
if (config.logonCode.isBlank() || config.callsign.isBlank()) {
|
||||
return Result.success() // not configured yet
|
||||
}
|
||||
return when (acarsRepo.poll(config)) {
|
||||
is AcarsResult.Success -> Result.success()
|
||||
return when (val result = acarsRepo.poll(config)) {
|
||||
is AcarsResult.Success -> {
|
||||
if (result.data.isNotEmpty()) {
|
||||
NotificationHelper.notifyNewMessages(applicationContext, result.data)
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
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
|
||||
# header note.
|
||||
#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