4 Commits

43 changed files with 1295 additions and 237 deletions
+44 -7
View File
@@ -4,7 +4,7 @@ alwaysApply: true
When working with the mobile app: When working with the mobile app:
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors. - After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
# ULTIMATE SILENCE & EFFICIENCY POLICY # ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
@@ -15,11 +15,48 @@ When working with the mobile app:
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls. - THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks. - MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try. - FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
- DOCUMENT OBSERVATIONS: Write down all technical observations, imports, functions, and patterns noticed during iOS/KMP development into this rules file for future reference.
- DO NOT clean any caches.
# Java Runtime Configuration (Android) # iOS & KMP OBSERVATIONS
- **On Windows:** ## iOS Platform Imports
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr` - `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
- **On macOS:** - `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home` - `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file. - `kotlinx.cinterop.*` - C interop utilities (memScoped, alloc, ptr, value, etc.)
- `kotlinx.coroutines.*` - Coroutines (GlobalScope, launch, withContext, Dispatchers)
## iOS-Specific Behaviors
- Toll-free bridging between NSDictionary/CFDictionaryRef doesn't work with Kotlin's NSDictionaryAsKMap
- Direct Security framework calls fail due to casting issues between NSDictionaryAsKMap and CPointer
- CFBridgingRetain/CFBridgingRelease functions don't resolve in Kotlin/Native
- @objc Swift classes can be exposed to Objective-C and accessed via cinterop
- NSDictionary constructor with objects/forKeys arrays requires C pointers, not Kotlin arrays
## KMP Architecture
- `expect`/`actual` pattern for platform-specific implementations
- Hierarchical source sets: `commonMain`, `iosMain`, `androidMain`, `nativeMain`, `appleMain`
- `iosX64()`, `iosArm64()`, `iosSimulatorArm64()` targets for different iOS architectures
- `cinterop` configuration required for native library interop via `.def` files
- `kotlin.mpp.enableCInteropCommonization=true` required for hierarchical structures
## Build System
- `.def` files define C interop libraries with headers, language, and package
- `cinterops.create("name")` or `val name by cinterops.creating` for cinterop setup
- `definitionFile.set(file("path"))` to specify .def file location
- Different HTTP clients: `io.ktor.client.engine.darwin.Darwin` for iOS, `okhttp` for Android
## Runtime Patterns
- `@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)` for experimental C interop
- `@DelicateCoroutinesApi` annotation for GlobalScope.launch
- `memScoped { }` for memory-safe C interop operations
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
- Platform-specific logging with `ru.fromchat.core.Logger`
## C Interop Gotchas
- NSDictionaryAsKMap (Kotlin's internal NSDictionary wrapper) ≠ NSDictionary (Foundation object)
- Security framework expects strict CFDictionaryRef types, not NSDictionary toll-free bridging
- C interop paths must be relative to the .def file location or use compilerOpts
- Complex Objective-C frameworks like Security are unreliable with direct Kotlin interop
- Use Swift → Objective-C → Kotlin cinterop chain for complex native operations
+1
View File
@@ -24,3 +24,4 @@ release
*.xcuserstate *.xcuserstate
xcuserdata xcuserdata
google-services.json
+7
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.google.services)
} }
kotlin { kotlin {
@@ -111,6 +112,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.play.services.base)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.adaptive.android) implementation(libs.androidx.adaptive.android)
@@ -118,7 +120,12 @@ dependencies {
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.slf4j.android) implementation(libs.slf4j.android)
implementation(libs.material) implementation(libs.material)
implementation(libs.firebase.messaging)
implementation(libs.kotlinx.serialization.json)
implementation(project(":app:shared")) implementation(project(":app:shared"))
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
} }
// Process `google-services.json` into resources so Firebase initializes automatically.
apply(plugin = "com.google.gms.google-services")
+16 -1
View File
@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -25,5 +25,20 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".fcm.FromChatFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".notifications.NotificationReplyReceiver"
android:exported="false">
<intent-filter>
<action android:name="ru.fromchat.NOTIFICATION_REPLY" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -1,11 +1,113 @@
package ru.fromchat package ru.fromchat
import android.app.Application import android.app.Application
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
class App: Application() { class App: Application() {
@OptIn(DelicateCoroutinesApi::class)
private fun fetchAndNotify() {
GlobalScope.launch(Dispatchers.IO) {
runCatching {
NotificationHelper.fetchAndNotify(applicationContext)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
UtilsLibrary.init(this) UtilsLibrary.init(this)
WebSocketManager.addGlobalMessageHandler { msg ->
runCatching {
if (msg.type == "newMessage") {
fetchAndNotify()
} else if (msg.type == "updates") {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
var shouldFetch = false
for (item in updates) {
if (
item
.jsonObject["type"]
?.jsonPrimitive
?.content
in arrayOf("newMessage", "dmNew")
) {
shouldFetch = true
break
}
}
if (shouldFetch) fetchAndNotify()
}
}
}
}
GlobalScope.launch(Dispatchers.IO) {
runCatching {
ApiClient.loadPersistedData()
}
runCatching {
uploadPendingFcmTokenIfAvailable()
}
// If we have an auth token, try to get current FCM token and register it immediately
runCatching {
val auth = ApiClient.token
if (!auth.isNullOrEmpty()) {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
GlobalScope.launch(Dispatchers.IO) {
if (task.isSuccessful) {
try {
val resp = ApiClient.http.post(
"${Config.apiBaseUrl}/push/register"
) {
header("Content-Type", "application/json")
setBody(
ApiClient.json.encodeToString(
mapOf("token" to task.result)
)
)
}
Log.d(
"AppFCM",
"Registered existing FCM token on startup, status=${resp.status.value}"
)
} catch (e: Exception) {
Log.e(
"AppFCM",
"Failed to register FCM token on startup: ${e.message}"
)
}
} else {
Log.w(
"AppFCM",
"FirebaseMessaging token fetch failed on startup: ${task.exception?.message}"
)
}
}
}
}
}
}
} }
} }
@@ -1,20 +1,133 @@
package ru.fromchat package ru.fromchat
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var scrollToMessageId by mutableStateOf<Int?>(null)
private var startAtPublicChat by mutableStateOf(false)
private var prevIsPublicChatVisible: Boolean? = null
private fun handleIntent(intent: Intent?) {
val messageId = intent?.getIntExtra("scroll_to_message_id", -1) ?: -1
scrollToMessageId = if (messageId != -1) messageId else null
startAtPublicChat = messageId != -1
// Mark messages as read if clicked from notification
if (intent?.getBooleanExtra("mark_message_read", false) == true) {
markMessagesAsRead()
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun markMessagesAsRead() {
GlobalScope.launch {
try {
// Get all unread messages and mark them as read
val messageIds = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.map { it.id }
if (messageIds.isNotEmpty()) {
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(mapOf("messageIds" to messageIds))
}
Log.d("MainActivity", "Marked ${messageIds.size} messages as read: $messageIds")
}
} catch (e: Exception) {
Log.e("MainActivity", "Failed to mark messages as read", e)
}
}
}
private fun checkGooglePlayServices(): Boolean {
with (GoogleApiAvailability.getInstance()) {
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
if (resultCode == ConnectionResult.SUCCESS) {
return true
}
if (isUserResolvableError(resultCode)) {
getErrorDialog(
this@MainActivity,
resultCode,
9000
)?.show()
}
return false
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
// Handle initial intent
handleIntent(intent)
setContent { setContent {
App() App(
} scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat
)
}
// Request POST_NOTIFICATIONS permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
}
checkGooglePlayServices()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onPause() {
super.onPause()
prevIsPublicChatVisible = isPublicChatVisible
isPublicChatVisible = false
}
override fun onResume() {
super.onResume()
isPublicChatVisible = prevIsPublicChatVisible ?: false
} }
} }
@@ -0,0 +1,36 @@
package ru.fromchat.fcm
import android.util.Log
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
try {
val pending = settings.getString("pending_fcm_token", "")
// Only upload if we have auth token
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
return@withContext
}
try {
ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
header("Content-Type", "application/json")
setBody(ApiClient.json.encodeToString(mapOf("token" to pending)))
}
settings.remove("pending_fcm_token")
} catch (e: Exception) {
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
}
} catch (e: Exception) {
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
}
}
@@ -0,0 +1,62 @@
package ru.fromchat.fcm
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
import ru.fromchat.notifications.NotificationHelper
@OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d("FromChatFCM", "onMessageReceived: from=${remoteMessage.from}, data=${remoteMessage.data}")
GlobalScope.launch(Dispatchers.IO) {
try {
NotificationHelper.fetchAndNotify(applicationContext)
} catch (e: Exception) {
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
}
}
}
override fun onNewToken(token: String) {
Log.d("FromChatFCM", "onNewToken: $token")
GlobalScope.launch(Dispatchers.IO) {
// Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login)
try {
val authToken = ApiClient.token
if (!authToken.isNullOrEmpty()) {
// Call backend endpoint to register token
try {
val resp = ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
}
Log.d("FromChatFCM", "Uploaded FCM token to server: ${resp.status.value}")
} catch (e: Exception) {
Log.e("FromChatFCM", "Failed to upload token: ${e.message}", e)
}
} else {
// Save to shared preferences for later upload (best-effort)
runCatching {
settings.putString("pending_fcm_token", token)
}
}
} catch (e: Exception) {
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
}
super.onNewToken(token)
}
}
}
@@ -0,0 +1,205 @@
package ru.fromchat.notifications
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.MainActivity
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.ui.isPublicChatVisible
object NotificationHelper {
private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
const val KEY_TEXT_REPLY = "key_text_reply"
private fun createMessageIntent(context: Context, messageId: Int) = PendingIntent.getActivity(
context,
messageId,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("scroll_to_message_id", messageId)
putExtra("mark_message_read", true)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
context,
SUMMARY_NOTIFICATION_ID,
Intent(context, NotificationReplyReceiver::class.java).apply {
action = "ru.fromchat.NOTIFICATION_REPLY"
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "FromChat message notifications"
}
)
}
}
suspend fun fetchAndNotify(context: Context) {
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
try {
val messages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages")
if (messages.isEmpty()) return
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
// Display notifications on main thread
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages)
}
} catch (e: Exception) {
Log.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) {
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) {
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
GlobalScope.launch {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
// Find new messages that are not from the current user
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) && // Not already shown
msg.user_id != currentUserId // Not from current user
}
.ifEmpty { return@launch }
.apply { forEach { shown.add(it.id.toString()) } }
newMessageCount = newMessages.size
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let {
for (msg in messages.takeLast(10)) {
val timestamp = try {
java.time.Instant.parse(msg.timestamp).toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
it.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.content,
timestamp,
Person.Builder()
.setName(msg.username)
.build()
)
)
}
it
}
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
createReplyIntent(context)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_view,
"View Chat",
createMessageIntent(
context,
newMessages.last().id
)
).build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.build()
)
}
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
Log.d("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
}
}
}
@@ -0,0 +1,32 @@
package ru.fromchat.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.RemoteInput
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
@OptIn(DelicateCoroutinesApi::class)
class NotificationReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let {
if (it.isNotBlank()) {
Log.d("NotificationReply", "Received reply: $it")
GlobalScope.launch(Dispatchers.IO) {
try {
ApiClient.sendMessage(it)
Log.d("NotificationReply", "Reply sent successfully")
} catch (e: Exception) {
Log.e("NotificationReply", "Failed to send reply", e)
}
}
}
}
}
}
@@ -0,0 +1,16 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z"
android:fillColor="#fff" />
<path
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z"
android:fillColor="#fff" />
<path
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z"
android:fillColor="#fff" />
</vector>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.2.4</string> <string>0.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>024</string> <string>03</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
+1
View File
@@ -54,6 +54,7 @@ kotlin {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx)
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
@@ -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}")
}
}
@@ -1,8 +1,13 @@
package ru.fromchat.api package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.secureSettings
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.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
@@ -17,10 +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.utils.failOnError 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
@@ -50,7 +57,35 @@ object ApiClient {
} }
install(WebSockets) { install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive pingInterval = 5000.milliseconds
}
defaultRequest {
token?.let { authToken ->
bearerAuth(authToken)
}
}
// Handle HTTP errors and auth errors globally
HttpResponseValidator {
validateResponse { response ->
if (response.status.value == 401 || response.status.value == 403) {
token = null
user = null
onAuthError?.let {
MainScope().launch {
it()
}
}
}
if (response.status.value !in (200..299) + 101) {
throw ClientRequestException(
response,
response.status.description
)
}
}
} }
} }
@@ -60,69 +95,108 @@ object ApiClient {
@Volatile @Volatile
var user: User? = null var user: User? = null
// Global auth error handler
var onAuthError: (() -> Unit)? = null
// Load persisted token and user info
suspend fun loadPersistedData() {
try {
val savedToken = secureSettings.getString("auth_token", "")
token = savedToken
if (!token.isNullOrEmpty()) {
val userInfo = settings.getString("user_info", "")
if (userInfo.isNotEmpty()) {
user = json.decodeFromString(userInfo)
}
}
} catch (e: Exception) {
ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e)
}
}
suspend fun login(request: LoginRequest) = suspend fun login(request: LoginRequest) =
http http
.post("${Config.apiBaseUrl}/login") { .post("${Config.apiBaseUrl}/login") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") }) setBody(request)
} }
.failOnError()
.body<LoginResponse>() .body<LoginResponse>()
.also { .also {
token = it.token token = it.token
user = it.user 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) = 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()
}
}
} }
.failOnError()
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http http
.get("${Config.apiBaseUrl}/get_messages") { .get("${Config.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit) parameter("limit", limit)
beforeId?.let { parameter("before_id", it) } beforeId?.let { parameter("before_id", it) }
} }
.failOnError()
.body<MessagesResponse>() .body<MessagesResponse>()
suspend fun send(message: String) = // Validate token by fetching user profile
http suspend fun validateToken(): Boolean {
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
try { try {
http.get("${Config.apiBaseUrl}/logout") { http
bearerAuth(authToken) .get("${Config.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown
} catch (e: ClientRequestException) {
if (e.response.status.value == 401 || e.response.status.value == 403) {
return false
} }
throw e
} catch (e: Exception) { } catch (e: Exception) {
// Ignore logout errors throw e
} }
} }
suspend fun logout() {
runCatching {
http.get("${Config.apiBaseUrl}/logout")
}
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 // 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(
@@ -136,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(
@@ -155,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(
@@ -173,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
} }
} }
} }
@@ -13,8 +13,11 @@ object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null) private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow() val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config private val config: ServerConfigData get() {
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized") if (_serverConfig.value == null) initialize()
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
}
/** /**
* Initialize configuration by loading from storage * Initialize configuration by loading from storage
@@ -0,0 +1,3 @@
package ru.fromchat.fcm
expect suspend fun uploadPendingFcmTokenIfAvailable()
@@ -8,6 +8,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -17,6 +21,7 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
@@ -25,14 +30,27 @@ import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("") } 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) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { runCatching {
Config.initialize() Config.initialize()
} }
// Load persisted token and user data
ApiClient.loadPersistedData()
// Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = when {
hasToken && startAtPublicChat -> "chats/publicChat"
hasToken && !startAtPublicChat -> "chat"
else -> "login"
}
} }
// Observe lifecycle events to manage WebSocket connection // Observe lifecycle events to manage WebSocket connection
@@ -60,14 +78,37 @@ 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
LaunchedEffect(navController) {
ApiClient.onAuthError = {
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController LocalNavController provides navController
) { ) {
if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "login", startDestination = startDestination!!,
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
Start, Start,
@@ -125,7 +166,7 @@ fun App() {
} }
composable("chats/publicChat") { composable("chats/publicChat") {
PublicChatScreen() PublicChatScreen(scrollToMessageId = scrollToMessageId)
} }
composable("about") { composable("about") {
@@ -135,3 +176,4 @@ fun App() {
} }
} }
} }
}
@@ -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
) )
} }
@@ -208,9 +208,7 @@ fun SettingsTab(
scope.launch { scope.launch {
// Logout // Logout
try { try {
ApiClient.token?.let { token -> ApiClient.logout()
ApiClient.logout(token)
}
} catch (e: Exception) { } catch (e: Exception) {
// Ignore logout errors // Ignore logout errors
} }
@@ -152,9 +152,7 @@ fun ServerConfigScreen() {
// Ensure we're logged out when server config changes // Ensure we're logged out when server config changes
runCatching { runCatching {
ApiClient.token?.let { token -> ApiClient.logout()
ApiClient.logout(token)
}
} }
// Clear API client state // Clear API client state
@@ -4,10 +4,33 @@ import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.engine.darwin.DarwinClientEngineConfig import io.ktor.client.engine.darwin.DarwinClientEngineConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.headers
actual fun createPlatformHttpClient( actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit block: HttpClientConfig<*>.() -> Unit
): HttpClient { ): HttpClient {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit) return HttpClient(Darwin) {
// Configure default request headers to ensure UTF-8 encoding
defaultRequest {
contentType(ContentType.Application.Json)
headers {
append("Accept-Charset", "utf-8")
append("Content-Type", "application/json; charset=utf-8")
}
}
// Add timeout configuration
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
}
// Apply the passed configuration block
block(this)
}
} }
@@ -0,0 +1,3 @@
package ru.fromchat.fcm
actual suspend fun uploadPendingFcmTokenIfAvailable() {}
+11
View File
@@ -9,3 +9,14 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
} }
// Ensure google-services plugin is available to subprojects that apply it.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath(libs.google.services)
}
}
+13
View File
@@ -8,6 +8,8 @@ compose-multiplatform = "1.9.3"
#noinspection NewerVersionAvailable #noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded" constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.1"
googleServices = "4.4.4"
haze = "1.7.1" haze = "1.7.1"
kotlin = "2.3.0" kotlin = "2.3.0"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
@@ -17,17 +19,20 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.8.2" kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2" kotlinxIoCore = "0.8.2"
multiplatformSettings = "1.3.0"
serialization = "2.3.0" serialization = "2.3.0"
serialization-json = "1.9.0" serialization-json = "1.9.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.12.2" activityKtx = "1.12.2"
navigationCompose = "2.9.1" navigationCompose = "2.9.1"
datastore = "1.2.0" datastore = "1.2.0"
security-crypto = "1.1.0-alpha06"
ktor = "3.3.3" ktor = "3.3.3"
slf4j = "1.7.36" 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" }
@@ -37,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" }
@@ -49,9 +56,13 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" } datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
@@ -68,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" }
@@ -78,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" }
+176 -109
View File
@@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
# --- Настройка и окружение --- # --- Setup ---
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
# --- Обработка аргументов --- # --- Arguments ---
IS_PRERELEASE=false IS_PRERELEASE=false
show_help() { show_help() {
@@ -33,17 +33,17 @@ for arg in "$@"; do
esac esac
done done
# --- Конфигурация цветов и оформления --- # --- Colors ---
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
MAGENTA='\033[0;35m' MAGENTA='\033[0;35m'
NC='\033[0m' # No Color NC='\033[0m'
BOLD='\033[1m' BOLD='\033[1m'
# --- Функции логирования --- # --- Logging ---
info() { echo -e "${BLUE}${NC} $1"; } info() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; } success() { echo -e "${GREEN}${NC} $1"; }
warning() { echo -e "${YELLOW}${NC} $1"; } warning() { echo -e "${YELLOW}${NC} $1"; }
@@ -53,46 +53,99 @@ substep() { echo -e " ${GREEN}•${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}" echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
# --- Настройка памяти для предотвращения OutOfMemory --- # --- 1. Version Input & Validation ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g" step "Configuration"
echo -en " ${GREEN}${NC} Release version: "
# --- Переменные Git / Версии --- read -r USER_INPUT
step "Metadata collection"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
# shellcheck disable=SC2207
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}"
substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# Regex for x, x.y, or x.y.z
VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
# shellcheck disable=SC2001 # shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g') CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
exit 1
fi
TAG="v$CLEAN_VERSION"
VERSION_STR="$CLEAN_VERSION"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$VERSION_STR" | LC_ALL=C sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1 [[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android --- # Check if tag exists
RECREATE_TAG=false
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo -n " " && warning "Tag $TAG already exists."
echo -en " ${GREEN}${NC} Delete it and move to the new commit? (y/N): "
read -r CONFIRM
if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
RECREATE_TAG=true
else
exit 0
fi
fi
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD)
STASH_MARKER="release_stash_$(date +%s)"
HAS_STASHED=false
restore_git_state() {
if [ "$HAS_STASHED" = true ]; then
STASH_ID=$(git stash list | grep "$STASH_MARKER" | head -n 1 | cut -d':' -f1)
if [[ -n "$STASH_ID" ]]; then
git stash pop "$STASH_ID" > /dev/null 2>&1 || true
fi
HAS_STASHED=false
fi
}
trap restore_git_state EXIT INT TERM
# --- 2. Git Cleanup & Safety ---
if [[ -n $(git status --short) ]]; then
git stash push --include-untracked -m "$STASH_MARKER" > /dev/null 2>&1
HAS_STASHED=true
fi
git fetch origin "$CURRENT_BRANCH" > /dev/null 2>&1
LOCAL_HASH=$(git rev-parse HEAD)
REMOTE_HASH=$(git rev-parse "origin/$CURRENT_BRANCH")
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
if ! git merge-base --is-ancestor "$REMOTE_HASH" "$LOCAL_HASH"; then
error "Remote branch has commits you don't have. Please pull first."
exit 1
fi
fi
# --- Constants ---
BUILD_DIR="$(pwd)/build"
DESC_FILE="$BUILD_DIR/.release_desc.md"
RELEASES_DIR="$(pwd)/releases"
mkdir -p "$RELEASES_DIR"
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- 3. Build Functions ---
build_android() { build_android() {
step "Building Android Release" step "Building Android Release"
if ./gradlew :app:android:assembleRelease; then if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1) APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then if [[ -f "$APK_SRC" ]]; then
mkdir -p releases
DISPLAY_NAME="FromChat-$TAG-android.apk" DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME" cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME" ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}" success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else else
error "Android APK not found after build" error "APK not found"; exit 1
exit 1
fi fi
else else
error "Android build failed" error "Android build failed"; exit 1
exit 1
fi fi
} }
# --- Сборка iOS ---
build_ios() { build_ios() {
step "Building iOS Release" step "Building iOS Release"
if [[ "$OSTYPE" != "darwin"* ]]; then if [[ "$OSTYPE" != "darwin"* ]]; then
@@ -106,129 +159,143 @@ build_ios() {
IOS_PROJECT_DIR="app/ios" IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp" [[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
substep "Setting iOS version to $BUILD_NUMBER..."
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1) PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
fi fi
substep "Xcode Archiving (Unsigned)..." substep "Xcode Archiving..."
rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
mkdir -p build/ios if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \ -scheme iOS \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-derivedDataPath build/ios \ -derivedDataPath "$BUILD_DIR/ios" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \ clean build > "$BUILD_DIR/ios/build.log" 2>&1
build > build/ios/build.log 2>&1; then then
error "iOS build failed. Check: $BUILD_DIR/ios/build.log"; exit 1
success "Xcode build successful."
else
error "Xcode build failed. Check build/ios/build.log"
exit 1
fi fi
substep "Packaging IPA for TrollStore..." substep "Packaging to ${CYAN}.ipa${NC}..."
mkdir -p build/ios/Payload APP_BUNDLE_PATH=$(find "$BUILD_DIR/ios/Build/Products/Release-iphoneos" -name "*.app" -type d | head -n 1)
APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then if [[ -n "$APP_BUNDLE_PATH" ]]; then
cp -r "$APP_PATH" build/ios/Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa" IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null IPA_PATH="$RELEASES_DIR/$IPA_NAME"
rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME" # Create Payload structure inside build dir
PAYLOAD_STAGE="$BUILD_DIR/ios/ipa_stage"
rm -rf "$PAYLOAD_STAGE" && mkdir -p "$PAYLOAD_STAGE/Payload"
# Use cp -R to dereference symlinks and copy actual files into the stage
# This is safe because it's only the final bundle
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
# Zip from the stage directory
(cd "$PAYLOAD_STAGE" && zip -r "$IPA_PATH" Payload > /dev/null 2>&1)
# Cleanup stage
rm -rf "$PAYLOAD_STAGE"
if [[ -f "$IPA_PATH" ]]; then
IOS_ASSET="$IPA_PATH"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}" success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else else
error "Could not find .app bundle" error "IPA generation failed (file not found in releases)"
exit 1 exit 1
fi fi
else
error "Could not find .app bundle"; exit 1
fi
git checkout -- "$PLIST_PATH" > /dev/null 2>&1 || true
} }
# --- Подготовка описания --- build_android
build_ios
# --- 4. Git Finalizing ---
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
git add "$PLIST_PATH" > /dev/null 2>&1
if ! git diff --cached --quiet; then
if git commit --amend --no-edit > /dev/null 2>&1; then
substep "Info.plist updated (amend)."
else
error "Failed to amend commit."
fi
fi
fi
if [[ "$RECREATE_TAG" == true ]]; then
git tag -d "$TAG" > /dev/null 2>&1 || true
git push origin :refs/tags/"$TAG" > /dev/null 2>&1 || true
fi
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "Release $TAG" > /dev/null 2>&1
fi
git push origin "$CURRENT_BRANCH" --force-with-lease --tags > /dev/null 2>&1
# --- 5. GitHub Release ---
prepare_description() { prepare_description() {
DESC_FILE=".release_desc.md" echo -e "<!-- Release Description -->" > "$DESC_FILE"
echo -e "<!--\nEnter the release description. Leave empty for no description.\n-->" > "$DESC_FILE"
# Открываем nano. Пользователь должен отредактировать файл.
nano "$DESC_FILE" nano "$DESC_FILE"
CLEAN_DESC=$(LC_ALL=C perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# Удаляем комментарии <!-- ... --> и лишние пробелы/пустые строки
# Используем perl для многострочного удаления комментариев
CLEAN_DESC=$(perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
rm -f "$DESC_FILE" rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > .final_notes.md echo "$CLEAN_DESC" > "$DESC_FILE"
NOTES_ARG=("-F" "./.final_notes.md") NOTES_ARG=("-F" "$DESC_FILE")
else else
NOTES_ARG=("--generate-notes") NOTES_ARG=("--generate-notes")
fi fi
} }
# --- Публикация в GitHub ---
publish_github() { publish_github() {
step "GitHub Deployment" step "GitHub Release"
if ! command -v gh &> /dev/null; then if ! command -v gh &> /dev/null; then return; fi
warning "GitHub CLI (gh) not found. Skipping upload."
return
fi
substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true
prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease"
fi
ASSETS=() ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET") [[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET") [[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
[ ${#ASSETS[@]} -eq 0 ] && return
if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload."
return
fi
# Подготавливаем описание перед созданием
prepare_description prepare_description
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..." substep "Editing existing release..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag" EDIT_ARGS=("--draft=false")
[[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
# Если есть кастомные ноты, обновляем их [[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
if [[ -f .final_notes.md ]]; then gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
gh release edit "$TAG" -F ./.final_notes.md substep "Uploading files..."
fi gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
gh release upload "$TAG" "${ASSETS[@]}" --clobber
else else
substep "Creating new release ${YELLOW}$TAG${NC}..." substep "Creating the release..."
# gh release create принимает либо --generate-notes, либо --notes-file PR_FLAG=""
gh release create "$TAG" \ [[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
"${NOTES_ARG[@]}" \ gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
--notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \
"${ASSETS[@]}"
fi fi
rm -f "$DESC_FILE"
rm -f .final_notes.md success "Success!"
success "Assets and notes uploaded to GitHub Release"
} }
# --- Основной процесс ---
mkdir -p releases
build_android
build_ios
publish_github publish_github
restore_git_state
trap - EXIT INT TERM
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}" echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
+11 -1
View File
@@ -40,6 +40,7 @@ kotlin {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.kotlinx.serialization.json)
} }
androidMain.dependencies { androidMain.dependencies {
@@ -49,10 +50,19 @@ kotlin {
implementation(libs.biometric) implementation(libs.biometric)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.material)
implementation(libs.security.crypto)
} }
iosMain.dependencies { iosMain.dependencies {
// nothing implementation(libs.ktor.client.core)
implementation(libs.ktor.client.darwin)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.kotlinx.datetime)
// Multiplatform settings for iOS Keychain support
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.multiplatform.settings.serialization)
} }
} }
} }
@@ -0,0 +1,83 @@
package com.pr0gramm3r101.utils.settings
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.pr0gramm3r101.utils.UtilsLibrary.context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidSecureSettings : Settings {
private val masterKey by lazy {
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val encryptedPrefs by lazy {
EncryptedSharedPreferences.create(
context,
"secure_storage",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override suspend fun putString(key: String, value: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putString(key, value) }
}
override suspend fun getString(key: String, default: String) = withContext(Dispatchers.IO) {
encryptedPrefs.getString(key, default) ?: default
}
override suspend fun putInt(key: String, value: Int) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putInt(key, value) }
}
override suspend fun getInt(key: String, default: Int) = withContext(Dispatchers.IO) {
encryptedPrefs.getInt(key, default)
}
override suspend fun putLong(key: String, value: Long) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putLong(key, value) }
}
override suspend fun getLong(key: String, default: Long) = withContext(Dispatchers.IO) {
encryptedPrefs.getLong(key, default)
}
override suspend fun putFloat(key: String, value: Float) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putFloat(key, value) }
}
override suspend fun getFloat(key: String, default: Float) = withContext(Dispatchers.IO) {
encryptedPrefs.getFloat(key, default)
}
override suspend fun putBoolean(key: String, value: Boolean) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putBoolean(key, value) }
}
override suspend fun getBoolean(key: String, default: Boolean) = withContext(Dispatchers.IO) {
encryptedPrefs.getBoolean(key, default)
}
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putStringSet(key, value) }
}
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
encryptedPrefs.getStringSet(key, default) ?: default
}
override suspend fun remove(key: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { remove(key) }
}
override suspend fun contains(key: String) = withContext(Dispatchers.IO) {
encryptedPrefs.contains(key)
}
}
@@ -79,10 +79,6 @@ class AndroidSettings(): Settings {
preferences[stringSetPreferencesKey(key)] ?: default preferences[stringSetPreferencesKey(key)] ?: default
}.first() }.first()
override suspend fun putStringList(key: String, value: List<String>) = putStringSet(key, value.toSet())
override suspend fun getStringList(key: String, default: List<String>) = getStringSet(key, default.toSet()).toList()
override suspend fun remove(key: String) { override suspend fun remove(key: String) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key)) preferences.remove(stringPreferencesKey(key))
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
object DataStoreSingleton { object DataStoreSingleton {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val dataStore: DataStore<Preferences> val dataStore get() = UtilsLibrary.context.dataStore
get() = UtilsLibrary.context.dataStore
} }
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings() actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings actual val secureSettings: Settings
get() = TODO("Not yet implemented") get() = AndroidSecureSettings()
@@ -2,6 +2,10 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
interface Settings { interface Settings {
companion object { companion object {
inline fun get() = settings inline fun get() = settings
@@ -26,8 +30,23 @@ interface Settings {
suspend fun putStringSet(key: String, value: Set<String>) suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>) suspend fun putStringList(key: String, value: List<String>) = withContext(Dispatchers.Default) {
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> putString(key, Json.encodeToString(value))
}
suspend fun getStringList(key: String, default: List<String> = emptyList()) = withContext(Dispatchers.Default) {
try {
getString(key, "").let {
if (it.isEmpty()) {
default
} else {
Json.decodeFromString<List<String>>(it)
}
}
} catch (_: Exception) {
default
}
}
suspend fun remove(key: String) suspend fun remove(key: String)
@@ -0,0 +1,46 @@
package com.pr0gramm3r101.utils.settings
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.KeychainSettings
import com.russhwolf.settings.coroutines.toSuspendSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
class IosSecureSettings : Settings {
private val suspendSettings = KeychainSettings(service = "ru.fromchat.secure").toSuspendSettings()
override suspend fun putString(key: String, value: String) = suspendSettings.putString(key, value)
override suspend fun getString(key: String, default: String) = suspendSettings.getString(key, default)
override suspend fun putInt(key: String, value: Int) = suspendSettings.putInt(key, value)
override suspend fun getInt(key: String, default: Int) = suspendSettings.getInt(key, default)
override suspend fun putLong(key: String, value: Long) = suspendSettings.putLong(key, value)
override suspend fun getLong(key: String, default: Long) = suspendSettings.getLong(key, default)
override suspend fun putFloat(key: String, value: Float) = suspendSettings.putFloat(key, value)
override suspend fun getFloat(key: String, default: Float) = suspendSettings.getFloat(key, default)
override suspend fun putBoolean(key: String, value: Boolean) = suspendSettings.putBoolean(key, value)
override suspend fun getBoolean(key: String, default: Boolean) = suspendSettings.getBoolean(key, default)
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
putStringList(key, value.toList())
}
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
getStringList(key, default.toList()).toSet()
}
override suspend fun remove(key: String) = suspendSettings.remove(key)
override suspend fun contains(key: String) = suspendSettings.hasKey(key)
}
@@ -1,10 +1,10 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
// TODO fix
class IosSettings : Settings { class IosSettings : Settings {
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
@@ -45,7 +45,7 @@ class IosSettings : Settings {
override suspend fun remove(key: String) = defaults.removeObjectForKey(key) override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) { override suspend fun contains(key: String): Boolean = withContext(Dispatchers.IO) {
defaults.objectForKey(key) != null defaults.objectForKey(key) != null
} }
} }
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings() actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings actual val secureSettings: Settings
get() = TODO("Not yet implemented") get() = IosSecureSettings()