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
|
*.xcuserstate
|
||||||
xcuserdata
|
xcuserdata
|
||||||
|
google-services.json
|
||||||
@@ -9,6 +9,7 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -111,6 +112,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.play.services.base)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.adaptive.android)
|
implementation(libs.androidx.adaptive.android)
|
||||||
@@ -118,7 +120,12 @@ dependencies {
|
|||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.slf4j.android)
|
implementation(libs.slf4j.android)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
implementation(libs.firebase.messaging)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(project(":app:shared"))
|
implementation(project(":app:shared"))
|
||||||
implementation(project(":utils:shared"))
|
implementation(project(":utils:shared"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process `google-services.json` into resources so Firebase initializes automatically.
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -25,5 +25,20 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notifications.NotificationReplyReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="ru.fromchat.NOTIFICATION_REPLY" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,11 +1,113 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.WebSocketManager
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
class App: Application() {
|
class App: Application() {
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun fetchAndNotify() {
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
NotificationHelper.fetchAndNotify(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
UtilsLibrary.init(this)
|
UtilsLibrary.init(this)
|
||||||
|
|
||||||
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
|
runCatching {
|
||||||
|
if (msg.type == "newMessage") {
|
||||||
|
fetchAndNotify()
|
||||||
|
} else if (msg.type == "updates") {
|
||||||
|
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
|
||||||
|
var shouldFetch = false
|
||||||
|
for (item in updates) {
|
||||||
|
if (
|
||||||
|
item
|
||||||
|
.jsonObject["type"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
in arrayOf("newMessage", "dmNew")
|
||||||
|
) {
|
||||||
|
shouldFetch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetch) fetchAndNotify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
ApiClient.loadPersistedData()
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
uploadPendingFcmTokenIfAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have an auth token, try to get current FCM token and register it immediately
|
||||||
|
runCatching {
|
||||||
|
val auth = ApiClient.token
|
||||||
|
if (!auth.isNullOrEmpty()) {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
try {
|
||||||
|
val resp = ApiClient.http.post(
|
||||||
|
"${Config.apiBaseUrl}/push/register"
|
||||||
|
) {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(
|
||||||
|
ApiClient.json.encodeToString(
|
||||||
|
mapOf("token" to task.result)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
"AppFCM",
|
||||||
|
"Registered existing FCM token on startup, status=${resp.status.value}"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(
|
||||||
|
"AppFCM",
|
||||||
|
"Failed to register FCM token on startup: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(
|
||||||
|
"AppFCM",
|
||||||
|
"FirebaseMessaging token fetch failed on startup: ${task.exception?.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,133 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import com.google.android.gms.common.ConnectionResult
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.MessagesResponse
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var scrollToMessageId by mutableStateOf<Int?>(null)
|
||||||
|
private var startAtPublicChat by mutableStateOf(false)
|
||||||
|
private var prevIsPublicChatVisible: Boolean? = null
|
||||||
|
|
||||||
|
private fun handleIntent(intent: Intent?) {
|
||||||
|
val messageId = intent?.getIntExtra("scroll_to_message_id", -1) ?: -1
|
||||||
|
scrollToMessageId = if (messageId != -1) messageId else null
|
||||||
|
startAtPublicChat = messageId != -1
|
||||||
|
|
||||||
|
// Mark messages as read if clicked from notification
|
||||||
|
if (intent?.getBooleanExtra("mark_message_read", false) == true) {
|
||||||
|
markMessagesAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun markMessagesAsRead() {
|
||||||
|
GlobalScope.launch {
|
||||||
|
try {
|
||||||
|
// Get all unread messages and mark them as read
|
||||||
|
val messageIds = ApiClient.http
|
||||||
|
.get("${Config.apiBaseUrl}/messages/new")
|
||||||
|
.body<MessagesResponse>()
|
||||||
|
.messages
|
||||||
|
.map { it.id }
|
||||||
|
|
||||||
|
if (messageIds.isNotEmpty()) {
|
||||||
|
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(mapOf("messageIds" to messageIds))
|
||||||
|
}
|
||||||
|
Log.d("MainActivity", "Marked ${messageIds.size} messages as read: $messageIds")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Failed to mark messages as read", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkGooglePlayServices(): Boolean {
|
||||||
|
with (GoogleApiAvailability.getInstance()) {
|
||||||
|
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
|
||||||
|
|
||||||
|
if (resultCode == ConnectionResult.SUCCESS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserResolvableError(resultCode)) {
|
||||||
|
getErrorDialog(
|
||||||
|
this@MainActivity,
|
||||||
|
resultCode,
|
||||||
|
9000
|
||||||
|
)?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Handle initial intent
|
||||||
|
handleIntent(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
|
scrollToMessageId = scrollToMessageId,
|
||||||
|
startAtPublicChat = startAtPublicChat
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGooglePlayServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
prevIsPublicChatVisible = isPublicChatVisible
|
||||||
|
isPublicChatVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isPublicChatVisible = prevIsPublicChatVisible ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
|
suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val pending = settings.getString("pending_fcm_token", "")
|
||||||
|
|
||||||
|
// Only upload if we have auth token
|
||||||
|
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
||||||
|
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(ApiClient.json.encodeToString(mapOf("token" to pending)))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.remove("pending_fcm_token")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
|
Log.d("FromChatFCM", "onMessageReceived: from=${remoteMessage.from}, data=${remoteMessage.data}")
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
NotificationHelper.fetchAndNotify(applicationContext)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Log.d("FromChatFCM", "onNewToken: $token")
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
// Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login)
|
||||||
|
try {
|
||||||
|
val authToken = ApiClient.token
|
||||||
|
if (!authToken.isNullOrEmpty()) {
|
||||||
|
// Call backend endpoint to register token
|
||||||
|
try {
|
||||||
|
val resp = ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
|
||||||
|
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
||||||
|
}
|
||||||
|
Log.d("FromChatFCM", "Uploaded FCM token to server: ${resp.status.value}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "Failed to upload token: ${e.message}", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Save to shared preferences for later upload (best-effort)
|
||||||
|
runCatching {
|
||||||
|
settings.putString("pending_fcm_token", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onNewToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.MainActivity
|
||||||
|
import ru.fromchat.R
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.MessagesResponse
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
private const val CHANNEL_ID = "fromchat_messages"
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
|
||||||
|
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||||
|
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||||
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
|
|
||||||
|
private fun createMessageIntent(context: Context, messageId: Int) = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
messageId,
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra("scroll_to_message_id", messageId)
|
||||||
|
putExtra("mark_message_read", true)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
SUMMARY_NOTIFICATION_ID,
|
||||||
|
Intent(context, NotificationReplyReceiver::class.java).apply {
|
||||||
|
action = "ru.fromchat.NOTIFICATION_REPLY"
|
||||||
|
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
(context
|
||||||
|
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
).createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "FromChat message notifications"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAndNotify(context: Context) {
|
||||||
|
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val messages = ApiClient.http
|
||||||
|
.get("${Config.apiBaseUrl}/messages/new")
|
||||||
|
.body<MessagesResponse>()
|
||||||
|
.messages
|
||||||
|
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages")
|
||||||
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
|
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||||
|
|
||||||
|
// Display notifications on main thread
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
createChannel(context)
|
||||||
|
displayNotifications(context, messages)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||||
|
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||||
|
|
||||||
|
// Don't show notifications if user is currently viewing the public chat
|
||||||
|
if (isPublicChatVisible) {
|
||||||
|
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch {
|
||||||
|
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||||
|
var newMessageCount = 0
|
||||||
|
|
||||||
|
with(NotificationManagerCompat.from(context)) {
|
||||||
|
if (
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
// Find new messages that are not from the current user
|
||||||
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
|
|
||||||
|
if (currentUserId == -1) return@launch
|
||||||
|
|
||||||
|
val newMessages = messages
|
||||||
|
.filter { msg ->
|
||||||
|
!shown.contains(msg.id.toString()) && // Not already shown
|
||||||
|
msg.user_id != currentUserId // Not from current user
|
||||||
|
}
|
||||||
|
.ifEmpty { return@launch }
|
||||||
|
.apply { forEach { shown.add(it.id.toString()) } }
|
||||||
|
|
||||||
|
newMessageCount = newMessages.size
|
||||||
|
|
||||||
|
notify(
|
||||||
|
SUMMARY_NOTIFICATION_ID,
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.logo_big)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.MessagingStyle(
|
||||||
|
Person.Builder().setName("FromChat").build()
|
||||||
|
).setConversationTitle("Public Chat").let {
|
||||||
|
for (msg in messages.takeLast(10)) {
|
||||||
|
val timestamp = try {
|
||||||
|
java.time.Instant.parse(msg.timestamp).toEpochMilli()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
it.addMessage(
|
||||||
|
NotificationCompat.MessagingStyle.Message(
|
||||||
|
msg.content,
|
||||||
|
timestamp,
|
||||||
|
Person.Builder()
|
||||||
|
.setName(msg.username)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
android.R.drawable.ic_menu_send,
|
||||||
|
"Reply",
|
||||||
|
createReplyIntent(context)
|
||||||
|
)
|
||||||
|
.addRemoteInput(
|
||||||
|
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||||
|
.setLabel("Reply to chat...")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setAllowGeneratedReplies(true)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
android.R.drawable.ic_menu_view,
|
||||||
|
"View Chat",
|
||||||
|
createMessageIntent(
|
||||||
|
context,
|
||||||
|
newMessages.last().id
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||||
|
Log.d("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let {
|
||||||
|
if (it.isNotBlank()) {
|
||||||
|
Log.d("NotificationReply", "Received reply: $it")
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
ApiClient.sendMessage(it)
|
||||||
|
Log.d("NotificationReply", "Reply sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NotificationReply", "Failed to send reply", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.5</string>
|
<string>0.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>025</string>
|
<string>03</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package ru.fromchat.api
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import io.ktor.client.plugins.logging.Logger
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom Android logger for Ktor that uses Android's Log system
|
|
||||||
*/
|
|
||||||
object AndroidKtorLogger : Logger {
|
|
||||||
private const val TAG = "Ktor"
|
|
||||||
|
|
||||||
override fun log(message: String) {
|
|
||||||
Log.d(TAG, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
|
actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val pending = settings.getString("pending_fcm_token", "")
|
||||||
|
|
||||||
|
// Only upload if we have auth token
|
||||||
|
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
||||||
|
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(ApiClient.json.encodeToString(mapOf("token" to pending)))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.remove("pending_fcm_token")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.pr0gramm3r101.utils.settings.secureSettings
|
|||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import io.ktor.client.plugins.HttpResponseValidator
|
import io.ktor.client.plugins.HttpResponseValidator
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.plugins.defaultRequest
|
import io.ktor.client.plugins.defaultRequest
|
||||||
@@ -21,9 +22,12 @@ import io.ktor.client.request.setBody
|
|||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -53,10 +57,9 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install(WebSockets) {
|
install(WebSockets) {
|
||||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
pingInterval = 5000.milliseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default auth header for all requests
|
|
||||||
defaultRequest {
|
defaultRequest {
|
||||||
token?.let { authToken ->
|
token?.let { authToken ->
|
||||||
bearerAuth(authToken)
|
bearerAuth(authToken)
|
||||||
@@ -66,22 +69,21 @@ object ApiClient {
|
|||||||
// Handle HTTP errors and auth errors globally
|
// Handle HTTP errors and auth errors globally
|
||||||
HttpResponseValidator {
|
HttpResponseValidator {
|
||||||
validateResponse { response ->
|
validateResponse { response ->
|
||||||
// Handle auth errors
|
|
||||||
if (response.status.value == 401 || response.status.value == 403) {
|
if (response.status.value == 401 || response.status.value == 403) {
|
||||||
// Clear invalid token and notify about auth error
|
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
onAuthError?.invoke()
|
onAuthError?.let {
|
||||||
|
MainScope().launch {
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow WebSocket upgrade responses (101 Switching Protocols)
|
if (response.status.value !in (200..299) + 101) {
|
||||||
if (response.status.value == 101) {
|
throw ClientRequestException(
|
||||||
return@validateResponse
|
response,
|
||||||
}
|
response.status.description
|
||||||
|
)
|
||||||
// Throw exception for non-2xx status codes (like failOnError())
|
|
||||||
if (response.status.value !in 200..299) {
|
|
||||||
throw io.ktor.client.plugins.ClientRequestException(response, response.status.description)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,13 +127,26 @@ object ApiClient {
|
|||||||
user = it.user
|
user = it.user
|
||||||
secureSettings.putString("auth_token", it.token)
|
secureSettings.putString("auth_token", it.token)
|
||||||
settings.putString("user_info", json.encodeToString(it.user))
|
settings.putString("user_info", json.encodeToString(it.user))
|
||||||
|
settings.putInt("current_user_id", it.user.id)
|
||||||
|
// Upload any pending FCM token after successful login
|
||||||
|
MainScope().launch {
|
||||||
|
runCatching {
|
||||||
|
uploadPendingFcmTokenIfAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegisterRequest) =
|
suspend fun register(request: RegisterRequest) =
|
||||||
http
|
http.post("${Config.apiBaseUrl}/register") {
|
||||||
.post("${Config.apiBaseUrl}/register") {
|
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
|
}.also {
|
||||||
|
// Upload any pending FCM token after successful registration
|
||||||
|
MainScope().launch {
|
||||||
|
runCatching {
|
||||||
|
uploadPendingFcmTokenIfAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||||
@@ -143,56 +158,45 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body<MessagesResponse>()
|
.body<MessagesResponse>()
|
||||||
|
|
||||||
suspend fun send(message: String) =
|
|
||||||
http
|
|
||||||
.post("${Config.apiBaseUrl}/send_message") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(SendMessageRequest(message))
|
|
||||||
}
|
|
||||||
.body<SendMessageResponse>()
|
|
||||||
|
|
||||||
// Validate token by fetching user profile
|
// Validate token by fetching user profile
|
||||||
suspend fun validateToken(): Boolean {
|
suspend fun validateToken(): Boolean {
|
||||||
try {
|
try {
|
||||||
http
|
http
|
||||||
.get("${Config.apiBaseUrl}/api/user/profile")
|
.get("${Config.apiBaseUrl}/api/user/profile")
|
||||||
return true // Token is valid if no exception thrown
|
return true // Token is valid if no exception thrown
|
||||||
} catch (e: io.ktor.client.plugins.ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
// Check if it's an auth error (401/403)
|
|
||||||
if (e.response.status.value == 401 || e.response.status.value == 403) {
|
if (e.response.status.value == 401 || e.response.status.value == 403) {
|
||||||
return false // Token is invalid
|
return false
|
||||||
}
|
|
||||||
// For other HTTP errors, re-throw (don't treat as token invalid)
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// For network/other errors, re-throw (don't treat as token invalid)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
try {
|
runCatching {
|
||||||
http.get("${Config.apiBaseUrl}/logout")
|
http.get("${Config.apiBaseUrl}/logout")
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore logout errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secureSettings.remove("auth_token")
|
secureSettings.remove("auth_token")
|
||||||
settings.remove("user_info")
|
settings.remove("user_info")
|
||||||
|
settings.remove("current_user_id")
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||||
|
|
||||||
// WebSocket send helpers
|
// WebSocket send helpers
|
||||||
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
|
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
|
||||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "sendMessage",
|
type = "sendMessage",
|
||||||
credentials = WebSocketCredentials(
|
credentials = WebSocketCredentials(
|
||||||
scheme = "Bearer",
|
scheme = "Bearer",
|
||||||
credentials = token
|
credentials = getTokenSafely()
|
||||||
),
|
),
|
||||||
data = json.encodeToJsonElement(
|
data = json.encodeToJsonElement(
|
||||||
WebSocketSendMessageRequest(
|
WebSocketSendMessageRequest(
|
||||||
@@ -206,13 +210,12 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun editMessage(messageId: Int, content: String) {
|
suspend fun editMessage(messageId: Int, content: String) {
|
||||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "editMessage",
|
type = "editMessage",
|
||||||
credentials = WebSocketCredentials(
|
credentials = WebSocketCredentials(
|
||||||
scheme = "Bearer",
|
scheme = "Bearer",
|
||||||
credentials = token
|
credentials = getTokenSafely()
|
||||||
),
|
),
|
||||||
data = json.encodeToJsonElement(
|
data = json.encodeToJsonElement(
|
||||||
WebSocketEditMessageRequest(
|
WebSocketEditMessageRequest(
|
||||||
@@ -225,13 +228,12 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteMessage(messageId: Int) {
|
suspend fun deleteMessage(messageId: Int) {
|
||||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "deleteMessage",
|
type = "deleteMessage",
|
||||||
credentials = WebSocketCredentials(
|
credentials = WebSocketCredentials(
|
||||||
scheme = "Bearer",
|
scheme = "Bearer",
|
||||||
credentials = token
|
credentials = getTokenSafely()
|
||||||
),
|
),
|
||||||
data = json.encodeToJsonElement(
|
data = json.encodeToJsonElement(
|
||||||
WebSocketDeleteMessageRequest(
|
WebSocketDeleteMessageRequest(
|
||||||
@@ -243,38 +245,30 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendTyping() {
|
suspend fun sendTyping() {
|
||||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
runCatching {
|
||||||
try {
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "typing",
|
type = "typing",
|
||||||
credentials = WebSocketCredentials(
|
credentials = WebSocketCredentials(
|
||||||
scheme = "Bearer",
|
scheme = "Bearer",
|
||||||
credentials = token
|
credentials = getTokenSafely()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
|
||||||
// Silently ignore if WebSocket is not connected yet
|
|
||||||
// Typing indicators are not critical
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendStopTyping() {
|
suspend fun sendStopTyping() {
|
||||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
runCatching {
|
||||||
try {
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "stopTyping",
|
type = "stopTyping",
|
||||||
credentials = WebSocketCredentials(
|
credentials = WebSocketCredentials(
|
||||||
scheme = "Bearer",
|
scheme = "Bearer",
|
||||||
credentials = token
|
credentials = getTokenSafely()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
|
||||||
// Silently ignore if WebSocket is not connected yet
|
|
||||||
// Typing indicators are not critical
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
expect suspend fun uploadPendingFcmTokenIfAvailable()
|
||||||
@@ -33,7 +33,7 @@ import ru.fromchat.ui.setup.ServerConfigScreen
|
|||||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||||
var startDestination by remember { mutableStateOf<String?>(null) }
|
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -46,10 +46,11 @@ fun App() {
|
|||||||
|
|
||||||
// Now determine start destination based on loaded token
|
// Now determine start destination based on loaded token
|
||||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||||
startDestination = if (hasToken) "chat" else "login"
|
startDestination = when {
|
||||||
|
hasToken && startAtPublicChat -> "chats/publicChat"
|
||||||
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
|
hasToken && !startAtPublicChat -> "chat"
|
||||||
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
|
else -> "login"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe lifecycle events to manage WebSocket connection
|
// Observe lifecycle events to manage WebSocket connection
|
||||||
@@ -77,7 +78,17 @@ fun App() {
|
|||||||
|
|
||||||
FromChatTheme {
|
FromChatTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val animationSpec = tween<IntOffset>(400)
|
|
||||||
|
// Handle navigation to public chat when requested (e.g., from notification)
|
||||||
|
LaunchedEffect(startAtPublicChat) {
|
||||||
|
if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
||||||
|
navController.navigate("chats/publicChat") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Set up global auth error handler
|
// Set up global auth error handler
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
@@ -93,6 +104,8 @@ fun App() {
|
|||||||
LocalNavController provides navController
|
LocalNavController provides navController
|
||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
|
val animationSpec = tween<IntOffset>(400)
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination!!,
|
startDestination = startDestination!!,
|
||||||
@@ -153,7 +166,7 @@ fun App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("chats/publicChat") {
|
composable("chats/publicChat") {
|
||||||
PublicChatScreen()
|
PublicChatScreen(scrollToMessageId = scrollToMessageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("about") {
|
composable("about") {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
var isPublicChatVisible = false
|
||||||
@@ -174,7 +174,9 @@ fun LoginScreen(
|
|||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
},
|
},
|
||||||
onSuccess = { onLoginSuccess() }
|
onSuccess = {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
ApiClient.login(LoginRequest(username.trim(), derived))
|
ApiClient.login(LoginRequest(username.trim(), derived))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ fun RegisterScreen(
|
|||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
},
|
},
|
||||||
onSuccess = { onRegistered() }
|
onSuccess = {
|
||||||
|
onRegistered()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
ApiClient.register(
|
ApiClient.register(
|
||||||
RegisterRequest(
|
RegisterRequest(
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ import ru.fromchat.ui.LocalNavController
|
|||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
panel: ChatPanel,
|
panel: ChatPanel,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
scrollToMessageId: Int? = null
|
||||||
) {
|
) {
|
||||||
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
|
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
|
||||||
|
|
||||||
@@ -105,6 +106,22 @@ fun ChatScreen(
|
|||||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll to specific message when requested (e.g., from notification click)
|
||||||
|
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
||||||
|
scrollToMessageId?.let { messageId ->
|
||||||
|
val messages = panelState.messages
|
||||||
|
val messageIndex = messages.indexOfFirst { it.id == messageId }
|
||||||
|
if (messageIndex != -1) {
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(
|
||||||
|
index = messages.size - 1 - messageIndex,
|
||||||
|
scrollOffset = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
||||||
@@ -307,16 +324,14 @@ fun ChatScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
|
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
|
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
|
||||||
reverseLayout = true
|
|
||||||
) {
|
) {
|
||||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = panelState.messages.reversed(),
|
items = panelState.messages,
|
||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { message ->
|
) { message ->
|
||||||
val isAuthor = message.user_id == currentUserId
|
|
||||||
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||||
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
|
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||||
|
|
||||||
@@ -332,7 +347,7 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
MessageItem(
|
MessageItem(
|
||||||
message = message,
|
message = message,
|
||||||
isAuthor = isAuthor,
|
isAuthor = message.user_id == currentUserId,
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
contextMenuState = ContextMenuState(
|
contextMenuState = ContextMenuState(
|
||||||
isOpen = true,
|
isOpen = true,
|
||||||
@@ -350,7 +365,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PublicChatScreen() {
|
fun PublicChatScreen(scrollToMessageId: Int? = null) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
|
|
||||||
@@ -28,10 +30,19 @@ fun PublicChatScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track visibility for notifications
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
isPublicChatVisible = true
|
||||||
|
onDispose {
|
||||||
|
isPublicChatVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render with ChatScreen
|
// Render with ChatScreen
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
currentUserId = currentUserId
|
currentUserId = currentUserId,
|
||||||
|
scrollToMessageId = scrollToMessageId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
actual suspend fun uploadPendingFcmTokenIfAvailable() {}
|
||||||
@@ -9,3 +9,14 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure google-services plugin is available to subprojects that apply it.
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath(libs.google.services)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ compose-multiplatform = "1.9.3"
|
|||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.6.1-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
|
firebaseMessaging = "25.0.1"
|
||||||
|
googleServices = "4.4.4"
|
||||||
haze = "1.7.1"
|
haze = "1.7.1"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
@@ -30,6 +32,7 @@ slf4j = "1.7.36"
|
|||||||
kotlinxDatetime = "0.7.1"
|
kotlinxDatetime = "0.7.1"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2025.12.01"
|
||||||
|
playServicesBase = "18.9.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
@@ -39,6 +42,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
|
|||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
|
||||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
|
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
|
||||||
constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
|
constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
|
||||||
|
firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
|
||||||
|
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||||
jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||||
@@ -74,6 +79,7 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph
|
|||||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -84,3 +90,4 @@ android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.
|
|||||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
||||||
|
google-services = { id = "com.google.gms.google-services" }
|
||||||
Reference in New Issue
Block a user