Implement Firebase push and WebSocket notifications

This commit is contained in:
2025-12-29 11:50:18 +03:00
Unverified
parent d1be84d89d
commit bee72c7b17
27 changed files with 833 additions and 160 deletions
@@ -1,17 +0,0 @@
package ru.fromchat.api
import android.util.Log
import io.ktor.client.plugins.logging.Logger
/**
* Custom Android logger for Ktor that uses Android's Log system
*/
object AndroidKtorLogger : Logger {
private const val TAG = "Ktor"
override fun log(message: String) {
Log.d(TAG, message)
}
}
@@ -0,0 +1,36 @@
package ru.fromchat.fcm
import android.util.Log
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
try {
val pending = settings.getString("pending_fcm_token", "")
// Only upload if we have auth token
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
return@withContext
}
try {
ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
header("Content-Type", "application/json")
setBody(ApiClient.json.encodeToString(mapOf("token" to pending)))
}
settings.remove("pending_fcm_token")
} catch (e: Exception) {
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
}
} catch (e: Exception) {
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
}
}
@@ -4,6 +4,7 @@ import com.pr0gramm3r101.utils.settings.secureSettings
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
@@ -21,9 +22,12 @@ import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
@@ -53,10 +57,9 @@ object ApiClient {
}
install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
pingInterval = 5000.milliseconds
}
// Set default auth header for all requests
defaultRequest {
token?.let { authToken ->
bearerAuth(authToken)
@@ -66,22 +69,21 @@ object ApiClient {
// Handle HTTP errors and auth errors globally
HttpResponseValidator {
validateResponse { response ->
// Handle auth errors
if (response.status.value == 401 || response.status.value == 403) {
// Clear invalid token and notify about auth error
token = null
user = null
onAuthError?.invoke()
onAuthError?.let {
MainScope().launch {
it()
}
}
}
// Allow WebSocket upgrade responses (101 Switching Protocols)
if (response.status.value == 101) {
return@validateResponse
}
// Throw exception for non-2xx status codes (like failOnError())
if (response.status.value !in 200..299) {
throw io.ktor.client.plugins.ClientRequestException(response, response.status.description)
if (response.status.value !in (200..299) + 101) {
throw ClientRequestException(
response,
response.status.description
)
}
}
}
@@ -125,14 +127,27 @@ object ApiClient {
user = it.user
secureSettings.putString("auth_token", it.token)
settings.putString("user_info", json.encodeToString(it.user))
settings.putInt("current_user_id", it.user.id)
// Upload any pending FCM token after successful login
MainScope().launch {
runCatching {
uploadPendingFcmTokenIfAvailable()
}
}
}
suspend fun register(request: RegisterRequest) =
http
.post("${Config.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
http.post("${Config.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}.also {
// Upload any pending FCM token after successful registration
MainScope().launch {
runCatching {
uploadPendingFcmTokenIfAvailable()
}
}
}
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
@@ -143,56 +158,45 @@ object ApiClient {
}
.body<MessagesResponse>()
suspend fun send(message: String) =
http
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
setBody(SendMessageRequest(message))
}
.body<SendMessageResponse>()
// Validate token by fetching user profile
suspend fun validateToken(): Boolean {
try {
http
.get("${Config.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown
} catch (e: io.ktor.client.plugins.ClientRequestException) {
// Check if it's an auth error (401/403)
} catch (e: ClientRequestException) {
if (e.response.status.value == 401 || e.response.status.value == 403) {
return false // Token is invalid
return false
}
// For other HTTP errors, re-throw (don't treat as token invalid)
throw e
} catch (e: Exception) {
// For network/other errors, re-throw (don't treat as token invalid)
throw e
}
}
suspend fun logout() {
try {
runCatching {
http.get("${Config.apiBaseUrl}/logout")
} catch (e: Exception) {
// Ignore logout errors
}
secureSettings.remove("auth_token")
settings.remove("user_info")
settings.remove("current_user_id")
token = null
user = null
}
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
// WebSocket send helpers
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "sendMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(
WebSocketSendMessageRequest(
@@ -206,13 +210,12 @@ object ApiClient {
}
suspend fun editMessage(messageId: Int, content: String) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "editMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(
WebSocketEditMessageRequest(
@@ -225,13 +228,12 @@ object ApiClient {
}
suspend fun deleteMessage(messageId: Int) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "deleteMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(
WebSocketDeleteMessageRequest(
@@ -243,38 +245,30 @@ object ApiClient {
}
suspend fun sendTyping() {
val token = token ?: throw IllegalStateException("Not authenticated")
try {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "typing",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
credentials = getTokenSafely()
)
)
)
} catch (e: Exception) {
// Silently ignore if WebSocket is not connected yet
// Typing indicators are not critical
}
}
suspend fun sendStopTyping() {
val token = token ?: throw IllegalStateException("Not authenticated")
try {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "stopTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
credentials = getTokenSafely()
)
)
)
} catch (e: Exception) {
// Silently ignore if WebSocket is not connected yet
// Typing indicators are not critical
}
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.fcm
expect suspend fun uploadPendingFcmTokenIfAvailable()
@@ -33,7 +33,7 @@ import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@Composable
fun App() {
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
var startDestination by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
@@ -46,10 +46,11 @@ fun App() {
// Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = if (hasToken) "chat" else "login"
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
startDestination = when {
hasToken && startAtPublicChat -> "chats/publicChat"
hasToken && !startAtPublicChat -> "chat"
else -> "login"
}
}
// Observe lifecycle events to manage WebSocket connection
@@ -77,7 +78,17 @@ fun App() {
FromChatTheme {
val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400)
// Handle navigation to public chat when requested (e.g., from notification)
LaunchedEffect(startAtPublicChat) {
if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
navController.navigate("chats/publicChat") {
launchSingleTop = true
}
}
}
// Set up global auth error handler
LaunchedEffect(navController) {
@@ -93,73 +104,75 @@ fun App() {
LocalNavController provides navController
) {
if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chats/publicChat") {
PublicChatScreen(scrollToMessageId = scrollToMessageId)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
composable("about") {
AboutScreen()
}
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
}
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.ui
var isPublicChatVisible = false
@@ -174,7 +174,9 @@ fun LoginScreen(
onError = { message, _ ->
alert = message
},
onSuccess = { onLoginSuccess() }
onSuccess = {
onLoginSuccess()
}
) {
ApiClient.login(LoginRequest(username.trim(), derived))
}
@@ -172,7 +172,9 @@ fun RegisterScreen(
onError = { message, _ ->
alert = message
},
onSuccess = { onRegistered() }
onSuccess = {
onRegistered()
}
) {
ApiClient.register(
RegisterRequest(
@@ -73,7 +73,8 @@ import ru.fromchat.ui.LocalNavController
fun ChatScreen(
panel: ChatPanel,
currentUserId: Int?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scrollToMessageId: Int? = null
) {
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
@@ -105,6 +106,22 @@ fun ChatScreen(
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
// Scroll to specific message when requested (e.g., from notification click)
LaunchedEffect(scrollToMessageId, panelState.messages) {
scrollToMessageId?.let { messageId ->
val messages = panelState.messages
val messageIndex = messages.indexOfFirst { it.id == messageId }
if (messageIndex != -1) {
scope.launch {
listState.animateScrollToItem(
index = messages.size - 1 - messageIndex,
scrollOffset = 0
)
}
}
}
}
// UI state
var inputText by rememberSaveable { mutableStateOf("") }
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
@@ -307,16 +324,14 @@ fun ChatScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
reverseLayout = true
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
items(
items = panelState.messages.reversed(),
items = panelState.messages,
key = { it.id }
) { message ->
val isAuthor = message.user_id == currentUserId
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
@@ -332,7 +347,7 @@ fun ChatScreen(
) {
MessageItem(
message = message,
isAuthor = isAuthor,
isAuthor = message.user_id == currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
@@ -350,7 +365,7 @@ fun ChatScreen(
}
}
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
}
}
@@ -1,14 +1,16 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible
@Composable
fun PublicChatScreen() {
fun PublicChatScreen(scrollToMessageId: Int? = null) {
val scope = rememberCoroutineScope()
val currentUserId = ApiClient.user?.id
@@ -28,10 +30,19 @@ fun PublicChatScreen() {
}
}
// Track visibility for notifications
DisposableEffect(Unit) {
isPublicChatVisible = true
onDispose {
isPublicChatVisible = false
}
}
// Render with ChatScreen
ChatScreen(
panel = panel,
currentUserId = currentUserId
currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId
)
}
@@ -0,0 +1,3 @@
package ru.fromchat.fcm
actual suspend fun uploadPendingFcmTokenIfAvailable() {}