Files
Kecalek/specs/agent-l-notifications-service.md
filip fe861cfafa Initial commit: Kecalek Android client
Complete Android client for encrypted chat platform.
78+ Kotlin files: crypto (X3DH, Double Ratchet, AES-GCM, Ed25519, X25519,
RSA-PSS), network (TCP/TLS, 50 endpoints), Hilt DI, Room+SQLCipher DB,
Jetpack Compose UI with Catppuccin Mocha theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 01:19:17 +01:00

10 KiB

Agent L: Notifications + Background Service

Phase: 4 (Feature Completion)

Depends on: Agent A (Manifest), Agent I (DI)

Context

The app needs a persistent TCP connection to receive real-time push notifications from the server. On Android, this requires a Foreground Service to keep the connection alive when the app is backgrounded. Additionally, local notifications are shown when messages arrive while the app is not in focus.

Task

Create foreground service for TCP connection, notification channels, and app lifecycle handling.

Files to Create

1. core/ChatService.kt

Android Foreground Service:

package com.kecalek.chat.core

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.kecalek.chat.MainActivity
import com.kecalek.chat.R
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class ChatService : Service() {

    // TODO: @Inject ChatClient or ConnectionManager

    companion object {
        const val CHANNEL_ID_SERVICE = "kecalek_service"
        const val CHANNEL_ID_MESSAGES = "kecalek_messages"
        const val CHANNEL_ID_GROUPS = "kecalek_groups"
        const val CHANNEL_ID_SYSTEM = "kecalek_system"
        const val NOTIFICATION_ID_SERVICE = 1
    }

    override fun onCreate() {
        super.onCreate()
        createNotificationChannels()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startForeground(NOTIFICATION_ID_SERVICE, buildServiceNotification())
        // TODO: Start TCP connection listener
        // TODO: Handle incoming notifications and show local notifications
        return START_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        // TODO: Disconnect from server
        super.onDestroy()
    }

    private fun createNotificationChannels() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val manager = getSystemService(NotificationManager::class.java)

            // Service channel (silent, low priority)
            manager.createNotificationChannel(
                NotificationChannel(
                    CHANNEL_ID_SERVICE,
                    "Connection Service",
                    NotificationManager.IMPORTANCE_LOW,
                ).apply {
                    description = "Keeps the encrypted connection alive"
                    setShowBadge(false)
                }
            )

            // Message notifications (high priority, sound)
            manager.createNotificationChannel(
                NotificationChannel(
                    CHANNEL_ID_MESSAGES,
                    "Messages",
                    NotificationManager.IMPORTANCE_HIGH,
                ).apply {
                    description = "New message notifications"
                    enableVibration(true)
                }
            )

            // Group notifications
            manager.createNotificationChannel(
                NotificationChannel(
                    CHANNEL_ID_GROUPS,
                    "Groups",
                    NotificationManager.IMPORTANCE_DEFAULT,
                ).apply {
                    description = "Group activity notifications"
                }
            )

            // System notifications (key changes, connection)
            manager.createNotificationChannel(
                NotificationChannel(
                    CHANNEL_ID_SYSTEM,
                    "System",
                    NotificationManager.IMPORTANCE_LOW,
                ).apply {
                    description = "Connection and security notifications"
                }
            )
        }
    }

    private fun buildServiceNotification(): Notification {
        val intent = Intent(this, MainActivity::class.java)
        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )

        return NotificationCompat.Builder(this, CHANNEL_ID_SERVICE)
            .setContentTitle("Kecalek")
            .setContentText("Connected securely")
            .setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
            .setContentIntent(pendingIntent)
            .setOngoing(true)
            .setSilent(true)
            .build()
    }
}

2. core/NotificationHelper.kt

Helper for showing local notifications:

package com.kecalek.chat.core

import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.kecalek.chat.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NotificationHelper @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    private val notificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    private var nextNotificationId = 100

    fun showMessageNotification(
        senderName: String,
        conversationId: String,
        messagePreview: String?,
    ) {
        // E2EE: Never show plaintext in notification if app is locked
        val displayText = messagePreview ?: "New encrypted message"

        val intent = Intent(context, MainActivity::class.java).apply {
            putExtra("conversationId", conversationId)
            flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
        }
        val pendingIntent = PendingIntent.getActivity(
            context, conversationId.hashCode(), intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )

        val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_MESSAGES)
            .setContentTitle(senderName)
            .setContentText(displayText)
            .setSmallIcon(android.R.drawable.ic_dialog_email) // TODO: Custom icon
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .setGroup("messages")
            .build()

        notificationManager.notify(nextNotificationId++, notification)
    }

    fun showGroupNotification(
        groupName: String,
        action: String, // "invited you", "member joined", etc.
    ) {
        val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_GROUPS)
            .setContentTitle(groupName)
            .setContentText(action)
            .setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Custom icon
            .setAutoCancel(true)
            .build()

        notificationManager.notify(nextNotificationId++, notification)
    }

    fun showSystemNotification(title: String, text: String) {
        val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_SYSTEM)
            .setContentTitle(title)
            .setContentText(text)
            .setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
            .setAutoCancel(true)
            .build()

        notificationManager.notify(nextNotificationId++, notification)
    }

    fun cancelAll() {
        notificationManager.cancelAll()
    }
}

3. core/AppLifecycleObserver.kt

package com.kecalek.chat.core

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Observes app lifecycle to manage TCP connection state.
 * - Foreground: ensure connection is active
 * - Background: keep connection via foreground service
 */
@Singleton
class AppLifecycleObserver @Inject constructor(
    // TODO: Inject ChatClient or ConnectionManager
) : DefaultLifecycleObserver {

    var isInForeground: Boolean = false
        private set

    override fun onStart(owner: LifecycleOwner) {
        isInForeground = true
        // TODO: Reconnect if disconnected, health check
    }

    override fun onStop(owner: LifecycleOwner) {
        isInForeground = false
        // TODO: Connection stays alive via foreground service
    }
}

4. Update AndroidManifest.xml

Add service declaration:

<service
    android:name=".core.ChatService"
    android:foregroundServiceType="dataSync"
    android:exported="false" />

Notification Types to Handle

These 17 server push notification types must trigger appropriate local notifications:

Server Type Notification Channel Display
new_message Messages "Sender: message preview"
messages_read (none) Silent — update UI only
message_deleted (none) Silent — update UI only
message_reacted (none) Silent — update UI only
message_pinned (none) Silent — update UI only
message_unpinned (none) Silent — update UI only
conversation_created Groups "New conversation created"
conversation_renamed Groups "Group renamed to X"
member_added Groups "X joined the group"
member_removed Groups "X was removed"
group_invitation Groups "X invited you to Y"
user_online (none) Silent — update UI only
user_offline (none) Silent — update UI only
online_users (none) Silent — update UI only
session_reset System "Session reset by X"
keys_updated System "Security keys updated"

Constraints

  • Foreground service with FOREGROUND_SERVICE_DATA_SYNC type
  • START_STICKY to restart if killed
  • Notification channels required for Android O+
  • E2EE consideration: don't show plaintext in notifications when app is locked
  • Group notifications by conversation for notification stacking
  • Use PendingIntent.FLAG_IMMUTABLE (Android 12+ requirement)

DO NOT

  • Implement actual TCP connection management
  • Handle message decryption
  • Implement Firebase Cloud Messaging (future enhancement)
  • Store messages or update database directly (delegate to ChatClient)