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:
Justin 2026-04-15 15:02:06 -05:00
parent 13a51517c1
commit 0f1705255f
11 changed files with 313 additions and 63 deletions

View File

@ -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"

View File

@ -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)

View File

@ -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 {

View File

@ -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 -> {

View File

@ -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,

View File

@ -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 ->

View File

@ -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)

View File

@ -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()
} }
} }

View File

@ -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 }
)
}
}
}

View File

@ -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)
}
}

View File

@ -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