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>
300 lines
10 KiB
Markdown
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)
|