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:
@@ -24,3 +24,4 @@ release
|
||||
|
||||
*.xcuserstate
|
||||
xcuserdata
|
||||
google-services.json
|
||||
@@ -9,6 +9,7 @@ plugins {
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.google.services)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -111,6 +112,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.play.services.base)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.adaptive.android)
|
||||
@@ -118,7 +120,12 @@ dependencies {
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.slf4j.android)
|
||||
implementation(libs.material)
|
||||
implementation(libs.firebase.messaging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":utils:shared"))
|
||||
}
|
||||
|
||||
// Process `google-services.json` into resources so Firebase initializes automatically.
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
@@ -2,7 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -25,5 +25,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
</manifest>
|
||||
@@ -1,11 +1,113 @@
|
||||
package ru.fromchat
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
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() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun fetchAndNotify() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
NotificationHelper.fetchAndNotify(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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 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.isPublicChatVisible
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Handle initial intent
|
||||
handleIntent(intent)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,13 @@
|
||||
android:translateX="150"
|
||||
android:translateY="150">
|
||||
<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: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" />
|
||||
</group>
|
||||
</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>
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.2.5</string>
|
||||
<string>0.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>025</string>
|
||||
<string>03</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<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 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() {}
|
||||
@@ -9,3 +9,14 @@ plugins {
|
||||
alias(libs.plugins.jetbrains.kotlin.android) 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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ compose-multiplatform = "1.9.3"
|
||||
#noinspection NewerVersionAvailable
|
||||
constraintlayout = "0.6.1-shaded"
|
||||
coreSplashscreen = "1.2.0"
|
||||
firebaseMessaging = "25.0.1"
|
||||
googleServices = "4.4.4"
|
||||
haze = "1.7.1"
|
||||
kotlin = "2.3.0"
|
||||
adaptiveAndroid = "1.2.0"
|
||||
@@ -30,6 +32,7 @@ slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
composeBom = "2025.12.01"
|
||||
playServicesBase = "18.9.0"
|
||||
|
||||
[libraries]
|
||||
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-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
|
||||
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-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||
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-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
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]
|
||||
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" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
||||
google-services = { id = "com.google.gms.google-services" }
|
||||
Reference in New Issue
Block a user