diff --git a/.claude/agent-memory/android-bug-hunter/MEMORY.md b/.claude/agent-memory/android-bug-hunter/MEMORY.md new file mode 100644 index 0000000..fcc13b3 --- /dev/null +++ b/.claude/agent-memory/android-bug-hunter/MEMORY.md @@ -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` diff --git a/.claude/agent-memory/ui-ux-reviewer/MEMORY.md b/.claude/agent-memory/ui-ux-reviewer/MEMORY.md new file mode 100644 index 0000000..e65c2e7 --- /dev/null +++ b/.claude/agent-memory/ui-ux-reviewer/MEMORY.md @@ -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) diff --git a/.claude/agent-memory/ui-ux-reviewer/patterns.md b/.claude/agent-memory/ui-ux-reviewer/patterns.md new file mode 100644 index 0000000..3908ac0 --- /dev/null +++ b/.claude/agent-memory/ui-ux-reviewer/patterns.md @@ -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) diff --git a/.claude/agents/ui-ux-reviewer.md b/.claude/agents/ui-ux-reviewer.md new file mode 100644 index 0000000..78e4b14 --- /dev/null +++ b/.claude/agents/ui-ux-reviewer.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9bc8ad..9fc884c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15dc74c..e6d7559 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + + android:exported="true" + android:launchMode="singleTop"> + + + + + + = context.dataStore.data.map { prefs -> @@ -169,6 +174,63 @@ class PreferencesManager(private val context: Context) { } } + val lastOpenedRoom: Flow = 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> = context.dataStore.data.map { prefs -> + val raw = prefs[KEY_ROOM_NAME_CACHE] ?: return@map emptyMap() + try { Json.decodeFromString>(raw) } catch (_: Exception) { emptyMap() } + } + + suspend fun saveRoomNameCache(names: Map) { + context.dataStore.edit { prefs -> + prefs[KEY_ROOM_NAME_CACHE] = Json.encodeToString(names) + } + } + + val upEndpoint: Flow = 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 = 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> = context.dataStore.data.map { prefs -> + val raw = prefs[KEY_MUTED_ROOMS] ?: return@map emptySet() + try { Json.decodeFromString>(raw) } catch (_: Exception) { emptySet() } + } + + suspend fun toggleRoomMute(roomId: String) { + context.dataStore.edit { prefs -> + val existing = prefs[KEY_MUTED_ROOMS]?.let { + try { Json.decodeFromString>(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() } } diff --git a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt index 7514473..b15b435 100644 --- a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt +++ b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt @@ -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()) } } diff --git a/app/src/main/java/com/example/fluffytrix/push/DeepLinkState.kt b/app/src/main/java/com/example/fluffytrix/push/DeepLinkState.kt new file mode 100644 index 0000000..b714368 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/push/DeepLinkState.kt @@ -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(null) + val pendingRoomId: StateFlow = _pendingRoomId + + fun set(roomId: String) { + _pendingRoomId.value = roomId + } + + fun clear() { + _pendingRoomId.value = null + } +} diff --git a/app/src/main/java/com/example/fluffytrix/push/FluffytrixPushReceiver.kt b/app/src/main/java/com/example/fluffytrix/push/FluffytrixPushReceiver.kt new file mode 100644 index 0000000..1ae8e45 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/push/FluffytrixPushReceiver.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/push/NotificationHelper.kt b/app/src/main/java/com/example/fluffytrix/push/NotificationHelper.kt new file mode 100644 index 0000000..8ed1a60 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/push/NotificationHelper.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/fluffytrix/push/PushRegistrationManager.kt b/app/src/main/java/com/example/fluffytrix/push/PushRegistrationManager.kt new file mode 100644 index 0000000..228829d --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/push/PushRegistrationManager.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index e920ba8..f0192f1 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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(null) } profileSheet?.let { sheet -> diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index f405531..bbdc1b4 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -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 = emptyMap() + private val _spaces = MutableStateFlow>(emptyList()) val spaces: StateFlow> = _spaces @@ -139,6 +144,36 @@ class MainViewModel( private val _selectedChannel = MutableStateFlow(null) val selectedChannel: StateFlow = _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 = _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) { 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() diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt index 30e6c3a..e6a8e8e 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt @@ -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.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, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db38b24..36f0f01 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }