push notifications
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user