push notifications

This commit is contained in:
2026-03-03 20:32:53 +00:00
parent 8c0cbac246
commit 276d2f2615
18 changed files with 802 additions and 32 deletions

View File

@@ -0,0 +1,53 @@
# Android Bug Hunter — Project Memory
## Recurring Patterns Found
### NotificationHelper: runBlocking on Service Thread
`NotificationHelper.show()` calls `runBlocking` three times (notificationsEnabled, mutedRooms, roomNameCache).
This blocks the `PushService` callback thread. Fix: pass pre-collected values in or make `show()` a suspend fun.
See: `push/NotificationHelper.kt`
### FluffytrixPushService: Leaking CoroutineScope
`FluffytrixPushService` creates its own `CoroutineScope(SupervisorJob() + Dispatchers.IO)` but never cancels
it when the service is destroyed. Coroutines launched from `onNewEndpoint`/`onUnregistered` can outlive the
service. Fix: cancel scope in `onDestroy()`.
### PushRegistrationManager: OkHttp response body not closed
`registerPusher()` stores `response.body?.string()` (which auto-closes), but the `execute()` call itself is
never inside a `use {}` block — if `body?.string()` throws, the response is not closed.
Fix: wrap in `response.use { ... }`.
### build.gradle.kts: API key in plain BuildConfig
`TENOR_API_KEY` hardcoded in `buildConfigField` — visible in plaintext in the APK's BuildConfig class.
### MainViewModel: `onUpdate` listener launches on `Dispatchers.Default` inside `viewModelScope`
The timeline listener's `onUpdate` callback launches a coroutine with `viewModelScope.launch(Dispatchers.Default)`.
If the ViewModel is cleared mid-flight the scope is cancelled but the `onUpdate` lambda (captured by the
SDK via JNI) can still fire and attempt `launch` on a cancelled scope — harmless but noisy.
### MainViewModel: `CoroutineScope` inside `loadTimeline` shared across closures
A `Mutex` local to `loadTimeline` is captured by the `TimelineListener` lambda. When `loadTimeline` is called
again for a new room, the old listener still holds the old mutex — correct, but produces confusing parallelism.
### MainViewModel: `sendGif` creates a new OkHttpClient per call
A brand-new `OkHttpClient()` is constructed inside `sendGif` on every invocation. Should reuse a shared
client (like the one in `PushRegistrationManager`).
### SettingsScreen: new OkHttpClient per API-key test
A new `OkHttpClient()` is created on every "Save" button press inside `SettingsScreen`. Leaks the connection
pool after the composable is disposed.
### DeepLinkState: global singleton StateFlow — never clears on logout
`DeepLinkState` is a process-level `object`. After logout/re-login the stale `pendingRoomId` can navigate
the new session to the old room.
### PreferencesManager: `notificationsEnabled` default logic is inverted-looking
`prefs[KEY_NOTIFICATIONS_ENABLED] != false` — treats missing key as `true` (desired), but any corruption
storing a non-boolean type would also return `true`. Low risk but worth noting.
## Key File Paths
- Push infra: `app/src/main/java/com/example/fluffytrix/push/`
- ViewModel: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt`
- Settings UI: `app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt`
- DI: `app/src/main/java/com/example/fluffytrix/di/AppModule.kt`
- Prefs: `app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt`

View File

@@ -0,0 +1,19 @@
# UI/UX Reviewer Memory — Fluffytrix
See `patterns.md` for detailed design conventions.
## Key Conventions (short form)
- Spacing rhythm: 4/6/8/10/12/16/24/32dp — no odd values
- Channel list item padding: horizontal 12dp, vertical 10dp
- Settings screen row padding: vertical 6dp (info rows), vertical 12dp (nav rows)
- Icon size in lists: 20dp (channel icons, drag handles); 32dp (member avatars)
- Space sidebar icons: 48dp touch target, 64dp column width
- Color tokens: always MaterialTheme.colorScheme — EXCEPT senderColors array (Discord palette, intentional)
- Two hardcoded colors in SpaceList unread dots: `Color.Red` and `Color.Gray` — inconsistent with ChannelList which uses `colorScheme.error` and `colorScheme.primary`
- Typography: titleMedium for section headers in panels; labelMedium uppercase for channel sections; bodyLarge for channel names; bodyMedium for settings labels; bodySmall for subtitles
- Icon style: filled (Icons.Default.*) throughout — AutoMirrored used for directional icons
- Scaffold used in SettingsScreen with TopAppBar; MainScreen Scaffold has no topBar (custom layout)
- `collectAsStateWithLifecycle` used in MainScreen; `collectAsState` used in SettingsScreen — inconsistent
- MainScreen uses `@Immutable` data class for ProfileSheetState (good practice)
- Drag gesture threshold: 60f px for swipe-open/close channel list
- LazyListState for channel list is owned by ViewModel (correct — survives recomposition)

View File

@@ -0,0 +1,40 @@
# Fluffytrix UI Patterns
## Layout Structure
- MainScreen: Scaffold (no topBar) → Box → Row[SpaceList | MessageTimeline | MemberList?]
- Channel list overlays via AnimatedVisibility + zIndex(1f), slides from left
- SpaceList: 64dp wide, surfaceVariant background, icons 48dp
- ChannelList: fills width, surface background, max ~280dp visually
- MemberList: 240dp wide, surface background
- SettingsScreen: Scaffold + TopAppBar + Column + verticalScroll
## Spacing Rhythm
- Standard horizontal content padding: 16dp (settings, channel list header)
- Channel list items: horizontal 12dp, vertical 10dp
- Thread sub-items: start indent 24dp, horizontal 10dp, vertical 6dp
- Member list items: horizontal 8dp, vertical 4dp
- Settings rows: vertical 6dp (info/toggle), vertical 12dp (nav)
- Section dividers: padding(vertical = 16.dp)
## Color Token Usage
- Surface backgrounds: `colorScheme.surface` or `colorScheme.surfaceVariant`
- Selected item bg: `colorScheme.primaryContainer`; text: `colorScheme.onPrimaryContainer`
- Unread dot in ChannelList: error (mention) / primary (unread) — correct
- Unread dot in SpaceList: hardcoded `Color.Red` / `Color.Gray` — BUG, inconsistent
- Section text: `colorScheme.onSurface.copy(alpha = 0.7f)` uppercase labelMedium
- Disabled/subtle text: `colorScheme.onSurfaceVariant` or with `.copy(alpha = 0.6f)`
## Icon Conventions
- All icons: Icons.Default.* (filled style)
- Directional icons: Icons.AutoMirrored.Filled.*
- Icon size in list rows: 20dp
- All icons in touch targets: wrapped in IconButton (48dp auto-sizing) or clickable with min 48dp
## Notification (push/) — no Compose UI
- NotificationHelper: uses `R.mipmap.ic_launcher` as small icon (should be `R.drawable` monochrome)
- Channel: "messages" / IMPORTANCE_DEFAULT
- Tap → deep link via Intent extra "roomId" → DeepLinkState.set()
## State Collection
- MainScreen: collectAsStateWithLifecycle (correct, lifecycle-aware)
- SettingsScreen: collectAsState (should be collectAsStateWithLifecycle for consistency)

View File

@@ -0,0 +1,124 @@
---
name: ui-ux-reviewer
description: "Use this agent when UI components, screens, or navigation flows are added or modified. It reviews Jetpack Compose code for visual consistency, Material 3 adherence, and user experience quality.\\n\\nExamples:\\n\\n- User: \"Add a settings screen with toggle options\"\\n Assistant: *writes the settings screen composable*\\n \"Now let me use the ui-ux-reviewer agent to review the new screen for consistency and UX quality.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Update the channel list to show unread badges\"\\n Assistant: *implements unread badge composable and integrates it*\\n \"Let me run the ui-ux-reviewer agent to ensure the badges are consistent with the rest of the app's design language.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Fix the message input bar layout\"\\n Assistant: *modifies the input bar composable*\\n \"I'll use the ui-ux-reviewer agent to verify the layout changes maintain consistency and good UX.\"\\n *launches ui-ux-reviewer agent via Task tool*"
model: sonnet
memory: project
---
You are an elite UI/UX reviewer specializing in Android Jetpack Compose applications with Material 3 / Material You theming. You have deep expertise in building consistent, accessible, and intuitive interfaces. Your particular strength is Discord-like chat application layouts.
## Your Role
You review recently written or modified UI code to ensure visual consistency across the entire app and a smooth, intuitive user experience. You do NOT rewrite the whole codebase — you focus on the recently changed files and check them against established patterns.
## Project Context
This is Fluffytrix, an Android Matrix chat client with a Discord-like UI:
- Jetpack Compose UI with Material 3 dynamic colors
- Discord-like layout: space sidebar → channel list → message area → member list
- Package: `com.example.fluffytrix`
- Target: Android 14+ (minSdk 34)
## Review Process
1. **Identify changed/new UI files** — focus your review on recently modified composables and screens.
2. **Check consistency** by examining existing UI patterns in the codebase:
- Read several existing screens/components to establish the baseline patterns
- Compare the new code against those patterns
- Look for: padding values, color usage, typography styles, icon sizing, elevation, shape/corner radius, spacing rhythm
3. **Evaluate UX quality**:
- Touch target sizes (minimum 48dp)
- Loading states — are they present where needed?
- Error states — are they handled gracefully?
- Empty states — do lists show meaningful empty content?
- Navigation clarity — is it obvious how to go back or proceed?
- Feedback — do interactive elements provide visual feedback (ripple, state changes)?
- Scrolling behavior — is content scrollable when it could overflow?
- Keyboard handling — does the UI adapt when the soft keyboard appears?
4. **Check Material 3 adherence**:
- Uses `MaterialTheme.colorScheme` tokens instead of hardcoded colors
- Uses `MaterialTheme.typography` instead of custom text styles
- Proper use of Surface, Card, and container components
- Consistent use of Material 3 icons (filled vs outlined — pick one style)
- Dynamic color support (no colors that break with light/dark theme)
5. **Accessibility**:
- Content descriptions on icons and images
- Sufficient color contrast
- Semantic properties for screen readers
- Text scaling support (don't use fixed sp that breaks at large font sizes)
## Output Format
Structure your review as:
### ✅ Consistent Patterns
List what the code does well and matches existing patterns.
### ⚠️ Inconsistencies Found
For each issue:
- **File**: path
- **Issue**: what's wrong
- **Expected**: what the pattern should be (with reference to where the correct pattern exists)
- **Fix**: concrete code suggestion
### 🎯 UX Improvements
Suggestions that aren't bugs but would improve the user experience.
Prioritize issues by impact: blocking issues first, then visual inconsistencies, then nice-to-haves.
## Rules
- Always read existing UI code first to understand established patterns before making judgments
- Never suggest changes that would break the Discord-like layout intent
- Prefer MaterialTheme tokens over any hardcoded values
- If you're unsure whether something is intentional, flag it as a question rather than an error
- Keep suggestions actionable — include code snippets for fixes
- Don't nitpick formatting; focus on user-visible consistency and experience
**Update your agent memory** as you discover UI patterns, design conventions, component reuse patterns, color/spacing constants, and navigation structures in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Common padding/spacing values used across screens
- Standard composable patterns (e.g., how list items are structured)
- Color token usage conventions
- Icon style choices (filled vs outlined)
- Navigation patterns and screen transition styles
- Reusable component locations
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mrfluffy/Documents/projects/Android/fluffytrix/.claude/agent-memory/ui-ux-reviewer/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -18,7 +18,6 @@ android {
versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "TENOR_API_KEY", "\"AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCDY\"")
}
buildTypes {
@@ -107,6 +106,9 @@ dependencies {
// Jetpack Emoji Picker
implementation(libs.emoji.picker)
// UnifiedPush
implementation(libs.unifiedpush)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".FluffytrixApplication"
@@ -16,13 +18,22 @@
android:theme="@style/Theme.Fluffytrix">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".push.FluffytrixPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -9,21 +9,42 @@ import coil3.memory.MemoryCache
import coil3.gif.AnimatedImageDecoder
import coil3.video.VideoFrameDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.di.appModule
import com.example.fluffytrix.di.dataModule
import com.example.fluffytrix.push.NotificationHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.unifiedpush.android.connector.UnifiedPush
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@FluffytrixApplication)
modules(appModule, dataModule)
}
NotificationHelper.ensureChannel(this)
val preferencesManager: PreferencesManager by inject()
appScope.launch {
val loggedIn = preferencesManager.isLoggedIn.first()
if (loggedIn) {
// Only register if a distributor is already saved; user must select one manually otherwise
if (UnifiedPush.getSavedDistributor(this@FluffytrixApplication) != null) {
UnifiedPush.register(this@FluffytrixApplication)
}
}
}
}
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {

View File

@@ -1,20 +1,41 @@
package com.example.fluffytrix
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.example.fluffytrix.push.DeepLinkState
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
import com.example.fluffytrix.ui.theme.FluffytrixTheme
class MainActivity : ComponentActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
setContent {
FluffytrixTheme {
FluffytrixNavigation()
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
}
}

View File

@@ -32,6 +32,11 @@ class PreferencesManager(private val context: Context) {
private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names")
private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads")
private val KEY_TENOR_API_KEY = stringPreferencesKey("tenor_api_key")
private val KEY_LAST_OPENED_ROOM = stringPreferencesKey("last_opened_room")
private val KEY_UP_ENDPOINT = stringPreferencesKey("up_endpoint")
private val KEY_ROOM_NAME_CACHE = stringPreferencesKey("room_name_cache")
private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
private val KEY_MUTED_ROOMS = stringPreferencesKey("muted_rooms")
}
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -169,6 +174,63 @@ class PreferencesManager(private val context: Context) {
}
}
val lastOpenedRoom: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_LAST_OPENED_ROOM]
}
suspend fun setLastOpenedRoom(roomId: String) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_OPENED_ROOM] = roomId
}
}
val roomNameCache: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_ROOM_NAME_CACHE] ?: return@map emptyMap()
try { Json.decodeFromString<Map<String, String>>(raw) } catch (_: Exception) { emptyMap() }
}
suspend fun saveRoomNameCache(names: Map<String, String>) {
context.dataStore.edit { prefs ->
prefs[KEY_ROOM_NAME_CACHE] = Json.encodeToString(names)
}
}
val upEndpoint: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_UP_ENDPOINT]
}
suspend fun setUpEndpoint(url: String?) {
context.dataStore.edit { prefs ->
if (url != null) prefs[KEY_UP_ENDPOINT] = url else prefs.remove(KEY_UP_ENDPOINT)
}
}
val notificationsEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_NOTIFICATIONS_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_NOTIFICATIONS_ENABLED] = enabled
}
}
val mutedRooms: Flow<Set<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_MUTED_ROOMS] ?: return@map emptySet()
try { Json.decodeFromString<Set<String>>(raw) } catch (_: Exception) { emptySet() }
}
suspend fun toggleRoomMute(roomId: String) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_MUTED_ROOMS]?.let {
try { Json.decodeFromString<Set<String>>(it) } catch (_: Exception) { emptySet() }
} ?: emptySet()
prefs[KEY_MUTED_ROOMS] = Json.encodeToString(
if (roomId in existing) existing - roomId else existing + roomId
)
}
}
suspend fun clearSession() {
context.dataStore.edit { it.clear() }
}

View File

@@ -4,6 +4,7 @@ import android.app.Application
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import com.example.fluffytrix.push.PushRegistrationManager
import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
@@ -21,4 +22,5 @@ val dataModule = module {
single { PreferencesManager(get()) }
single { AuthRepository(get(), get()) }
single { EmojiPackRepository(get()) }
single { PushRegistrationManager(get()) }
}

View File

@@ -0,0 +1,17 @@
package com.example.fluffytrix.push
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object DeepLinkState {
private val _pendingRoomId = MutableStateFlow<String?>(null)
val pendingRoomId: StateFlow<String?> = _pendingRoomId
fun set(roomId: String) {
_pendingRoomId.value = roomId
}
fun clear() {
_pendingRoomId.value = null
}
}

View File

@@ -0,0 +1,64 @@
package com.example.fluffytrix.push
import android.util.Log
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
private const val TAG = "PushReceiver"
class FluffytrixPushService : PushService(), KoinComponent {
private val preferencesManager: PreferencesManager by inject()
private val pushRegistrationManager: PushRegistrationManager by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onMessage(message: PushMessage, instance: String) {
Log.d(TAG, "Push message received")
scope.launch {
NotificationHelper.show(this@FluffytrixPushService, message.content, preferencesManager)
}
}
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
Log.i(TAG, "New UP endpoint: ${endpoint.url}")
scope.launch {
preferencesManager.setUpEndpoint(endpoint.url)
try {
pushRegistrationManager.registerPusher(endpoint.url)
} catch (e: Exception) {
Log.e(TAG, "Failed to register pusher: ${e.message}")
}
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UP registration failed: $reason")
}
override fun onUnregistered(instance: String) {
Log.i(TAG, "UP unregistered")
scope.launch {
val endpoint = preferencesManager.upEndpoint.first()
if (endpoint != null) {
pushRegistrationManager.unregisterPusher(endpoint)
}
preferencesManager.setUpEndpoint(null)
}
}
}

View File

@@ -0,0 +1,85 @@
package com.example.fluffytrix.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.example.fluffytrix.MainActivity
import com.example.fluffytrix.R
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.flow.first
import org.json.JSONObject
object NotificationHelper {
private const val CHANNEL_ID = "messages"
private const val CHANNEL_NAME = "Messages"
fun ensureChannel(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
)
nm.createNotificationChannel(channel)
}
}
suspend fun show(context: Context, payload: ByteArray, preferencesManager: PreferencesManager) {
val notificationsEnabled = preferencesManager.notificationsEnabled.first()
if (!notificationsEnabled) return
val mutedRooms = preferencesManager.mutedRooms.first()
val json = try {
JSONObject(String(payload))
} catch (_: Exception) {
return
}
val notification = json.optJSONObject("notification") ?: return
val roomId = notification.optString("room_id").takeIf { it.isNotBlank() } ?: return
if (roomId in mutedRooms) return
val cachedNames = preferencesManager.roomNameCache.first()
val roomName = notification.optString("room_name").ifBlank {
cachedNames[roomId] ?: roomId
}
val sender = notification.optString("sender_display_name").ifBlank {
notification.optString("sender").ifBlank { "Someone" }
}
val content = notification.optJSONObject("counts")?.let {
val msgs = it.optInt("unread", 0)
if (msgs > 0) "$msgs unread message${if (msgs > 1) "s" else ""}" else null
} ?: "$sender sent a message"
ensureChannel(context)
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("roomId", roomId)
}
val pendingIntent = PendingIntent.getActivity(
context,
roomId.hashCode(),
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(roomName)
.setContentText(content)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
nm.notify(roomId.hashCode(), notif)
}
}

View File

@@ -0,0 +1,89 @@
package com.example.fluffytrix.push
import android.util.Log
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.flow.first
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private const val TAG = "PushReg"
class PushRegistrationManager(
private val preferencesManager: PreferencesManager,
) {
private val httpClient = OkHttpClient()
suspend fun registerPusher(endpoint: String) {
val homeserver = preferencesManager.homeserverUrl.first() ?: run {
Log.w(TAG, "No homeserver URL — skipping pusher registration")
return
}
val accessToken = preferencesManager.accessToken.first() ?: run {
Log.w(TAG, "No access token — skipping pusher registration")
return
}
val deviceId = preferencesManager.deviceId.first() ?: "fluffytrix"
val userId = preferencesManager.userId.first() ?: "unknown"
val body = JSONObject().apply {
put("app_id", "com.example.fluffytrix")
put("app_display_name", "Fluffytrix")
put("device_display_name", deviceId)
put("kind", "http")
put("lang", "en")
put("pushkey", endpoint)
put("data", JSONObject().apply {
put("url", run {
val parsed = java.net.URL(endpoint)
"${parsed.protocol}://${parsed.host}${if (parsed.port != -1) ":${parsed.port}" else ""}/_matrix/push/v1/notify"
})
})
}.toString()
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { response ->
val responseBody = response.body?.string()
if (response.isSuccessful) {
Log.i(TAG, "Pusher registered successfully for $userId")
} else {
val msg = "HTTP ${response.code}: $responseBody"
Log.w(TAG, "Pusher registration failed: $msg")
throw Exception(msg)
}
}
}
suspend fun unregisterPusher(endpoint: String) {
val homeserver = preferencesManager.homeserverUrl.first() ?: return
val accessToken = preferencesManager.accessToken.first() ?: return
val body = JSONObject().apply {
put("app_id", "com.example.fluffytrix")
put("kind", null)
put("pushkey", endpoint)
}.toString()
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
try {
httpClient.newCall(request).execute().close()
Log.i(TAG, "Pusher unregistered")
} catch (e: Exception) {
Log.e(TAG, "Pusher unregister error", e)
}
}
}

View File

@@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -25,7 +27,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.compose.runtime.Immutable
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.push.DeepLinkState
import com.example.fluffytrix.ui.screens.main.components.ChannelList
import com.example.fluffytrix.ui.screens.main.components.MemberList
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
@@ -34,12 +38,21 @@ import com.example.fluffytrix.ui.screens.main.components.UserProfileSheet
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Immutable
private data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
@Composable
fun MainScreen(
onLogout: () -> Unit,
onSettingsClick: () -> Unit = {},
viewModel: MainViewModel = koinViewModel(),
) {
val pendingDeepLinkRoom by DeepLinkState.pendingRoomId.collectAsStateWithLifecycle()
LaunchedEffect(pendingDeepLinkRoom) {
val roomId = pendingDeepLinkRoom ?: return@LaunchedEffect
viewModel.openRoom(roomId)
}
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle()
@@ -65,7 +78,6 @@ fun MainScreen(
val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
var profileSheet by remember { mutableStateOf<ProfileSheetState?>(null) }
profileSheet?.let { sheet ->

View File

@@ -16,6 +16,7 @@ import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.async
import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.awaitAll
@@ -23,6 +24,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CreateRoomParameters
@@ -127,6 +129,9 @@ class MainViewModel(
private val emojiPackRepository: EmojiPackRepository,
) : ViewModel() {
private val httpClient = okhttp3.OkHttpClient()
private var lastSavedNameCache: Map<String, String> = emptyMap()
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
val spaces: StateFlow<List<SpaceItem>> = _spaces
@@ -139,6 +144,36 @@ class MainViewModel(
private val _selectedChannel = MutableStateFlow<String?>(null)
val selectedChannel: StateFlow<String?> = _selectedChannel
fun openRoom(roomId: String, persist: Boolean = true) {
viewModelScope.launch {
// Wait for space children map if not yet populated (preload runs after first room load)
val spaceMap = if (_spaceChildrenMap.value.isNotEmpty()) {
_spaceChildrenMap.value
} else {
withTimeoutOrNull(6000) {
_spaceChildrenMap.first { it.isNotEmpty() }
} ?: _spaceChildrenMap.value
}
val parentSpaceId = spaceMap.entries.firstOrNull { (_, rooms) -> roomId in rooms }?.key
if (parentSpaceId != null) {
_selectedSpace.value = parentSpaceId
loadSpaceChildren(parentSpaceId)
} else {
_selectedSpace.value = null
val cached = cachedOrphanIds
if (cached != null) _spaceChildren.value = cached
else { _spaceChildren.value = emptySet(); loadOrphanRooms() }
}
selectChannel(roomId, persist = persist)
_showChannelList.value = false
if (com.example.fluffytrix.push.DeepLinkState.pendingRoomId.value == roomId) {
com.example.fluffytrix.push.DeepLinkState.clear()
}
}
}
private val _showChannelList = MutableStateFlow(true)
val showChannelList: StateFlow<Boolean> = _showChannelList
@@ -397,7 +432,7 @@ class MainViewModel(
}
}
_allChannelRooms.value = joinedRooms
val channelItems = joinedRooms
.filter { try { !it.isSpace() } catch (_: Exception) { true } }
.map { room ->
ChannelItem(
@@ -407,12 +442,25 @@ class MainViewModel(
avatarUrl = avatarUrl(room.avatarUrl()),
)
}
_allChannelRooms.value = channelItems
val newNameCache = channelItems.associate { it.id to it.name }
if (newNameCache != lastSavedNameCache) {
lastSavedNameCache = newNameCache
preferencesManager.saveRoomNameCache(newNameCache)
}
updateSpaceUnreadStatus()
// On first load, compute orphan rooms and preload space children for unread dots
// On first load, restore last opened room and compute orphan rooms
if (!orphanRoomsLoaded && _selectedSpace.value == null) {
orphanRoomsLoaded = true
// Restore last opened room (skip if a deep-link room is already pending)
if (com.example.fluffytrix.push.DeepLinkState.pendingRoomId.value == null && _selectedChannel.value == null) {
val lastRoom = preferencesManager.lastOpenedRoom.first()
if (lastRoom != null && channelItems.any { it.id == lastRoom }) {
openRoom(lastRoom, persist = false)
}
}
loadOrphanRooms()
}
if (!spaceChildrenPreloaded && _spaces.value.isNotEmpty()) {
@@ -557,6 +605,7 @@ class MainViewModel(
override fun onUpdate(diff: List<TimelineDiff>) {
viewModelScope.launch(Dispatchers.Default) {
mutex.withLock {
if (_selectedChannel.value != roomId) return@withLock
for (d in diff) {
when (d) {
is TimelineDiff.Reset -> {
@@ -1081,8 +1130,7 @@ class MainViewModel(
viewModelScope.launch(Dispatchers.IO) {
try {
android.util.Log.d("SendGif", "Downloading $url")
val client = okhttp3.OkHttpClient()
val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute()
val response = httpClient.newCall(okhttp3.Request.Builder().url(url).build()).execute()
android.util.Log.d("SendGif", "Response: ${response.code}")
val bytes = response.body?.bytes() ?: run {
android.util.Log.e("SendGif", "Empty body")
@@ -1348,7 +1396,7 @@ class MainViewModel(
}
}
fun selectChannel(channelId: String) {
fun selectChannel(channelId: String, persist: Boolean = true) {
// Place unread marker: in descending list (newest=0), marker at index (count-1)
// means it appears visually above the block of unread messages
val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0
@@ -1356,6 +1404,7 @@ class MainViewModel(
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
_selectedChannel.value = channelId
if (persist) viewModelScope.launch { preferencesManager.setLastOpenedRoom(channelId) }
if (_roomUnreadStatus.value.containsKey(channelId)) {
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId
_roomUnreadCount.value = _roomUnreadCount.value - channelId
@@ -1697,7 +1746,7 @@ class MainViewModel(
isDirect = true,
visibility = RoomVisibility.Private,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = emptyList(),
invite = listOf(normalizedUserId),
avatar = null,
powerLevelContentOverride = null,
joinRuleOverride = null,
@@ -1706,25 +1755,6 @@ class MainViewModel(
isSpace = false,
)
)
// Poll until the room appears in the local store, then invite
var room = client.getRoom(newRoomId)
if (room == null) {
repeat(10) {
kotlinx.coroutines.delay(500)
room = client.getRoom(newRoomId)
if (room != null) return@repeat
}
}
if (room != null) {
try {
room!!.inviteUserById(normalizedUserId)
android.util.Log.d("MainVM", "Invited $normalizedUserId to $newRoomId")
} catch (e: Exception) {
android.util.Log.e("MainVM", "inviteUserById failed: ${e.message}", e)
}
} else {
android.util.Log.e("MainVM", "Room $newRoomId not found after waiting, skipping invite")
}
roomId = newRoomId
// Seed the channel name from the invited user's profile so it
@@ -1749,6 +1779,7 @@ class MainViewModel(
fun logout() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
com.example.fluffytrix.push.DeepLinkState.clear()
viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { }
authRepository.logout()

View File

@@ -35,11 +35,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import org.unifiedpush.android.connector.UnifiedPush
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.push.PushRegistrationManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -59,15 +62,21 @@ fun SettingsScreen(
onEmojiPackManagement: () -> Unit = {},
) {
val preferencesManager: PreferencesManager = koinInject()
val pushRegistrationManager: PushRegistrationManager = koinInject()
val userId by preferencesManager.userId.collectAsState(initial = null)
val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null)
val deviceId by preferencesManager.deviceId.collectAsState(initial = null)
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false)
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val upEndpoint by preferencesManager.upEndpoint.collectAsState(initial = null)
val savedGiphyKey by preferencesManager.tenorApiKey.collectAsState(initial = "")
var giphyKeyInput by remember { mutableStateOf("") }
var giphyKeyStatus by remember { mutableStateOf<GiphyKeyStatus>(GiphyKeyStatus.Idle) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
var upDistributor by remember { mutableStateOf(UnifiedPush.getSavedDistributor(context) ?: "") }
var showDistributorPicker by remember { mutableStateOf(false) }
var pusherRegStatus by remember { mutableStateOf("") }
val appVersion = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown"
} catch (_: Exception) {
@@ -179,9 +188,9 @@ fun SettingsScreen(
val valid = withContext(Dispatchers.IO) {
try {
val url = "https://api.giphy.com/v1/gifs/trending?api_key=$key&limit=1"
val client = okhttp3.OkHttpClient()
val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute()
response.isSuccessful
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build())
.execute()
.use { it.isSuccessful }
} catch (_: Exception) { false }
}
if (valid) {
@@ -213,7 +222,109 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeader("Notifications")
SettingRow("Push notifications", "Enabled")
SettingToggleRow(
label = "Enable notifications",
description = "Show push notifications for new messages",
checked = notificationsEnabled,
onCheckedChange = { scope.launch { preferencesManager.setNotificationsEnabled(it) } },
)
SettingRow(
label = "Push distributor",
value = upDistributor.ifBlank { "None selected" },
)
SettingRow(
label = "Endpoint",
value = upEndpoint?.let { if (it.length > 50) it.take(50) + "" else it } ?: "Not registered",
)
Button(
onClick = { showDistributorPicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(if (upDistributor.isBlank()) "Select distributor" else "Change distributor")
}
if (upEndpoint != null) {
Button(
onClick = {
pusherRegStatus = "Registering…"
scope.launch {
try {
withContext(Dispatchers.IO) {
pushRegistrationManager.registerPusher(upEndpoint!!)
}
pusherRegStatus = "Pusher registered!"
} catch (e: Exception) {
pusherRegStatus = "Failed: ${e.message}"
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Re-register pusher with homeserver")
}
}
if (pusherRegStatus.isNotBlank()) {
Text(
pusherRegStatus,
style = MaterialTheme.typography.bodySmall,
color = if (pusherRegStatus.startsWith("Failed") || pusherRegStatus.startsWith("Pusher reg failed"))
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (showDistributorPicker) {
val distributors = remember(showDistributorPicker) { UnifiedPush.getDistributors(context) }
if (distributors.isEmpty()) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showDistributorPicker = false },
title = { Text("No distributor found") },
text = { Text("Install a UnifiedPush distributor app (e.g. ntfy) to enable push notifications.") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
Text("OK")
}
},
)
} else {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showDistributorPicker = false },
title = { Text("Select distributor") },
text = {
Column {
distributors.forEach { pkg ->
val label = try {
context.packageManager.getApplicationLabel(
context.packageManager.getApplicationInfo(pkg, 0)
).toString()
} catch (_: Exception) { pkg }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
UnifiedPush.saveDistributor(context, pkg)
UnifiedPush.register(context)
upDistributor = pkg
showDistributorPicker = false
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
Text("Cancel")
}
},
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -273,6 +384,8 @@ private fun SettingRow(label: String, value: String) {
text = value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -21,6 +21,7 @@ media3 = "1.6.0"
markdownRenderer = "0.37.0"
emojiPicker = "1.6.0"
kotlinxSerialization = "1.8.1"
unifiedpush = "3.3.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -82,6 +83,9 @@ markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdo
# Jetpack Emoji Picker
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
# UnifiedPush
unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }