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
View File
@@ -24,3 +24,4 @@ release
*.xcuserstate *.xcuserstate
xcuserdata xcuserdata
google-services.json
+7
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.google.services)
} }
kotlin { kotlin {
@@ -111,6 +112,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.play.services.base)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.adaptive.android) implementation(libs.androidx.adaptive.android)
@@ -118,7 +120,12 @@ dependencies {
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.slf4j.android) implementation(libs.slf4j.android)
implementation(libs.material) implementation(libs.material)
implementation(libs.firebase.messaging)
implementation(libs.kotlinx.serialization.json)
implementation(project(":app:shared")) implementation(project(":app:shared"))
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
} }
// Process `google-services.json` into resources so Firebase initializes automatically.
apply(plugin = "com.google.gms.google-services")
+16 -1
View File
@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -25,5 +25,20 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".fcm.FromChatFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".notifications.NotificationReplyReceiver"
android:exported="false">
<intent-filter>
<action android:name="ru.fromchat.NOTIFICATION_REPLY" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -1,11 +1,113 @@
package ru.fromchat package ru.fromchat
import android.app.Application import android.app.Application
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
class App: Application() { class App: Application() {
@OptIn(DelicateCoroutinesApi::class)
private fun fetchAndNotify() {
GlobalScope.launch(Dispatchers.IO) {
runCatching {
NotificationHelper.fetchAndNotify(applicationContext)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
UtilsLibrary.init(this) UtilsLibrary.init(this)
WebSocketManager.addGlobalMessageHandler { msg ->
runCatching {
if (msg.type == "newMessage") {
fetchAndNotify()
} else if (msg.type == "updates") {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
var shouldFetch = false
for (item in updates) {
if (
item
.jsonObject["type"]
?.jsonPrimitive
?.content
in arrayOf("newMessage", "dmNew")
) {
shouldFetch = true
break
}
}
if (shouldFetch) fetchAndNotify()
}
}
}
}
GlobalScope.launch(Dispatchers.IO) {
runCatching {
ApiClient.loadPersistedData()
}
runCatching {
uploadPendingFcmTokenIfAvailable()
}
// If we have an auth token, try to get current FCM token and register it immediately
runCatching {
val auth = ApiClient.token
if (!auth.isNullOrEmpty()) {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
GlobalScope.launch(Dispatchers.IO) {
if (task.isSuccessful) {
try {
val resp = ApiClient.http.post(
"${Config.apiBaseUrl}/push/register"
) {
header("Content-Type", "application/json")
setBody(
ApiClient.json.encodeToString(
mapOf("token" to task.result)
)
)
}
Log.d(
"AppFCM",
"Registered existing FCM token on startup, status=${resp.status.value}"
)
} catch (e: Exception) {
Log.e(
"AppFCM",
"Failed to register FCM token on startup: ${e.message}"
)
}
} else {
Log.w(
"AppFCM",
"FirebaseMessaging token fetch failed on startup: ${task.exception?.message}"
)
}
}
}
}
}
}
} }
} }
@@ -1,20 +1,133 @@
package ru.fromchat package ru.fromchat
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var scrollToMessageId by mutableStateOf<Int?>(null)
private var startAtPublicChat by mutableStateOf(false)
private var prevIsPublicChatVisible: Boolean? = null
private fun handleIntent(intent: Intent?) {
val messageId = intent?.getIntExtra("scroll_to_message_id", -1) ?: -1
scrollToMessageId = if (messageId != -1) messageId else null
startAtPublicChat = messageId != -1
// Mark messages as read if clicked from notification
if (intent?.getBooleanExtra("mark_message_read", false) == true) {
markMessagesAsRead()
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun markMessagesAsRead() {
GlobalScope.launch {
try {
// Get all unread messages and mark them as read
val messageIds = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.map { it.id }
if (messageIds.isNotEmpty()) {
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(mapOf("messageIds" to messageIds))
}
Log.d("MainActivity", "Marked ${messageIds.size} messages as read: $messageIds")
}
} catch (e: Exception) {
Log.e("MainActivity", "Failed to mark messages as read", e)
}
}
}
private fun checkGooglePlayServices(): Boolean {
with (GoogleApiAvailability.getInstance()) {
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
if (resultCode == ConnectionResult.SUCCESS) {
return true
}
if (isUserResolvableError(resultCode)) {
getErrorDialog(
this@MainActivity,
resultCode,
9000
)?.show()
}
return false
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
// Handle initial intent
handleIntent(intent)
setContent { setContent {
App() App(
scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat
)
} }
// Request POST_NOTIFICATIONS permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
}
checkGooglePlayServices()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onPause() {
super.onPause()
prevIsPublicChatVisible = isPublicChatVisible
isPublicChatVisible = false
}
override fun onResume() {
super.onResume()
isPublicChatVisible = prevIsPublicChatVisible ?: false
} }
} }
@@ -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
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}")
}
}
@@ -0,0 +1,62 @@
package ru.fromchat.fcm
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
import ru.fromchat.notifications.NotificationHelper
@OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d("FromChatFCM", "onMessageReceived: from=${remoteMessage.from}, data=${remoteMessage.data}")
GlobalScope.launch(Dispatchers.IO) {
try {
NotificationHelper.fetchAndNotify(applicationContext)
} catch (e: Exception) {
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
}
}
}
override fun onNewToken(token: String) {
Log.d("FromChatFCM", "onNewToken: $token")
GlobalScope.launch(Dispatchers.IO) {
// Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login)
try {
val authToken = ApiClient.token
if (!authToken.isNullOrEmpty()) {
// Call backend endpoint to register token
try {
val resp = ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
}
Log.d("FromChatFCM", "Uploaded FCM token to server: ${resp.status.value}")
} catch (e: Exception) {
Log.e("FromChatFCM", "Failed to upload token: ${e.message}", e)
}
} else {
// Save to shared preferences for later upload (best-effort)
runCatching {
settings.putString("pending_fcm_token", token)
}
}
} catch (e: Exception) {
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
}
super.onNewToken(token)
}
}
}
@@ -0,0 +1,205 @@
package ru.fromchat.notifications
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.MainActivity
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.ui.isPublicChatVisible
object NotificationHelper {
private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
const val KEY_TEXT_REPLY = "key_text_reply"
private fun createMessageIntent(context: Context, messageId: Int) = PendingIntent.getActivity(
context,
messageId,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("scroll_to_message_id", messageId)
putExtra("mark_message_read", true)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
context,
SUMMARY_NOTIFICATION_ID,
Intent(context, NotificationReplyReceiver::class.java).apply {
action = "ru.fromchat.NOTIFICATION_REPLY"
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "FromChat message notifications"
}
)
}
}
suspend fun fetchAndNotify(context: Context) {
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
try {
val messages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages")
if (messages.isEmpty()) return
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
// Display notifications on main thread
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages)
}
} catch (e: Exception) {
Log.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) {
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) {
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
GlobalScope.launch {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
// Find new messages that are not from the current user
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) && // Not already shown
msg.user_id != currentUserId // Not from current user
}
.ifEmpty { return@launch }
.apply { forEach { shown.add(it.id.toString()) } }
newMessageCount = newMessages.size
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let {
for (msg in messages.takeLast(10)) {
val timestamp = try {
java.time.Instant.parse(msg.timestamp).toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
it.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.content,
timestamp,
Person.Builder()
.setName(msg.username)
.build()
)
)
}
it
}
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
createReplyIntent(context)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_view,
"View Chat",
createMessageIntent(
context,
newMessages.last().id
)
).build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.build()
)
}
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
Log.d("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
}
}
}
@@ -0,0 +1,32 @@
package ru.fromchat.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.RemoteInput
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
@OptIn(DelicateCoroutinesApi::class)
class NotificationReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let {
if (it.isNotBlank()) {
Log.d("NotificationReply", "Received reply: $it")
GlobalScope.launch(Dispatchers.IO) {
try {
ApiClient.sendMessage(it)
Log.d("NotificationReply", "Reply sent successfully")
} catch (e: Exception) {
Log.e("NotificationReply", "Failed to send reply", e)
}
}
}
}
}
}
+7 -7
View File
@@ -10,13 +10,13 @@
android:translateX="150" android:translateX="150"
android:translateY="150"> android:translateY="150">
<path <path
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z"
android:fillColor="#fff" /> android:fillColor="#fff" />
<path <path
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z"
android:fillColor="#fff" /> android:fillColor="#fff" />
<path <path
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z"
android:fillColor="#fff" /> android:fillColor="#fff" />
</group> </group>
</vector> </vector>
@@ -0,0 +1,16 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z"
android:fillColor="#fff" />
<path
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z"
android:fillColor="#fff" />
<path
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z"
android:fillColor="#fff" />
</vector>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.2.5</string> <string>0.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>025</string> <string>03</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@@ -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 com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest 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.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -53,10 +57,9 @@ object ApiClient {
} }
install(WebSockets) { 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 { defaultRequest {
token?.let { authToken -> token?.let { authToken ->
bearerAuth(authToken) bearerAuth(authToken)
@@ -66,22 +69,21 @@ object ApiClient {
// Handle HTTP errors and auth errors globally // Handle HTTP errors and auth errors globally
HttpResponseValidator { HttpResponseValidator {
validateResponse { response -> validateResponse { response ->
// Handle auth errors
if (response.status.value == 401 || response.status.value == 403) { if (response.status.value == 401 || response.status.value == 403) {
// Clear invalid token and notify about auth error
token = null token = null
user = null user = null
onAuthError?.invoke() onAuthError?.let {
MainScope().launch {
it()
}
}
} }
// Allow WebSocket upgrade responses (101 Switching Protocols) if (response.status.value !in (200..299) + 101) {
if (response.status.value == 101) { throw ClientRequestException(
return@validateResponse response,
} response.status.description
)
// 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)
} }
} }
} }
@@ -125,14 +127,27 @@ object ApiClient {
user = it.user user = it.user
secureSettings.putString("auth_token", it.token) secureSettings.putString("auth_token", it.token)
settings.putString("user_info", json.encodeToString(it.user)) 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) = suspend fun register(request: RegisterRequest) =
http http.post("${Config.apiBaseUrl}/register") {
.post("${Config.apiBaseUrl}/register") { contentType(ContentType.Application.Json)
contentType(ContentType.Application.Json) setBody(request)
setBody(request) }.also {
// Upload any pending FCM token after successful registration
MainScope().launch {
runCatching {
uploadPendingFcmTokenIfAvailable()
}
} }
}
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http http
@@ -143,56 +158,45 @@ object ApiClient {
} }
.body<MessagesResponse>() .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 // Validate token by fetching user profile
suspend fun validateToken(): Boolean { suspend fun validateToken(): Boolean {
try { try {
http http
.get("${Config.apiBaseUrl}/api/user/profile") .get("${Config.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown return true // Token is valid if no exception thrown
} catch (e: io.ktor.client.plugins.ClientRequestException) { } catch (e: ClientRequestException) {
// Check if it's an auth error (401/403)
if (e.response.status.value == 401 || e.response.status.value == 403) { 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 throw e
} catch (e: Exception) { } catch (e: Exception) {
// For network/other errors, re-throw (don't treat as token invalid)
throw e throw e
} }
} }
suspend fun logout() { suspend fun logout() {
try { runCatching {
http.get("${Config.apiBaseUrl}/logout") http.get("${Config.apiBaseUrl}/logout")
} catch (e: Exception) {
// Ignore logout errors
} }
secureSettings.remove("auth_token") secureSettings.remove("auth_token")
settings.remove("user_info") settings.remove("user_info")
settings.remove("current_user_id")
token = null token = null
user = null user = null
} }
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
// WebSocket send helpers // WebSocket send helpers
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "sendMessage", type = "sendMessage",
credentials = WebSocketCredentials( credentials = WebSocketCredentials(
scheme = "Bearer", scheme = "Bearer",
credentials = token credentials = getTokenSafely()
), ),
data = json.encodeToJsonElement( data = json.encodeToJsonElement(
WebSocketSendMessageRequest( WebSocketSendMessageRequest(
@@ -206,13 +210,12 @@ object ApiClient {
} }
suspend fun editMessage(messageId: Int, content: String) { suspend fun editMessage(messageId: Int, content: String) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "editMessage", type = "editMessage",
credentials = WebSocketCredentials( credentials = WebSocketCredentials(
scheme = "Bearer", scheme = "Bearer",
credentials = token credentials = getTokenSafely()
), ),
data = json.encodeToJsonElement( data = json.encodeToJsonElement(
WebSocketEditMessageRequest( WebSocketEditMessageRequest(
@@ -225,13 +228,12 @@ object ApiClient {
} }
suspend fun deleteMessage(messageId: Int) { suspend fun deleteMessage(messageId: Int) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "deleteMessage", type = "deleteMessage",
credentials = WebSocketCredentials( credentials = WebSocketCredentials(
scheme = "Bearer", scheme = "Bearer",
credentials = token credentials = getTokenSafely()
), ),
data = json.encodeToJsonElement( data = json.encodeToJsonElement(
WebSocketDeleteMessageRequest( WebSocketDeleteMessageRequest(
@@ -243,38 +245,30 @@ object ApiClient {
} }
suspend fun sendTyping() { suspend fun sendTyping() {
val token = token ?: throw IllegalStateException("Not authenticated") runCatching {
try {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "typing", type = "typing",
credentials = WebSocketCredentials( credentials = WebSocketCredentials(
scheme = "Bearer", 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() { suspend fun sendStopTyping() {
val token = token ?: throw IllegalStateException("Not authenticated") runCatching {
try {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "stopTyping", type = "stopTyping",
credentials = WebSocketCredentials( credentials = WebSocketCredentials(
scheme = "Bearer", 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") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@Composable @Composable
fun App() { fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
var startDestination by remember { mutableStateOf<String?>(null) } var startDestination by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -46,10 +46,11 @@ fun App() {
// Now determine start destination based on loaded token // Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = if (hasToken) "chat" else "login" startDestination = when {
hasToken && startAtPublicChat -> "chats/publicChat"
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}") hasToken && !startAtPublicChat -> "chat"
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination") else -> "login"
}
} }
// Observe lifecycle events to manage WebSocket connection // Observe lifecycle events to manage WebSocket connection
@@ -77,7 +78,17 @@ fun App() {
FromChatTheme { FromChatTheme {
val navController = rememberNavController() 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 // Set up global auth error handler
LaunchedEffect(navController) { LaunchedEffect(navController) {
@@ -93,73 +104,75 @@ fun App() {
LocalNavController provides navController LocalNavController provides navController
) { ) {
if (startDestination != null) { if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination!!, startDestination = startDestination!!,
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
Start, Start,
animationSpec = animationSpec animationSpec = animationSpec
) )
}, },
exitTransition = { exitTransition = {
slideOutOfContainer( slideOutOfContainer(
Start, Start,
animationSpec = animationSpec animationSpec = animationSpec
) )
}, },
popEnterTransition = { popEnterTransition = {
slideIntoContainer( slideIntoContainer(
End, End,
animationSpec = animationSpec animationSpec = animationSpec
) )
}, },
popExitTransition = { popExitTransition = {
slideOutOfContainer( slideOutOfContainer(
End, End,
animationSpec = animationSpec animationSpec = animationSpec
) )
} }
) { ) {
composable("serverConfig") { composable("serverConfig") {
ServerConfigScreen() ServerConfigScreen()
} }
composable("login") { composable("login") {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
navController.navigate("chat") { navController.navigate("chat") {
popUpTo("login") { inclusive = true } 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") { composable("chats/publicChat") {
RegisterScreen( PublicChatScreen(scrollToMessageId = scrollToMessageId)
onRegistered = { navController.navigate("login") } }
)
}
composable("chat") { composable("about") {
MainScreen( AboutScreen()
onLogout = { }
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
} }
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, _ -> onError = { message, _ ->
alert = message alert = message
}, },
onSuccess = { onLoginSuccess() } onSuccess = {
onLoginSuccess()
}
) { ) {
ApiClient.login(LoginRequest(username.trim(), derived)) ApiClient.login(LoginRequest(username.trim(), derived))
} }
@@ -172,7 +172,9 @@ fun RegisterScreen(
onError = { message, _ -> onError = { message, _ ->
alert = message alert = message
}, },
onSuccess = { onRegistered() } onSuccess = {
onRegistered()
}
) { ) {
ApiClient.register( ApiClient.register(
RegisterRequest( RegisterRequest(
@@ -73,7 +73,8 @@ import ru.fromchat.ui.LocalNavController
fun ChatScreen( fun ChatScreen(
panel: ChatPanel, panel: ChatPanel,
currentUserId: Int?, currentUserId: Int?,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
scrollToMessageId: Int? = null
) { ) {
var panelState by remember(panel) { mutableStateOf(panel.getState()) } 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 }}") 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 // UI state
var inputText by rememberSaveable { mutableStateOf("") } var inputText by rememberSaveable { mutableStateOf("") }
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) } var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
@@ -307,16 +324,14 @@ fun ChatScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom), verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
reverseLayout = true
) { ) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
items( items(
items = panelState.messages.reversed(), items = panelState.messages,
key = { it.id } key = { it.id }
) { message -> ) { message ->
val isAuthor = message.user_id == currentUserId
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) } var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) } var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
@@ -332,7 +347,7 @@ fun ChatScreen(
) { ) {
MessageItem( MessageItem(
message = message, message = message,
isAuthor = isAuthor, isAuthor = message.user_id == currentUserId,
onLongPress = { onLongPress = {
contextMenuState = ContextMenuState( contextMenuState = ContextMenuState(
isOpen = true, 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 package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.isPublicChatVisible
@Composable @Composable
fun PublicChatScreen() { fun PublicChatScreen(scrollToMessageId: Int? = null) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentUserId = ApiClient.user?.id 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 // Render with ChatScreen
ChatScreen( ChatScreen(
panel = panel, panel = panel,
currentUserId = currentUserId currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId
) )
} }
@@ -0,0 +1,3 @@
package ru.fromchat.fcm
actual suspend fun uploadPendingFcmTokenIfAvailable() {}
+11
View File
@@ -9,3 +9,14 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
} }
// Ensure google-services plugin is available to subprojects that apply it.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath(libs.google.services)
}
}
+7
View File
@@ -8,6 +8,8 @@ compose-multiplatform = "1.9.3"
#noinspection NewerVersionAvailable #noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded" constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.1"
googleServices = "4.4.4"
haze = "1.7.1" haze = "1.7.1"
kotlin = "2.3.0" kotlin = "2.3.0"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
@@ -30,6 +32,7 @@ slf4j = "1.7.36"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
composeBom = "2025.12.01" composeBom = "2025.12.01"
playServicesBase = "18.9.0"
[libraries] [libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -39,6 +42,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" } constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
@@ -74,6 +79,7 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -84,3 +90,4 @@ android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
google-services = { id = "com.google.gms.google-services" }