13 Commits

63 changed files with 1679 additions and 913 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
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+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.1</string> <string>0.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>021</string> <string>03</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
+2 -1
View File
@@ -6,7 +6,7 @@ plugins {
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.android.kotlin.multiplatform.library)
kotlin("plugin.serialization") version "1.9.22" alias(libs.plugins.kotlin.serialization)
} }
kotlin { kotlin {
@@ -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,18 +0,0 @@
package ru.fromchat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
val DATETIME_FORMAT = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
@@ -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
} }
} }
} }
@@ -60,9 +60,7 @@ data class Message(
val reply_to: Message? = null, val reply_to: Message? = null,
val client_message_id: String? = null, val client_message_id: String? = null,
val reactions: List<ReactionData>? = null val reactions: List<ReactionData>? = null
) { )
val utcTimestamp = "${timestamp}Z"
}
@Serializable @Serializable
data class SendMessageRequest( data class SendMessageRequest(
@@ -36,8 +36,3 @@ data class TypingUpdateData(
val userId: Int, val userId: Int,
val username: String val username: String
) )
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -8,19 +8,75 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
object Settings { object Settings {
private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme"
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
private val settings = Settings() private val settings = Settings()
private fun runIO(block: suspend CoroutineScope.() -> Unit) { private fun runIO(block: suspend CoroutineScope.() -> Unit) {
CoroutineScope(Dispatchers.IO).launch(block = block) CoroutineScope(Dispatchers.IO).launch(block = block)
} }
private fun serverConfigNotInitialized(): Nothing
= throw IllegalStateException("Server config not initialized")
var materialYou: Boolean var materialYou: Boolean
get() = runBlocking { settings.getBoolean("materialYou", true) } get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
set(value) = runIO { settings.putBoolean("materialYou", value) } set(value) = runIO { settings.putBoolean(MATERIAL_YOU_KEY, value) }
var theme: Theme var theme: Theme
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] } get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt("theme", value.ordinal) } set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
var serverUrl: String
get() = runBlocking {
settings.getString(SERVER_URL_KEY).ifEmpty {
serverConfigNotInitialized()
}
}
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
var httpsEnabled: Boolean
get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY))
settings.getBoolean(HTTPS_ENABLED_KEY, true)
else serverConfigNotInitialized()
}
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
val hasServerConfig get() = try {
serverUrl
httpsEnabled
true
} catch (_: IllegalStateException) {
false
}
var serverConfig: ServerConfigData
get() {
if (!hasServerConfig) {
serverUrl = "fromchat.ru"
httpsEnabled = true
return ServerConfigData("fromchat.ru", true)
}
return ServerConfigData(serverUrl, httpsEnabled)
}
set(value) {
serverUrl = value.serverUrl
httpsEnabled = value.httpsEnabled
}
} }
@@ -1,10 +1,10 @@
package ru.fromchat.core.config package ru.fromchat.core.config
import com.pr0gramm3r101.utils.storage.ServerConfigData
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings
/** /**
* Application configuration * Application configuration
@@ -13,24 +13,24 @@ 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
*/ */
suspend fun initialize() { fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig() _serverConfig.value = Settings.serverConfig
// Set default values and prevent corruption
ServerConfigStorage.getConfig()
} }
/** /**
* Update server configuration * Update server configuration
*/ */
suspend fun updateServerConfig(config: ServerConfigData) { fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config) Settings.serverConfig = config
_serverConfig.value = config _serverConfig.value = config
} }
@@ -45,9 +45,4 @@ object Config {
*/ */
val webSocketUrl val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws" get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
} }
@@ -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") {
@@ -134,4 +175,5 @@ 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(
@@ -26,6 +26,27 @@ fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
end = Offset(1000f, 1000f) end = Offset(1000f, 1000f)
) )
/**
* Get gradient brush for own messages
*/
fun getReplyMessageGradient(isDark: Boolean) = Brush.linearGradient(
colors = if (isDark) {
listOf(
Color(0xFF5f11a6),
Color(0xFF1418da),
Color(0xFF1b3d73)
)
} else {
listOf(
Color(0xFF7836ee),
Color(0xFF2034f3),
Color(0xFF076eed)
)
},
start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/** /**
* Generate a consistent gradient from a name for avatar fallback * Generate a consistent gradient from a name for avatar fallback
*/ */
@@ -97,7 +97,7 @@ private fun PreviewBar(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 8.dp), .padding(start = 12.dp, end = 6.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -167,15 +167,12 @@ fun ChatInput(
} }
} }
Column(
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
) {
// Input field with blur
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .background(Color.Transparent)
.padding(horizontal = 8.dp, vertical = 8.dp) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
val shape = RoundedCornerShape(24.dp) val shape = RoundedCornerShape(24.dp)
@@ -272,5 +269,4 @@ fun ChatInput(
) )
} }
} }
}
} }
@@ -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) }
@@ -188,13 +205,12 @@ fun ChatScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Column( Column(Modifier.fillMaxWidth()) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = panelState.title, text = panelState.title,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
AnimatedContent( AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(), targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = { transitionSpec = {
@@ -207,9 +223,6 @@ fun ChatScreen(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} else {
// Empty space to maintain height
Box(modifier = Modifier.height(0.dp))
} }
} }
} }
@@ -319,7 +332,6 @@ fun ChatScreen(
items = panelState.messages, 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)) }
@@ -335,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,
@@ -12,9 +12,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -27,12 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
@@ -92,18 +95,11 @@ fun MessageItem(
.widthIn(max = 280.dp), .widthIn(max = 280.dp),
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) { ) {
// Reply preview
message.reply_to?.let { replyTo ->
ReplyPreview(
replyTo = replyTo,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Message bubble // Message bubble
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
Box( Box(
modifier = Modifier modifier = Modifier
.width(IntrinsicSize.Min)
.clip( .clip(
RoundedCornerShape( RoundedCornerShape(
topStart = 20.dp, topStart = 20.dp,
@@ -112,9 +108,11 @@ fun MessageItem(
bottomEnd = if (isAuthor) 8.dp else 20.dp bottomEnd = if (isAuthor) 8.dp else 20.dp
) )
) )
.then( .conditional(
if (isAuthor) { isAuthor,
Modifier.shadow( `if` = {
it
.shadow(
elevation = 8.dp, elevation = 8.dp,
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = 20.dp, topStart = 20.dp,
@@ -124,23 +122,13 @@ fun MessageItem(
), ),
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
) )
} else { .background(getMessageGradient(isDark))
Modifier },
`else` = {
background(MaterialTheme.colorScheme.surfaceContainerHighest)
} }
) )
.background( .padding(top = 6.dp)
brush = if (isAuthor) {
getMessageGradient(isDark)
} else {
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHighest
)
)
}
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
Column { Column {
// Username inside bubble (for received messages) // Username inside bubble (for received messages)
@@ -150,10 +138,59 @@ fun MessageItem(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp)
) )
} }
// Reply preview
message.reply_to?.let { replyTo ->
Box(
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.fillMaxWidth()
.height(IntrinsicSize.Min)
.conditional(
isAuthor,
`if` = {
background(getReplyMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceVariant)
}
)
) {
Box(
Modifier
.background(MaterialTheme.colorScheme.primary)
.width(3.dp)
.fillMaxHeight()
)
Column(
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
}
// Message content // Message content
Text( Text(
text = message.content, text = message.content,
@@ -162,12 +199,14 @@ fun MessageItem(
Color.White Color.White
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} },
modifier = Modifier.padding(horizontal = 12.dp)
) )
// Timestamp and edited indicator // Timestamp and edited indicator
Row( Row(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -202,45 +241,17 @@ fun MessageItem(
} }
} }
@Composable
private fun ReplyPreview(
replyTo: Message,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Column {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
@ExperimentalTime @ExperimentalTime
private fun formatTime(timestamp: String): String { private fun formatTime(timestamp: String): String {
return try { return try {
val instant = Instant.parse(timestamp) Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) "${
val hour = localDateTime.hour.toString().padStart(2, '0') it.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0') }:${
"$hour:$minute" it.minute.toString().padStart(2, '0')
} catch (e: Exception) { }"
}
} catch (_: Exception) {
// Fallback: try parsing without timezone if it fails // Fallback: try parsing without timezone if it fails
try { try {
val parts = timestamp.split("T") val parts = timestamp.split("T")
@@ -254,9 +265,8 @@ private fun formatTime(timestamp: String): String {
} else { } else {
"" ""
} }
} catch (e2: Exception) { } catch (_: Exception) {
"" ""
} }
} }
} }
@@ -29,7 +29,7 @@ class PublicChatPanel(
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) } updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
} }
} }
} }
@@ -58,7 +58,7 @@ class PublicChatPanel(
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true messagesLoaded = true
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoading(false) setLoading(false)
@@ -84,7 +84,7 @@ class PublicChatPanel(
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoadingMore(false) setLoadingMore(false)
@@ -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
) )
} }
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
@Composable @Composable
private fun TypingDots() { private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots") val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val duration = 1000
val initialScale = 1f
val targetScale = 1.3f
val dot1Alpha by infiniteTransition.animateFloat( // Функция-хелпер для создания анимации с задержкой
initialValue = 0.3f, @Composable
targetValue = 1f, fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
initialValue = initialScale,
targetValue = initialScale, // Возвращаемся в начало
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = keyframes { animation = keyframes {
durationMillis = 1400 durationMillis = duration
0.3f at 0 initialScale at delay // Начало подъема
1f at 200 targetScale at delay + 200 // Пик
0.3f at 400 initialScale at delay + 400 // Возврат
initialScale at duration // Удержание до конца цикла
} }
), ),
label = "dot1" label = "dot_scale"
) )
val dot2Alpha by infiniteTransition.animateFloat( val dot1Scale by animateDotScale(delay = 0)
initialValue = 0.3f, val dot2Scale by animateDotScale(delay = 200)
targetValue = 1f, val dot3Scale by animateDotScale(delay = 400)
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 200
1f at 400
0.3f at 600
}
),
label = "dot2"
)
val dot3Alpha by infiniteTransition.animateFloat( Row(
initialValue = 0.3f, verticalAlignment = Alignment.CenterVertically,
targetValue = 1f, horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
animationSpec = infiniteRepeatable( ) {
animation = keyframes { Dot(scale = dot1Scale, maxScale = targetScale)
durationMillis = 1400 Dot(scale = dot2Scale, maxScale = targetScale)
0.3f at 400 Dot(scale = dot3Scale, maxScale = targetScale)
1f at 600
0.3f at 800
}
),
label = "dot3"
)
Row(verticalAlignment = Alignment.CenterVertically) {
Dot(alpha = dot1Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot3Alpha)
} }
} }
@Composable @Composable
private fun Dot(alpha: Float) { @Suppress("SameParameterValue")
private fun Dot(maxScale: Float, scale: Float) {
Box(
modifier = Modifier.size(4.dp * maxScale),
contentAlignment = Alignment.Center
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.width(4.dp) .width(4.dp * scale)
.height(4.dp) .height(4.dp * scale),
.alpha(alpha),
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) {} ) {}
}
} }
@@ -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
} }
@@ -40,13 +40,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.storage.ServerConfigData
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled import ru.fromchat.https_enabled
import ru.fromchat.save_continue import ru.fromchat.save_continue
@@ -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
@@ -1,7 +1,8 @@
package ru.fromchat package ru.fromchat
import ru.fromchat.ui.App
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() } fun MainViewController() = ComposeUIViewController { App() }
@@ -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)
}
}
+17 -2
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,16 +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"
kotlinxSerializationJson = "1.9.0" multiplatformSettings = "1.3.0"
serialization = "2.3.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" }
@@ -36,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" }
@@ -45,12 +53,16 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } 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" }
@@ -67,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" }
@@ -76,3 +89,5 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } 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" } 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" }
google-services = { id = "com.google.gms.google-services" }
+194 -95
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"; }
@@ -52,47 +52,100 @@ step() { echo -e "\n${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; } substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}" echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
[[ "$IS_PRERELEASE" == true ]] && info "Prerelease mode enabled via --pre flag"
# --- Настройка памяти для предотвращения 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: "
read -r USER_INPUT
# --- Переменные Git / Версии --- # Regex for x, x.y, or x.y.z
step "Metadata collection" VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1") # shellcheck disable=SC2001
# shellcheck disable=SC2207 CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}" if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
substep "Previous tag: ${YELLOW}$PREVTAG${NC}" error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
exit 1
fi
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g') 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
build_android() { RECREATE_TAG=false
step "Building Android Release" if git rev-parse "$TAG" >/dev/null 2>&1; then
if ./gradlew :app:android:assembleRelease -PbuildNumber="$BUILD_NUMBER" -Dorg.gradle.jvmargs="-Xmx8g"; then echo -n " " && warning "Tag $TAG already exists."
APK_SRC=$(find . -name "*release.apk" | head -n 1) echo -en " ${GREEN}${NC} Delete it and move to the new commit? (y/N): "
read -r CONFIRM
if [[ -f "$APK_SRC" ]]; then if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
mkdir -p releases RECREATE_TAG=true
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else else
error "Android APK not found after build" exit 0
exit 1
fi fi
else fi
error "Android build failed"
exit 1 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() {
step "Building Android Release"
if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "APK not found"; exit 1
fi
else
error "Android build failed"; 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
@@ -101,102 +154,148 @@ build_ios() {
fi fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
# shellcheck disable=SC2155
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p) [[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
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() {
echo -e "<!-- Release Description -->" > "$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:]]*$//')
rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > "$DESC_FILE"
NOTES_ARG=("-F" "$DESC_FILE")
else
NOTES_ARG=("--generate-notes")
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
# Определяем флаг пререлиза на основе тега ИЛИ аргумента --pre
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 prepare_description
error "No assets found to upload. Check build logs."
return
fi
# Проверяем наличие релиза и создаем/обновляем
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")
gh release upload "$TAG" "${ASSETS[@]}" --clobber [[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
[[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
substep "Uploading files..."
gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
else else
substep "Creating new release ${YELLOW}$TAG${NC}..." substep "Creating the release..."
gh release create "$TAG" \ PR_FLAG=""
--generate-notes \ [[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
--notes-start-tag "${PREVTAG:-$TAG}" \ gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
$prerelease_flag \
"${ASSETS[@]}"
fi fi
success "Assets uploaded to GitHub Release" rm -f "$DESC_FILE"
success "Success!"
} }
# --- Основной процесс ---
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)
} }
} }
} }
@@ -720,6 +720,7 @@ val ActivityInfo.launchIntent get() = Intent().apply {
flags = FLAG_ACTIVITY_NEW_TASK flags = FLAG_ACTIVITY_NEW_TASK
} }
@SuppressLint("QueryPermissionsNeeded")
@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) @RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
fun ActivityInfo.isLauncher(context: Context): Boolean { fun ActivityInfo.isLauncher(context: Context): Boolean {
with (context) { with (context) {
@@ -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))
@@ -93,4 +89,15 @@ class AndroidSettings(): Settings {
preferences.remove(stringSetPreferencesKey(key)) preferences.remove(stringSetPreferencesKey(key))
} }
} }
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
} }
@@ -1,128 +0,0 @@
package com.pr0gramm3r101.utils.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class AndroidAsyncSettings : AsyncSettings {
private val dataStore: DataStore<Preferences>
get() = DataStoreSingleton.dataStore
override suspend fun putString(key: String, value: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value
}
}
override suspend fun getString(key: String, default: String): String {
return dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putInt(key: String, value: Int) {
dataStore.edit { preferences ->
preferences[intPreferencesKey(key)] = value
}
}
override suspend fun getInt(key: String, default: Int): Int {
return dataStore.data.map { preferences ->
preferences[intPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putLong(key: String, value: Long) {
dataStore.edit { preferences ->
preferences[longPreferencesKey(key)] = value
}
}
override suspend fun getLong(key: String, default: Long): Long {
return dataStore.data.map { preferences ->
preferences[longPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putFloat(key: String, value: Float) {
dataStore.edit { preferences ->
preferences[floatPreferencesKey(key)] = value
}
}
override suspend fun getFloat(key: String, default: Float): Float {
return dataStore.data.map { preferences ->
preferences[floatPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putBoolean(key: String, value: Boolean) {
dataStore.edit { preferences ->
preferences[booleanPreferencesKey(key)] = value
}
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean {
return dataStore.data.map { preferences ->
preferences[booleanPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringSet(key: String, value: Set<String>) {
dataStore.edit { preferences ->
preferences[stringSetPreferencesKey(key)] = value
}
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> {
return dataStore.data.map { preferences ->
preferences[stringSetPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringList(key: String, value: List<String>) {
putStringSet(key, value.toSet())
}
override suspend fun getStringList(key: String, default: List<String>): List<String> {
return getStringSet(key, default.toSet()).toList()
}
override suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))
preferences.remove(intPreferencesKey(key))
preferences.remove(longPreferencesKey(key))
preferences.remove(floatPreferencesKey(key))
preferences.remove(booleanPreferencesKey(key))
preferences.remove(stringSetPreferencesKey(key))
}
}
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
override suspend fun clear() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
actual val asyncSettings: AsyncSettings = AndroidAsyncSettings()
@@ -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
} }
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings() actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings
get() = AndroidSecureSettings()
@@ -1,5 +1,6 @@
package com.pr0gramm3r101.utils package com.pr0gramm3r101.utils
import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
/** /**
@@ -21,18 +22,22 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
val currentRoute = currentBackStackEntry?.destination?.route val currentRoute = currentBackStackEntry?.destination?.route
// Navigate to the new route, removing the current screen as well // Navigate to the new route, removing the current screen as well
if (currentRoute != null && currentRoute != route) {
navigate(route) { navigate(route) {
if (currentRoute != null && currentRoute != route) {
popUpTo(currentRoute) { popUpTo(currentRoute) {
inclusive = true inclusive = true
} }
this.launchSingleTop = launchSingleTop
} }
} else {
// Fallback: just navigate if current route is same or null
navigate(route) {
this.launchSingleTop = launchSingleTop this.launchSingleTop = launchSingleTop
} }
}
} }
inline fun Modifier.conditional(
condition: Boolean,
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
`if`: Modifier.(Modifier) -> Modifier
) = if (condition) {
this + `if`(this)
} else {
this + `else`(this)
}
@@ -1,37 +0,0 @@
package com.pr0gramm3r101.utils.settings
/**
* Async version of Settings API using suspend functions
*/
interface AsyncSettings {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String, default: String = ""): String
suspend fun putInt(key: String, value: Int)
suspend fun getInt(key: String, default: Int = 0): Int
suspend fun putLong(key: String, value: Long)
suspend fun getLong(key: String, default: Long = 0L): Long
suspend fun putFloat(key: String, value: Float)
suspend fun getFloat(key: String, default: Float = 0f): Float
suspend fun putBoolean(key: String, value: Boolean)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean
suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
suspend fun clear()
}
/**
* Global async settings instance
*/
expect val asyncSettings: AsyncSettings
@@ -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,25 @@ 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)
suspend fun contains(key: String): Boolean
} }
@@ -1,3 +1,4 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
expect val settings: Settings expect val settings: Settings
expect val secureSettings: Settings
@@ -1,29 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Authentication token storage
*/
object AuthStorage {
private val storage = NamespacedStorage("auth")
suspend fun getToken(): String? {
val token = storage.getString("token")
return token.ifEmpty { null }
}
suspend fun setToken(token: String?) {
if (token != null) {
storage.putString("token", token)
} else {
storage.remove("token")
}
}
suspend fun clearToken() {
storage.remove("token")
}
suspend fun putString(key: String, value: String) = storage.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default)
suspend fun remove(key: String) = storage.remove(key)
}
@@ -1,52 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
/**
* Server configuration storage
*/
object ServerConfigStorage {
private val storage = NamespacedStorage("server_config")
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
suspend fun getServerUrl() = storage.getString(SERVER_URL_KEY).ifEmpty { null }
suspend fun setServerUrl(url: String) = storage.putString(SERVER_URL_KEY, url)
suspend fun getHttpsEnabled() =
if (storage.contains(HTTPS_ENABLED_KEY))
storage.getBoolean(HTTPS_ENABLED_KEY, true)
else null
suspend fun setHttpsEnabled(enabled: Boolean) {
storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
}
suspend fun hasConfiguration() = getServerUrl() != null && getHttpsEnabled() != null
suspend fun getConfig(): ServerConfigData {
var url = getServerUrl()
var https = getHttpsEnabled()
if (url == null || https == null) {
url = "fromchat.ru"
https = true
saveConfig(ServerConfigData(url, https))
}
return ServerConfigData(url, https)
}
suspend fun saveConfig(config: ServerConfigData) {
setServerUrl(config.serverUrl)
setHttpsEnabled(config.httpsEnabled)
}
}
@@ -1,59 +0,0 @@
package com.pr0gramm3r101.utils.storage
import com.pr0gramm3r101.utils.settings.AsyncSettings
import com.pr0gramm3r101.utils.settings.asyncSettings
/**
* Generic key-value storage using AsyncSettings
*/
class Storage(private val settings: AsyncSettings = asyncSettings) {
suspend fun putString(key: String, value: String) = settings.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default)
suspend fun putInt(key: String, value: Int) = settings.putInt(key, value)
suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default)
suspend fun putLong(key: String, value: Long) = settings.putLong(key, value)
suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default)
suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value)
suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default)
suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default)
suspend fun putStringSet(key: String, value: Set<String>) = settings.putStringSet(key, value)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> = settings.getStringSet(key, default)
suspend fun putStringList(key: String, value: List<String>) = settings.putStringList(key, value)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> = settings.getStringList(key, default)
suspend fun remove(key: String) = settings.remove(key)
suspend fun contains(key: String): Boolean = settings.contains(key)
suspend fun clear() = settings.clear()
}
/**
* Namespaced storage for specific features
*/
class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) {
private fun key(name: String) = "$prefix:$name"
suspend fun putString(name: String, value: String) = storage.putString(key(name), value)
suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default)
suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value)
suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default)
suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value)
suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default)
suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value)
suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default)
suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value)
suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default)
suspend fun remove(name: String) = storage.remove(key(name))
suspend fun contains(name: String): Boolean = storage.contains(key(name))
}
@@ -1,99 +0,0 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSBundle
import platform.Foundation.NSUserDefaults
class IosAsyncSettings : AsyncSettings {
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
override suspend fun putString(key: String, value: String): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getString(key: String, default: String): String = withContext(Dispatchers.Default) {
defaults.stringForKey(key) ?: default
}
override suspend fun putInt(key: String, value: Int): Unit = withContext(Dispatchers.Default) {
defaults.setInteger(value.toLong(), key)
defaults.synchronize()
}
override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) {
defaults.integerForKey(key).toInt()
}
override suspend fun putLong(key: String, value: Long): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getLong(key: String, default: Long): Long = withContext(Dispatchers.Default) {
(defaults.objectForKey(key) as? platform.Foundation.NSNumber)?.longValue ?: default
}
override suspend fun putFloat(key: String, value: Float): Unit = withContext(Dispatchers.Default) {
defaults.setFloat(value, key)
defaults.synchronize()
}
override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) {
defaults.floatForKey(key)
}
override suspend fun putBoolean(key: String, value: Boolean): Unit = withContext(Dispatchers.Default) {
defaults.setBool(value, key)
defaults.synchronize()
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean = withContext(Dispatchers.Default) {
if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
default
}
}
override suspend fun putStringSet(key: String, value: Set<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value.toList(), key)
defaults.synchronize()
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>().toSet()
}
override suspend fun putStringList(key: String, value: List<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getStringList(key: String, default: List<String>): List<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>()
}
override suspend fun remove(key: String): Unit = withContext(Dispatchers.Default) {
defaults.removeObjectForKey(key)
defaults.synchronize()
}
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
defaults.objectForKey(key) != null
}
override suspend fun clear(): Unit = withContext(Dispatchers.Default) {
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: ""
val domain = defaults.persistentDomainForName(bundleId)
domain?.keys?.forEach { key ->
defaults.removeObjectForKey(key as String)
}
defaults.synchronize()
}
}
actual val asyncSettings: AsyncSettings = IosAsyncSettings()
@@ -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,8 +1,10 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
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
@@ -42,4 +44,8 @@ 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.IO) {
defaults.objectForKey(key) != null
}
} }
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings() actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings
get() = IosSecureSettings()