mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 03:25:06 +03:00
Compare commits
10 Commits
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -24,3 +24,4 @@ release
|
|||||||
|
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata
|
xcuserdata
|
||||||
|
google-services.json
|
||||||
@@ -9,6 +9,7 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -111,6 +112,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.play.services.base)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.adaptive.android)
|
implementation(libs.androidx.adaptive.android)
|
||||||
@@ -118,7 +120,12 @@ dependencies {
|
|||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.slf4j.android)
|
implementation(libs.slf4j.android)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
implementation(libs.firebase.messaging)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(project(":app:shared"))
|
implementation(project(":app:shared"))
|
||||||
implementation(project(":utils:shared"))
|
implementation(project(":utils:shared"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process `google-services.json` into resources so Firebase initializes automatically.
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -25,5 +25,20 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notifications.NotificationReplyReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="ru.fromchat.NOTIFICATION_REPLY" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,11 +1,113 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.WebSocketManager
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
class App: Application() {
|
class App: Application() {
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun fetchAndNotify() {
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
NotificationHelper.fetchAndNotify(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
UtilsLibrary.init(this)
|
UtilsLibrary.init(this)
|
||||||
|
|
||||||
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
|
runCatching {
|
||||||
|
if (msg.type == "newMessage") {
|
||||||
|
fetchAndNotify()
|
||||||
|
} else if (msg.type == "updates") {
|
||||||
|
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
|
||||||
|
var shouldFetch = false
|
||||||
|
for (item in updates) {
|
||||||
|
if (
|
||||||
|
item
|
||||||
|
.jsonObject["type"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
in arrayOf("newMessage", "dmNew")
|
||||||
|
) {
|
||||||
|
shouldFetch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetch) fetchAndNotify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
ApiClient.loadPersistedData()
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
uploadPendingFcmTokenIfAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have an auth token, try to get current FCM token and register it immediately
|
||||||
|
runCatching {
|
||||||
|
val auth = ApiClient.token
|
||||||
|
if (!auth.isNullOrEmpty()) {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
try {
|
||||||
|
val resp = ApiClient.http.post(
|
||||||
|
"${Config.apiBaseUrl}/push/register"
|
||||||
|
) {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(
|
||||||
|
ApiClient.json.encodeToString(
|
||||||
|
mapOf("token" to task.result)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
"AppFCM",
|
||||||
|
"Registered existing FCM token on startup, status=${resp.status.value}"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(
|
||||||
|
"AppFCM",
|
||||||
|
"Failed to register FCM token on startup: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(
|
||||||
|
"AppFCM",
|
||||||
|
"FirebaseMessaging token fetch failed on startup: ${task.exception?.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,133 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import com.google.android.gms.common.ConnectionResult
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.MessagesResponse
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var scrollToMessageId by mutableStateOf<Int?>(null)
|
||||||
|
private var startAtPublicChat by mutableStateOf(false)
|
||||||
|
private var prevIsPublicChatVisible: Boolean? = null
|
||||||
|
|
||||||
|
private fun handleIntent(intent: Intent?) {
|
||||||
|
val messageId = intent?.getIntExtra("scroll_to_message_id", -1) ?: -1
|
||||||
|
scrollToMessageId = if (messageId != -1) messageId else null
|
||||||
|
startAtPublicChat = messageId != -1
|
||||||
|
|
||||||
|
// Mark messages as read if clicked from notification
|
||||||
|
if (intent?.getBooleanExtra("mark_message_read", false) == true) {
|
||||||
|
markMessagesAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun markMessagesAsRead() {
|
||||||
|
GlobalScope.launch {
|
||||||
|
try {
|
||||||
|
// Get all unread messages and mark them as read
|
||||||
|
val messageIds = ApiClient.http
|
||||||
|
.get("${Config.apiBaseUrl}/messages/new")
|
||||||
|
.body<MessagesResponse>()
|
||||||
|
.messages
|
||||||
|
.map { it.id }
|
||||||
|
|
||||||
|
if (messageIds.isNotEmpty()) {
|
||||||
|
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(mapOf("messageIds" to messageIds))
|
||||||
|
}
|
||||||
|
Log.d("MainActivity", "Marked ${messageIds.size} messages as read: $messageIds")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Failed to mark messages as read", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkGooglePlayServices(): Boolean {
|
||||||
|
with (GoogleApiAvailability.getInstance()) {
|
||||||
|
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
|
||||||
|
|
||||||
|
if (resultCode == ConnectionResult.SUCCESS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserResolvableError(resultCode)) {
|
||||||
|
getErrorDialog(
|
||||||
|
this@MainActivity,
|
||||||
|
resultCode,
|
||||||
|
9000
|
||||||
|
)?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Handle initial intent
|
||||||
|
handleIntent(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
}
|
scrollToMessageId = scrollToMessageId,
|
||||||
|
startAtPublicChat = startAtPublicChat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGooglePlayServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
prevIsPublicChatVisible = isPublicChatVisible
|
||||||
|
isPublicChatVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isPublicChatVisible = prevIsPublicChatVisible ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
|
suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val pending = settings.getString("pending_fcm_token", "")
|
||||||
|
|
||||||
|
// Only upload if we have auth token
|
||||||
|
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
||||||
|
Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(ApiClient.json.encodeToString(mapOf("token" to pending)))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.remove("pending_fcm_token")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
|
Log.d("FromChatFCM", "onMessageReceived: from=${remoteMessage.from}, data=${remoteMessage.data}")
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
NotificationHelper.fetchAndNotify(applicationContext)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Log.d("FromChatFCM", "onNewToken: $token")
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
// Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login)
|
||||||
|
try {
|
||||||
|
val authToken = ApiClient.token
|
||||||
|
if (!authToken.isNullOrEmpty()) {
|
||||||
|
// Call backend endpoint to register token
|
||||||
|
try {
|
||||||
|
val resp = ApiClient.http.post("${Config.apiBaseUrl}/push/register") {
|
||||||
|
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
||||||
|
}
|
||||||
|
Log.d("FromChatFCM", "Uploaded FCM token to server: ${resp.status.value}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "Failed to upload token: ${e.message}", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Save to shared preferences for later upload (best-effort)
|
||||||
|
runCatching {
|
||||||
|
settings.putString("pending_fcm_token", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onNewToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.MainActivity
|
||||||
|
import ru.fromchat.R
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.MessagesResponse
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
private const val CHANNEL_ID = "fromchat_messages"
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
|
||||||
|
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||||
|
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||||
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
|
|
||||||
|
private fun createMessageIntent(context: Context, messageId: Int) = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
messageId,
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra("scroll_to_message_id", messageId)
|
||||||
|
putExtra("mark_message_read", true)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
SUMMARY_NOTIFICATION_ID,
|
||||||
|
Intent(context, NotificationReplyReceiver::class.java).apply {
|
||||||
|
action = "ru.fromchat.NOTIFICATION_REPLY"
|
||||||
|
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
(context
|
||||||
|
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
).createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "FromChat message notifications"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAndNotify(context: Context) {
|
||||||
|
Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val messages = ApiClient.http
|
||||||
|
.get("${Config.apiBaseUrl}/messages/new")
|
||||||
|
.body<MessagesResponse>()
|
||||||
|
.messages
|
||||||
|
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages")
|
||||||
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
|
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||||
|
|
||||||
|
// Display notifications on main thread
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
createChannel(context)
|
||||||
|
displayNotifications(context, messages)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||||
|
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||||
|
|
||||||
|
// Don't show notifications if user is currently viewing the public chat
|
||||||
|
if (isPublicChatVisible) {
|
||||||
|
Log.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch {
|
||||||
|
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||||
|
var newMessageCount = 0
|
||||||
|
|
||||||
|
with(NotificationManagerCompat.from(context)) {
|
||||||
|
if (
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
// Find new messages that are not from the current user
|
||||||
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
|
|
||||||
|
if (currentUserId == -1) return@launch
|
||||||
|
|
||||||
|
val newMessages = messages
|
||||||
|
.filter { msg ->
|
||||||
|
!shown.contains(msg.id.toString()) && // Not already shown
|
||||||
|
msg.user_id != currentUserId // Not from current user
|
||||||
|
}
|
||||||
|
.ifEmpty { return@launch }
|
||||||
|
.apply { forEach { shown.add(it.id.toString()) } }
|
||||||
|
|
||||||
|
newMessageCount = newMessages.size
|
||||||
|
|
||||||
|
notify(
|
||||||
|
SUMMARY_NOTIFICATION_ID,
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.logo_big)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.MessagingStyle(
|
||||||
|
Person.Builder().setName("FromChat").build()
|
||||||
|
).setConversationTitle("Public Chat").let {
|
||||||
|
for (msg in messages.takeLast(10)) {
|
||||||
|
val timestamp = try {
|
||||||
|
java.time.Instant.parse(msg.timestamp).toEpochMilli()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
it.addMessage(
|
||||||
|
NotificationCompat.MessagingStyle.Message(
|
||||||
|
msg.content,
|
||||||
|
timestamp,
|
||||||
|
Person.Builder()
|
||||||
|
.setName(msg.username)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
android.R.drawable.ic_menu_send,
|
||||||
|
"Reply",
|
||||||
|
createReplyIntent(context)
|
||||||
|
)
|
||||||
|
.addRemoteInput(
|
||||||
|
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||||
|
.setLabel("Reply to chat...")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setAllowGeneratedReplies(true)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
android.R.drawable.ic_menu_view,
|
||||||
|
"View Chat",
|
||||||
|
createMessageIntent(
|
||||||
|
context,
|
||||||
|
newMessages.last().id
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||||
|
Log.d("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let {
|
||||||
|
if (it.isNotBlank()) {
|
||||||
|
Log.d("NotificationReply", "Received reply: $it")
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
ApiClient.sendMessage(it)
|
||||||
|
Log.d("NotificationReply", "Reply sent successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NotificationReply", "Failed to send reply", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="200dp"
|
||||||
|
android:height="200dp"
|
||||||
|
android:viewportWidth="1000"
|
||||||
|
android:viewportHeight="1000">
|
||||||
|
<path
|
||||||
|
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z"
|
||||||
|
android:fillColor="#fff" />
|
||||||
|
<path
|
||||||
|
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z"
|
||||||
|
android:fillColor="#fff" />
|
||||||
|
<path
|
||||||
|
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z"
|
||||||
|
android:fillColor="#fff" />
|
||||||
|
</vector>
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.2</string>
|
<string>0.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>022</string>
|
<string>03</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -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") {
|
||||||
@@ -135,3 +176,4 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
var isPublicChatVisible = false
|
||||||
@@ -174,7 +174,9 @@ fun LoginScreen(
|
|||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
},
|
},
|
||||||
onSuccess = { onLoginSuccess() }
|
onSuccess = {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
ApiClient.login(LoginRequest(username.trim(), derived))
|
ApiClient.login(LoginRequest(username.trim(), derived))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ fun RegisterScreen(
|
|||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
},
|
},
|
||||||
onSuccess = { onRegistered() }
|
onSuccess = {
|
||||||
|
onRegistered()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
ApiClient.register(
|
ApiClient.register(
|
||||||
RegisterRequest(
|
RegisterRequest(
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -273,4 +270,3 @@ 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) }
|
||||||
@@ -315,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)) }
|
||||||
|
|
||||||
@@ -331,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) {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PublicChatScreen() {
|
fun PublicChatScreen(scrollToMessageId: Int? = null) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
|
|
||||||
@@ -28,10 +30,19 @@ fun PublicChatScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track visibility for notifications
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
isPublicChatVisible = true
|
||||||
|
onDispose {
|
||||||
|
isPublicChatVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render with ChatScreen
|
// Render with ChatScreen
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
currentUserId = currentUserId
|
currentUserId = currentUserId,
|
||||||
|
scrollToMessageId = scrollToMessageId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,9 +208,7 @@ fun SettingsTab(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
// Logout
|
// Logout
|
||||||
try {
|
try {
|
||||||
ApiClient.token?.let { token ->
|
ApiClient.logout()
|
||||||
ApiClient.logout(token)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore logout errors
|
// Ignore logout errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
@@ -9,3 +9,14 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure google-services plugin is available to subprojects that apply it.
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath(libs.google.services)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ compose-multiplatform = "1.9.3"
|
|||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.6.1-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
|
firebaseMessaging = "25.0.1"
|
||||||
|
googleServices = "4.4.4"
|
||||||
haze = "1.7.1"
|
haze = "1.7.1"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
@@ -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" }
|
||||||
+176
-109
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# --- Настройка и окружение ---
|
# --- Setup ---
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# --- Обработка аргументов ---
|
# --- Arguments ---
|
||||||
IS_PRERELEASE=false
|
IS_PRERELEASE=false
|
||||||
|
|
||||||
show_help() {
|
show_help() {
|
||||||
@@ -33,17 +33,17 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- Конфигурация цветов и оформления ---
|
# --- Colors ---
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
MAGENTA='\033[0;35m'
|
MAGENTA='\033[0;35m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m'
|
||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
|
|
||||||
# --- Функции логирования ---
|
# --- Logging ---
|
||||||
info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
||||||
success() { echo -e "${GREEN}✓${NC} $1"; }
|
success() { echo -e "${GREEN}✓${NC} $1"; }
|
||||||
warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||||
@@ -53,46 +53,99 @@ substep() { echo -e " ${GREEN}•${NC} $1"; }
|
|||||||
|
|
||||||
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
|
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
|
||||||
|
|
||||||
# --- Настройка памяти для предотвращения OutOfMemory ---
|
# --- 1. Version Input & Validation ---
|
||||||
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
|
step "Configuration"
|
||||||
|
echo -en " ${GREEN}•${NC} Release version: "
|
||||||
# --- Переменные Git / Версии ---
|
read -r USER_INPUT
|
||||||
step "Metadata collection"
|
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
|
|
||||||
# shellcheck disable=SC2207
|
|
||||||
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
|
|
||||||
PREVTAG=${TAGS[1]:-$TAG}
|
|
||||||
|
|
||||||
substep "Current tag: ${YELLOW}$TAG${NC}"
|
|
||||||
substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
|
|
||||||
|
|
||||||
|
# Regex for x, x.y, or x.y.z
|
||||||
|
VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
|
||||||
# shellcheck disable=SC2001
|
# shellcheck disable=SC2001
|
||||||
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
|
CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
|
||||||
|
|
||||||
|
if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
|
||||||
|
error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="v$CLEAN_VERSION"
|
||||||
|
VERSION_STR="$CLEAN_VERSION"
|
||||||
|
# shellcheck disable=SC2001
|
||||||
|
BUILD_NUMBER=$(echo "$VERSION_STR" | LC_ALL=C sed 's/[^0-9]//g')
|
||||||
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
|
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
|
||||||
|
|
||||||
# --- Сборка Android ---
|
# Check if tag exists
|
||||||
|
RECREATE_TAG=false
|
||||||
|
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo -n " " && warning "Tag $TAG already exists."
|
||||||
|
echo -en " ${GREEN}•${NC} Delete it and move to the new commit? (y/N): "
|
||||||
|
read -r CONFIRM
|
||||||
|
if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||||
|
RECREATE_TAG=true
|
||||||
|
else
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD)
|
||||||
|
STASH_MARKER="release_stash_$(date +%s)"
|
||||||
|
HAS_STASHED=false
|
||||||
|
|
||||||
|
restore_git_state() {
|
||||||
|
if [ "$HAS_STASHED" = true ]; then
|
||||||
|
STASH_ID=$(git stash list | grep "$STASH_MARKER" | head -n 1 | cut -d':' -f1)
|
||||||
|
if [[ -n "$STASH_ID" ]]; then
|
||||||
|
git stash pop "$STASH_ID" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
HAS_STASHED=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap restore_git_state EXIT INT TERM
|
||||||
|
|
||||||
|
# --- 2. Git Cleanup & Safety ---
|
||||||
|
if [[ -n $(git status --short) ]]; then
|
||||||
|
git stash push --include-untracked -m "$STASH_MARKER" > /dev/null 2>&1
|
||||||
|
HAS_STASHED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch origin "$CURRENT_BRANCH" > /dev/null 2>&1
|
||||||
|
LOCAL_HASH=$(git rev-parse HEAD)
|
||||||
|
REMOTE_HASH=$(git rev-parse "origin/$CURRENT_BRANCH")
|
||||||
|
|
||||||
|
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
|
||||||
|
if ! git merge-base --is-ancestor "$REMOTE_HASH" "$LOCAL_HASH"; then
|
||||||
|
error "Remote branch has commits you don't have. Please pull first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Constants ---
|
||||||
|
BUILD_DIR="$(pwd)/build"
|
||||||
|
DESC_FILE="$BUILD_DIR/.release_desc.md"
|
||||||
|
RELEASES_DIR="$(pwd)/releases"
|
||||||
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
|
||||||
|
|
||||||
|
# --- 3. Build Functions ---
|
||||||
|
|
||||||
build_android() {
|
build_android() {
|
||||||
step "Building Android Release"
|
step "Building Android Release"
|
||||||
if ./gradlew :app:android:assembleRelease; then
|
if ./gradlew :app:android:assembleRelease; then
|
||||||
APK_SRC=$(find . -name "*release.apk" | head -n 1)
|
APK_SRC=$(find . -name "*release.apk" | head -n 1)
|
||||||
|
|
||||||
if [[ -f "$APK_SRC" ]]; then
|
if [[ -f "$APK_SRC" ]]; then
|
||||||
mkdir -p releases
|
|
||||||
DISPLAY_NAME="FromChat-$TAG-android.apk"
|
DISPLAY_NAME="FromChat-$TAG-android.apk"
|
||||||
cp "$APK_SRC" "releases/$DISPLAY_NAME"
|
cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
|
||||||
ANDROID_ASSET="releases/$DISPLAY_NAME"
|
ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
|
||||||
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
|
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
|
||||||
else
|
else
|
||||||
error "Android APK not found after build"
|
error "APK not found"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
error "Android build failed"
|
error "Android build failed"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Сборка iOS ---
|
|
||||||
build_ios() {
|
build_ios() {
|
||||||
step "Building iOS Release"
|
step "Building iOS Release"
|
||||||
if [[ "$OSTYPE" != "darwin"* ]]; then
|
if [[ "$OSTYPE" != "darwin"* ]]; then
|
||||||
@@ -106,129 +159,143 @@ build_ios() {
|
|||||||
|
|
||||||
IOS_PROJECT_DIR="app/ios"
|
IOS_PROJECT_DIR="app/ios"
|
||||||
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
|
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
|
||||||
|
|
||||||
substep "Setting iOS version to $BUILD_NUMBER..."
|
|
||||||
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
|
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
|
||||||
|
|
||||||
if [[ -f "$PLIST_PATH" ]]; then
|
if [[ -f "$PLIST_PATH" ]]; then
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
substep "Xcode Archiving (Unsigned)..."
|
substep "Xcode Archiving..."
|
||||||
|
rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
|
||||||
mkdir -p build/ios
|
if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
|
||||||
|
|
||||||
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
|
|
||||||
-scheme iOS \
|
-scheme iOS \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
-sdk iphoneos \
|
-sdk iphoneos \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-derivedDataPath build/ios \
|
-derivedDataPath "$BUILD_DIR/ios" \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGNING_ENTITLEMENTS="" \
|
clean build > "$BUILD_DIR/ios/build.log" 2>&1
|
||||||
build > build/ios/build.log 2>&1; then
|
then
|
||||||
|
error "iOS build failed. Check: $BUILD_DIR/ios/build.log"; exit 1
|
||||||
success "Xcode build successful."
|
|
||||||
else
|
|
||||||
error "Xcode build failed. Check build/ios/build.log"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
substep "Packaging IPA for TrollStore..."
|
substep "Packaging to ${CYAN}.ipa${NC}..."
|
||||||
mkdir -p build/ios/Payload
|
APP_BUNDLE_PATH=$(find "$BUILD_DIR/ios/Build/Products/Release-iphoneos" -name "*.app" -type d | head -n 1)
|
||||||
APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
|
|
||||||
if [[ -n "$APP_PATH" ]]; then
|
if [[ -n "$APP_BUNDLE_PATH" ]]; then
|
||||||
cp -r "$APP_PATH" build/ios/Payload/
|
|
||||||
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
|
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
|
||||||
zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
|
IPA_PATH="$RELEASES_DIR/$IPA_NAME"
|
||||||
rm -rf build/ios/Payload
|
|
||||||
IOS_ASSET="releases/$IPA_NAME"
|
# Create Payload structure inside build dir
|
||||||
|
PAYLOAD_STAGE="$BUILD_DIR/ios/ipa_stage"
|
||||||
|
rm -rf "$PAYLOAD_STAGE" && mkdir -p "$PAYLOAD_STAGE/Payload"
|
||||||
|
|
||||||
|
# Use cp -R to dereference symlinks and copy actual files into the stage
|
||||||
|
# This is safe because it's only the final bundle
|
||||||
|
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
|
||||||
|
|
||||||
|
# Zip from the stage directory
|
||||||
|
(cd "$PAYLOAD_STAGE" && zip -r "$IPA_PATH" Payload > /dev/null 2>&1)
|
||||||
|
|
||||||
|
# Cleanup stage
|
||||||
|
rm -rf "$PAYLOAD_STAGE"
|
||||||
|
|
||||||
|
if [[ -f "$IPA_PATH" ]]; then
|
||||||
|
IOS_ASSET="$IPA_PATH"
|
||||||
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
|
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
|
||||||
else
|
else
|
||||||
error "Could not find .app bundle"
|
error "IPA generation failed (file not found in releases)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
error "Could not find .app bundle"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git checkout -- "$PLIST_PATH" > /dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Подготовка описания ---
|
build_android
|
||||||
|
build_ios
|
||||||
|
|
||||||
|
# --- 4. Git Finalizing ---
|
||||||
|
|
||||||
|
IOS_PROJECT_DIR="app/ios"
|
||||||
|
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
|
||||||
|
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
|
||||||
|
|
||||||
|
if [[ -f "$PLIST_PATH" ]]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
|
||||||
|
git add "$PLIST_PATH" > /dev/null 2>&1
|
||||||
|
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
if git commit --amend --no-edit > /dev/null 2>&1; then
|
||||||
|
substep "Info.plist updated (amend)."
|
||||||
|
else
|
||||||
|
error "Failed to amend commit."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RECREATE_TAG" == true ]]; then
|
||||||
|
git tag -d "$TAG" > /dev/null 2>&1 || true
|
||||||
|
git push origin :refs/tags/"$TAG" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||||
|
git tag -a "$TAG" -m "Release $TAG" > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push origin "$CURRENT_BRANCH" --force-with-lease --tags > /dev/null 2>&1
|
||||||
|
|
||||||
|
# --- 5. GitHub Release ---
|
||||||
|
|
||||||
prepare_description() {
|
prepare_description() {
|
||||||
DESC_FILE=".release_desc.md"
|
echo -e "<!-- Release Description -->" > "$DESC_FILE"
|
||||||
echo -e "<!--\nEnter the release description. Leave empty for no description.\n-->" > "$DESC_FILE"
|
|
||||||
|
|
||||||
# Открываем nano. Пользователь должен отредактировать файл.
|
|
||||||
nano "$DESC_FILE"
|
nano "$DESC_FILE"
|
||||||
|
CLEAN_DESC=$(LC_ALL=C perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
# Удаляем комментарии <!-- ... --> и лишние пробелы/пустые строки
|
|
||||||
# Используем perl для многострочного удаления комментариев
|
|
||||||
CLEAN_DESC=$(perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
||||||
|
|
||||||
rm -f "$DESC_FILE"
|
rm -f "$DESC_FILE"
|
||||||
|
|
||||||
if [[ -n "$CLEAN_DESC" ]]; then
|
if [[ -n "$CLEAN_DESC" ]]; then
|
||||||
echo "$CLEAN_DESC" > .final_notes.md
|
echo "$CLEAN_DESC" > "$DESC_FILE"
|
||||||
NOTES_ARG=("-F" "./.final_notes.md")
|
NOTES_ARG=("-F" "$DESC_FILE")
|
||||||
else
|
else
|
||||||
NOTES_ARG=("--generate-notes")
|
NOTES_ARG=("--generate-notes")
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Публикация в GitHub ---
|
|
||||||
publish_github() {
|
publish_github() {
|
||||||
step "GitHub Deployment"
|
step "GitHub Release"
|
||||||
if ! command -v gh &> /dev/null; then
|
if ! command -v gh &> /dev/null; then return; fi
|
||||||
warning "GitHub CLI (gh) not found. Skipping upload."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
substep "Pushing tags..."
|
|
||||||
git push --tags > /dev/null 2>&1 || true
|
|
||||||
|
|
||||||
prerelease_flag=""
|
|
||||||
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
|
|
||||||
prerelease_flag="--prerelease"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ASSETS=()
|
ASSETS=()
|
||||||
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
|
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
|
||||||
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
|
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
|
||||||
|
[ ${#ASSETS[@]} -eq 0 ] && return
|
||||||
|
|
||||||
if [ ${#ASSETS[@]} -eq 0 ]; then
|
|
||||||
error "No assets found to upload."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Подготавливаем описание перед созданием
|
|
||||||
prepare_description
|
prepare_description
|
||||||
|
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
substep "Updating existing release ${YELLOW}$TAG${NC}..."
|
substep "Editing existing release..."
|
||||||
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag"
|
EDIT_ARGS=("--draft=false")
|
||||||
|
[[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
|
||||||
# Если есть кастомные ноты, обновляем их
|
[[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
|
||||||
if [[ -f .final_notes.md ]]; then
|
gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
|
||||||
gh release edit "$TAG" -F ./.final_notes.md
|
substep "Uploading files..."
|
||||||
fi
|
gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
|
||||||
|
|
||||||
gh release upload "$TAG" "${ASSETS[@]}" --clobber
|
|
||||||
else
|
else
|
||||||
substep "Creating new release ${YELLOW}$TAG${NC}..."
|
substep "Creating the release..."
|
||||||
# gh release create принимает либо --generate-notes, либо --notes-file
|
PR_FLAG=""
|
||||||
gh release create "$TAG" \
|
[[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
|
||||||
"${NOTES_ARG[@]}" \
|
gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
|
||||||
--notes-start-tag "${PREVTAG:-$TAG}" \
|
|
||||||
$prerelease_flag \
|
|
||||||
"${ASSETS[@]}"
|
|
||||||
fi
|
fi
|
||||||
|
rm -f "$DESC_FILE"
|
||||||
rm -f .final_notes.md
|
success "Success!"
|
||||||
success "Assets and notes uploaded to GitHub Release"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Основной процесс ---
|
|
||||||
mkdir -p releases
|
|
||||||
build_android
|
|
||||||
build_ios
|
|
||||||
publish_github
|
publish_github
|
||||||
|
|
||||||
|
restore_git_state
|
||||||
|
trap - EXIT INT TERM
|
||||||
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
|
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
|
||||||
@@ -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) {
|
||||||
|
|||||||
+83
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+11
-4
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-128
@@ -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()
|
|
||||||
+1
-2
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
|
|||||||
object DataStoreSingleton {
|
object DataStoreSingleton {
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
val dataStore: DataStore<Preferences>
|
val dataStore get() = UtilsLibrary.context.dataStore
|
||||||
get() = UtilsLibrary.context.dataStore
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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)
|
|
||||||
}
|
|
||||||
-52
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -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()
|
||||||
Reference in New Issue
Block a user