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