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>
10 KiB
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_SYNCtype START_STICKYto 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)