mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement Firebase push and WebSocket notifications
This commit is contained in:
@@ -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() {}
|
||||
Reference in New Issue
Block a user