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

300 lines
10 KiB
Markdown

# 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:
```kotlin
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:
```kotlin
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
```kotlin
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:
```xml
<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)