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>
This commit is contained in:
299
specs/agent-l-notifications-service.md
Normal file
299
specs/agent-l-notifications-service.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user