diff --git a/.gitignore b/.gitignore
index ef060bf..76b9f2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,5 @@ releases
release
*.xcuserstate
-xcuserdata
\ No newline at end of file
+xcuserdata
+google-services.json
\ No newline at end of file
diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts
index f5729e3..1d7e97b 100644
--- a/app/android/build.gradle.kts
+++ b/app/android/build.gradle.kts
@@ -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"))
-}
\ No newline at end of file
+}
+
+// Process `google-services.json` into resources so Firebase initializes automatically.
+apply(plugin = "com.google.gms.google-services")
\ No newline at end of file
diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index c9d393d..b9099cc 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt
index 70a5eeb..8ffdbd3 100644
--- a/app/android/src/main/kotlin/ru/fromchat/App.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/App.kt
@@ -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}"
+ )
+ }
+ }
+ }
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
index f176529..51fbc60 100644
--- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
@@ -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(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()
+ .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
}
}
\ No newline at end of file
diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt
new file mode 100644
index 0000000..463d2c7
--- /dev/null
+++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt
@@ -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}")
+ }
+}
diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt
new file mode 100644
index 0000000..b48ca85
--- /dev/null
+++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt
@@ -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)
+ }
+ }
+}
+
+
diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
new file mode 100644
index 0000000..6c3cc9d
--- /dev/null
+++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
@@ -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()
+ .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) {
+ 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}")
+ }
+ }
+}
+
+
diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt
new file mode 100644
index 0000000..806adc3
--- /dev/null
+++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt
@@ -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)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/android/src/main/res/drawable/logo.xml b/app/android/src/main/res/drawable/logo.xml
index d76c3a9..42acefb 100644
--- a/app/android/src/main/res/drawable/logo.xml
+++ b/app/android/src/main/res/drawable/logo.xml
@@ -10,13 +10,13 @@
android:translateX="150"
android:translateY="150">
-
-
+
+
\ No newline at end of file
diff --git a/app/android/src/main/res/drawable/logo_big.xml b/app/android/src/main/res/drawable/logo_big.xml
new file mode 100644
index 0000000..6a07da6
--- /dev/null
+++ b/app/android/src/main/res/drawable/logo_big.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ios/iosApp/Info.plist b/app/ios/iosApp/Info.plist
index 5105bd2..f366a8a 100644
--- a/app/ios/iosApp/Info.plist
+++ b/app/ios/iosApp/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 0.2.5
+ 0.3
CFBundleVersion
- 025
+ 03
LSRequiresIPhoneOS
NSAppTransportSecurity
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt
deleted file mode 100644
index 5210d42..0000000
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt
+++ /dev/null
@@ -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)
- }
-}
-
-
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt
new file mode 100644
index 0000000..f9d98d4
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt
@@ -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}")
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/NotificationUtils.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/NotificationUtils.android.kt
new file mode 100644
index 0000000..e69de29
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index b0fb710..d3b1c93 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -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()
- suspend fun send(message: String) =
- http
- .post("${Config.apiBaseUrl}/send_message") {
- contentType(ContentType.Application.Json)
- setBody(SendMessageRequest(message))
- }
- .body()
-
// 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
}
}
}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt
new file mode 100644
index 0000000..21b9a18
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.fcm
+
+expect suspend fun uploadPendingFcmTokenIfAvailable()
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index 491dcda..9d55d12 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -33,7 +33,7 @@ import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf { error("NavController not provided") }
@Composable
-fun App() {
+fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
var startDestination by remember { mutableStateOf(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(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(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()
- }
- }
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt
new file mode 100644
index 0000000..bde43be
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.ui
+
+var isPublicChatVisible = false
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
index a64033c..9cd7082 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
@@ -174,7 +174,9 @@ fun LoginScreen(
onError = { message, _ ->
alert = message
},
- onSuccess = { onLoginSuccess() }
+ onSuccess = {
+ onLoginSuccess()
+ }
) {
ApiClient.login(LoginRequest(username.trim(), derived))
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
index 35c16c5..49c800d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
@@ -172,7 +172,9 @@ fun RegisterScreen(
onError = { message, _ ->
alert = message
},
- onSuccess = { onRegistered() }
+ onSuccess = {
+ onRegistered()
+ }
) {
ApiClient.register(
RegisterRequest(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index 44dcc0f..d64028c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -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(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
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
index be3afe8..d806d2d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
@@ -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
)
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt
new file mode 100644
index 0000000..8d24871
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.fcm
+
+actual suspend fun uploadPendingFcmTokenIfAvailable() {}
\ No newline at end of file
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/NotificationUtils.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/NotificationUtils.ios.kt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/NotificationUtils.ios.kt
@@ -0,0 +1 @@
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 75dec11..7b921e4 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,4 +8,15 @@ plugins {
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
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)
+ }
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f600841..086f61a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
@@ -83,4 +89,5 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
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" }
\ No newline at end of file
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
+google-services = { id = "com.google.gms.google-services" }
\ No newline at end of file