diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..16b902a
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fbc8c86..b7b7142 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,6 +3,10 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.kapt)
+ id("com.google.gms.google-services")
+ id("kotlin-parcelize")
+ alias(libs.plugins.kotlin.compose)
+
}
android {
@@ -17,27 +21,48 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
-
- externalNativeBuild {
- cmake {
- arguments("-DANDROID_STL=c++_shared")
- cppFlags("-Wl,-z,max-page-size=16384")
- }
- }
}
buildTypes {
- release {
+ debug {
+ //applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
isMinifyEnabled = false
+ isShrinkResources = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
+ register("staging") {
+ initWith(buildTypes["release"])
+ isMinifyEnabled = true
+ isShrinkResources = true
+ applicationIdSuffix = ".staging"
+ versionNameSuffix = "-staging"
+ }
+ }
+
+ androidComponents {
+ onVariants { variant ->
+ if (variant.buildType == "release") {
+ variant.packaging.resources.excludes.add("META-INF/*.version")
+ variant.packaging.resources.excludes.add("META-INF/*.kotlin_module")
+ variant.packaging.resources.excludes.add("kotlin/**")
+ variant.packaging.resources.excludes.add("DebugProbesKt.bin")
+ }
+ }
}
compileOptions {
- // Compose и Hilt лучше работают на Java 17
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -51,7 +76,8 @@ android {
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.5.1" // Соответствует Kotlin 1.9.0
+ //kotlinCompilerExtensionVersion = "1.5.1"
+ kotlinCompilerExtensionVersion = "1.5.15"
}
packaging {
@@ -59,22 +85,21 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1,androidx.compose.material3_material3.version}"
}
}
-
- externalNativeBuild {
- cmake {
- path = file("src/main/cpp/CMakeLists.txt")
- version = "3.22.1"
- }
- }
+}
+kapt {
+ correctErrorTypes = true
+ useBuildCache = false
}
+
dependencies {
- // 1. Сначала объявляем BOM (один раз в самом начале)
+ implementation(libs.play.services.cast.framework)
+ // 1. BOM
val composeBom = platform("androidx.compose:compose-bom:2024.02.01")
implementation(composeBom)
androidTestImplementation(composeBom)
- // 2. Jetpack Compose (версии берутся из BOM)
+ // 2. Compose
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
@@ -82,29 +107,102 @@ dependencies {
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.foundation)
- // 3. Material 3 (обязательно для PullToRefreshBox)
+ // 3. Material 3
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class")
implementation("androidx.compose.material:material-icons-extended")
- // 4. Сеть (Retrofit)
+ // 4. Network
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging)
- // 5. Dagger Hilt
+ // 5. Hilt
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
- // 6. Остальное
+ // 6. AndroidX
implementation(libs.appcompat)
implementation(libs.material)
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
- // Тесты и отладка
+ // 7. Tests
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
debugImplementation(libs.compose.ui.tooling)
+
+ // 8. Lifecycle & Security
+ implementation("androidx.lifecycle:lifecycle-process:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // 9. Firebase
+ implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
+ implementation("com.google.firebase:firebase-messaging")
+ implementation("com.google.firebase:firebase-analytics")
+
+ // 10. Room
+ implementation("androidx.room:room-runtime:2.7.1")
+ implementation("androidx.room:room-ktx:2.7.1")
+ kapt("androidx.room:room-compiler:2.7.1")
+
+ // 11. Media
+ implementation("com.github.bumptech.glide:glide:4.16.0")
+ kapt("com.github.bumptech.glide:compiler:4.16.0")
+ implementation("id.zelory:compressor:3.0.1")
+ implementation("com.otaliastudios:transcoder:0.10.3")
+ implementation("com.github.yalantis:ucrop:2.2.6")
+ implementation("androidx.work:work-runtime-ktx:2.9.0")
+ // Криптография для секретных чатов
+ implementation("org.bouncycastle:bcprov-jdk15on:1.70")
+ implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
+
+ // Для работы с большими числами (DH)
+ implementation("org.apache.commons:commons-math3:3.6.1")
+ // Для выбора изображений
+ implementation("com.github.dhaval2404:imagepicker:2.1")
+
+ // Для сжатия изображений
+ implementation("id.zelory:compressor:3.0.1")
+
+ // Для работы с видео
+ implementation("com.otaliastudios:transcoder:0.10.3")
+
+ // Glide для загрузки изображений
+ implementation("com.github.bumptech.glide:glide:4.16.0")
+
+ // Для работы с файлами
+ implementation("androidx.documentfile:documentfile:1.0.1")
}
+
+tasks.register("checkApkSize") {
+ doLast {
+ val releaseDir = layout.buildDirectory.dir("outputs/apk/release").get().asFile
+ val apkFile = releaseDir.listFiles()?.find { it.name.endsWith(".apk") }
+
+ if (apkFile != null) {
+ val sizeMB = apkFile.length() / (1024.0 * 1024.0)
+ println("✅ APK size: ${String.format("%.2f", sizeMB)} MB")
+
+ if (sizeMB > 100) {
+ println("⚠️ WARNING: APK size > 100MB! Consider further optimization.")
+ }
+ } else {
+ println("⚠️ No APK file found in $releaseDir")
+ }
+ }
+}
+//configurations.all {
+// resolutionStrategy {
+// force("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
+// force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0")
+// force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0")
+// force("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
+// }
+//}
+
+apply(plugin = "com.google.gms.google-services")
\ No newline at end of file
diff --git a/app/google-services.json b/app/google-services.json
new file mode 100644
index 0000000..dc1505d
--- /dev/null
+++ b/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "265598164282",
+ "project_id": "quapp-5cd84",
+ "storage_bucket": "quapp-5cd84.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:265598164282:android:4e6884c34c59a6b8ac0403",
+ "android_client_info": {
+ "package_name": "com.example.myserver"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyDNvk_Q52FLz89hc7n0W3vB0lWdecmDR2o"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..da36178 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,165 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+# ==================== ОБЩИЕ ПРАВИЛА ====================
+-keepattributes Signature
+-keepattributes *Annotation*
+-keepattributes SourceFile,LineNumberTable
+-keepattributes EnclosingMethod
+
+# Не обфусцировать имена классов с аннотациями
+-keep @kotlin.Metadata class *
+-keep class kotlin.Metadata { *; }
+
+# ==================== KOTLIN ====================
+-keep class kotlin.** { *; }
+-keep class kotlinx.coroutines.** { *; }
+-keep class kotlinx.serialization.** { *; }
+-dontwarn kotlinx.coroutines.**
+-dontwarn kotlinx.serialization.**
+
+# Корутины
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory { *; }
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler { *; }
+-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler { *; }
+-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory { *; }
+
+# ==================== ANDROIDX ====================
+-keep class androidx.** { *; }
+-keep interface androidx.** { *; }
+-dontwarn androidx.**
+
+# Compose
+-keep class androidx.compose.** { *; }
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.compose.ui.** { *; }
+-keep class androidx.compose.material.** { *; }
+-keep class androidx.compose.material3.** { *; }
+-dontwarn androidx.compose.**
+
+# Room
+-keep class * extends androidx.room.RoomDatabase
+-keep @androidx.room.Entity class *
+-keep @androidx.room.Dao class *
+-keepclassmembers class * {
+ @androidx.room.* ;
+}
+-keep class androidx.room.** { *; }
+-dontwarn androidx.room.paging.**
+
+# Hilt
+-keep class dagger.hilt.** { *; }
+-keep class javax.inject.** { *; }
+-keep class com.google.dagger.hilt.** { *; }
+-dontwarn dagger.hilt.android.**
+-dontwarn com.google.errorprone.annotations.*
+
+# ViewModel
+-keep class * extends androidx.lifecycle.ViewModel {
+ (...);
+}
+-keepclassmembers class * extends androidx.lifecycle.ViewModel {
+ (...);
+}
+
+# ==================== RETROFIT & OKHTTP ====================
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+-dontwarn org.conscrypt.**
+-keepattributes Signature
+-keepattributes Exceptions
+-keep class retrofit2.** { *; }
+-keepclasseswithmembers class * {
+ @retrofit2.http.* ;
+}
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+
+# ==================== GSON ====================
+-keep class com.google.gson.** { *; }
+-keep class com.example.myserver.** { *; } # Сохраняем наши модели
+-keepclassmembers class com.example.myserver.** {
+ (...);
+ ;
+}
+-keepattributes Signature
+-keepattributes *Annotation*
+
+# ==================== FIREBASE ====================
+-keep class com.google.firebase.** { *; }
+-keep class com.google.android.gms.** { *; }
+-dontwarn com.google.firebase.**
+-dontwarn com.google.android.gms.**
+
+# ==================== GLIDE ====================
+-keep class com.bumptech.glide.** { *; }
+-keep class com.bumptech.glide.integration.okhttp3.** { *; }
+-dontwarn com.bumptech.glide.**
+-dontwarn com.bumptech.glide.load.engine.bitmap_recycle.**
+
+# ==================== ROOM ====================
+-keep class * extends androidx.room.RoomDatabase
+-keep @androidx.room.Entity class *
+-keepclassmembers class * {
+ @androidx.room.* ;
+}
+-keep class androidx.room.** { *; }
+-keep class *Database_Impl { *; }
+
+# ==================== WEBSOCKET ====================
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+-keep class org.java_websocket.** { *; }
+
+# ==================== SECURITY (EncryptedPreferences) ====================
+-keep class androidx.security.** { *; }
+-keep class com.google.crypto.tink.** { *; }
+-dontwarn com.google.crypto.tink.**
+
+# ==================== МЕДИА (Compressor, Transcoder) ====================
+-keep class id.zelory.compressor.** { *; }
+-keep class com.otaliastudios.transcoder.** { *; }
+-dontwarn com.otaliastudios.transcoder.**
+
+# ==================== НАШИ МОДЕЛИ И DATA CLASSES ====================
+# Сохраняем все наши модели (для Gson/Room)
+-keep class com.example.myserver.** { *; }
+-keepclassmembers class com.example.myserver.** {
+ *** Companion;
+ *** INSTANCE;
+ (...);
+ ;
+}
+
+# Сохраняем sealed классы
+-keep class com.example.myserver.AuthState { *; }
+-keep class com.example.myserver.SessionState { *; }
+-keep class com.example.myserver.MessageStatus { *; }
+
+# ==================== УДАЛЕНИЕ ЛОГОВ В RELEASE ====================
+-assumenosideeffects class android.util.Log {
+ public static boolean isLoggable(java.lang.String, int);
+ public static int v(...);
+ public static int d(...);
+ public static int i(...);
+ public static int w(...);
+ public static int e(...);
+}
+
+# ==================== РАЗНОЕ ====================
+-keep class * implements android.os.Parcelable {
+ public static final android.os.Parcelable$Creator *;
+}
+-keepclassmembers class * implements java.io.Serializable {
+ static final long serialVersionUID;
+ private static final java.io.ObjectStreamField[] serialPersistentFields;
+ private void writeObject(java.io.ObjectOutputStream);
+ private void readObject(java.io.ObjectInputStream);
+ java.lang.Object writeReplace();
+ java.lang.Object readResolve();
+}
+-keepclassmembers class * {
+ @android.webkit.JavascriptInterface ;
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1956d1c..bb7d05b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,9 +4,16 @@
+
+
+
+
+
+
+ tools:targetApi="31">
+
+
+
+
+
+
+
+
+
@@ -35,15 +57,29 @@
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/AudioRecorder.kt b/app/src/main/java/com/example/myserver/AudioRecorder.kt
new file mode 100644
index 0000000..9d76a22
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/AudioRecorder.kt
@@ -0,0 +1,83 @@
+package com.example.myserver.audio
+
+import android.media.MediaRecorder
+import android.os.Handler
+import android.os.Looper
+import java.io.File
+import java.io.IOException
+
+class AudioRecorder(private val onAmplitudeUpdate: (Int) -> Unit) {
+
+ private var mediaRecorder: MediaRecorder? = null
+ private var isRecording = false
+ private var outputFile: File? = null
+ private val handler = Handler(Looper.getMainLooper())
+ private var amplitudeRunnable: Runnable? = null
+
+ fun startRecording(outputDir: File): File? {
+ try {
+ outputFile = File(outputDir, "audio_${System.currentTimeMillis()}.3gp")
+
+ mediaRecorder = MediaRecorder().apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
+ setOutputFile(outputFile?.absolutePath)
+
+ try {
+ prepare()
+ } catch (e: IOException) {
+ e.printStackTrace()
+ return null
+ }
+
+ start()
+ }
+
+ isRecording = true
+ startAmplitudeMonitoring()
+ return outputFile
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ fun stopRecording(): File? {
+ try {
+ mediaRecorder?.apply {
+ stop()
+ release()
+ }
+ mediaRecorder = null
+ isRecording = false
+ stopAmplitudeMonitoring()
+ return outputFile
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ fun isRecording(): Boolean = isRecording
+
+ private fun startAmplitudeMonitoring() {
+ amplitudeRunnable = object : Runnable {
+ override fun run() {
+ if (isRecording) {
+ val maxAmplitude = mediaRecorder?.maxAmplitude ?: 0
+ val percent = (maxAmplitude / 32767.0 * 100).toInt()
+ onAmplitudeUpdate(percent)
+ handler.postDelayed(this, 100)
+ }
+ }
+ }
+ amplitudeRunnable?.let { handler.post(it) }
+ }
+
+ private fun stopAmplitudeMonitoring() {
+ amplitudeRunnable?.let { handler.removeCallbacks(it) }
+ amplitudeRunnable = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/AuthViewModel.kt b/app/src/main/java/com/example/myserver/AuthViewModel.kt
index 7e46ae7..43de928 100644
--- a/app/src/main/java/com/example/myserver/AuthViewModel.kt
+++ b/app/src/main/java/com/example/myserver/AuthViewModel.kt
@@ -4,45 +4,128 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
- private val apiService: MyApiService
+ private val apiService: MyApiService,
+ private val sessionManager: SessionManager
) : ViewModel() {
- // Состояние экрана (загрузка, успех, ошибка)
private val _authState = MutableStateFlow(AuthState.Idle)
- val authState = _authState.asStateFlow()
+ val authState: StateFlow = _authState
- fun authUser(request: UserRequest, isRegistration: Boolean) {
+ private val _sessionState = MutableStateFlow(SessionState.Inactive)
+ val sessionState: StateFlow = _sessionState
+
+ fun checkAutoLogin(onSuccess: () -> Unit, onError: () -> Unit) {
+ viewModelScope.launch {
+ val sessionKey = sessionManager.getSessionKey()
+
+ if (sessionKey != null && sessionManager.isSessionValid()) {
+ try {
+ val response = apiService.checkSession(sessionKey)
+ if (response.isSuccessful && response.body()?.isActive == true) {
+ _sessionState.value = SessionState.Active(sessionManager.getUsername() ?: "")
+ onSuccess()
+ } else {
+ sessionManager.clearSession()
+ onError()
+ }
+ } catch (e: Exception) {
+ onError()
+ }
+ } else {
+ onError()
+ }
+ }
+ }
+ fun register(userRequest: UserRequest) {
viewModelScope.launch {
_authState.value = AuthState.Loading
try {
- val response = if (isRegistration) {
- apiService.register(request)
+ val response = apiService.register(userRequest)
+ if (response.isSuccessful) {
+ val result = response.body()
+ if (result?.success == true) {
+ _authState.value = AuthState.Success
+ } else {
+ _authState.value = AuthState.Error(result?.message ?: "Ошибка регистрации")
+ }
} else {
- apiService.login(request)
- }
-
- if (response.isSuccessful && response.body() != null) {
- _authState.value = AuthState.Success(response.body()!!)
- } else {
- _authState.value = AuthState.Error("Ошибка: ${response.code()}")
+ _authState.value = AuthState.Error("Ошибка сервера: ${response.code()}")
}
+ } catch (e: IOException) {
+ _authState.value = AuthState.Error("Нет соединения с сервером")
} catch (e: Exception) {
- _authState.value = AuthState.Error("Сеть недоступна: ${e.message}")
+ _authState.value = AuthState.Error("Неизвестная ошибка")
}
}
}
+
+ fun login(userRequest: UserRequest, deviceInfo: DeviceInfo, onSuccess: () -> Unit, onError: (String) -> Unit) {
+ viewModelScope.launch {
+ _authState.value = AuthState.Loading
+ try {
+ val response = apiService.login(userRequest)
+
+ if (response.isSuccessful) {
+ val result = response.body()
+ if (result?.success == true && result.sessionKey != null) {
+ sessionManager.saveSession(
+ result.sessionKey,
+ result.userId,
+ result.username ?: "",
+ result.realname ?: ""
+ )
+ _authState.value = AuthState.Success
+ _sessionState.value = SessionState.Active(result.username ?: "")
+ onSuccess()
+ } else {
+ onError(result?.message ?: "Ошибка входа")
+ }
+ } else {
+ onError("Ошибка сервера: ${response.code()}")
+ }
+ } catch (e: IOException) {
+ onError("Нет соединения с сервером")
+ } catch (e: HttpException) {
+ onError("Ошибка сервера")
+ } catch (e: Exception) {
+ onError("Неизвестная ошибка")
+ }
+ }
+ }
+
+ fun logout(onComplete: () -> Unit) {
+ viewModelScope.launch {
+ val sessionKey = sessionManager.getSessionKey()
+ if (sessionKey != null) {
+ try {
+ apiService.logout(sessionKey)
+ } catch (e: Exception) {
+ // Игнорируем
+ }
+ }
+ sessionManager.clearSession()
+ _sessionState.value = SessionState.Inactive
+ onComplete()
+ }
+ }
}
-// Описываем возможные состояния
sealed class AuthState {
object Idle : AuthState()
object Loading : AuthState()
- data class Success(val user: UserResponse) : AuthState()
+ object Success : AuthState()
data class Error(val message: String) : AuthState()
}
+
+sealed class SessionState {
+ object Inactive : SessionState()
+ data class Active(val username: String) : SessionState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/ChatActivity.kt b/app/src/main/java/com/example/myserver/ChatActivity.kt
new file mode 100644
index 0000000..ab593d6
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/ChatActivity.kt
@@ -0,0 +1,1403 @@
+package com.example.myserver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Bundle
+import android.provider.MediaStore
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.content.FileProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.example.myserver.audio.AudioRecorder
+import com.example.myserver.crypto.SecretChatManager
+import com.example.myserver.crypto.SecretChatState
+import com.example.myserver.services.WebSocketManager
+import com.google.gson.Gson
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.io.File
+import javax.inject.Inject
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+
+private const val TAG = "ChatActivity"
+private const val PAGE_SIZE = 30
+private const val TYPING_DELAY_MS = 1000L
+private const val TYPING_COOLDOWN_MS = 3000L
+
+@AndroidEntryPoint
+class ChatActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var apiService: MyApiService
+
+ @Inject
+ lateinit var webSocketManager: WebSocketManager
+
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ lateinit var secretChatManager: SecretChatManager
+
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages
+
+ private var recipientId by mutableStateOf(0L)
+ private var recipientName by mutableStateOf("")
+ private var currentUserId by mutableStateOf(0L)
+
+ private var currentPage = 0
+ private var totalPages = 0
+ private var isLoadingMore = false
+ private var hasMoreMessages = true
+
+ private var _isTyping = MutableStateFlow(false)
+ val isTyping: StateFlow = _isTyping
+ private var typingJob: Job? = null
+ private var lastSentTypingTime = 0L
+
+ private var isSecretChat = false
+ private var secretChatId: String? = null
+ private var tempPhotoUri: Uri? = null
+ private var audioRecorder: AudioRecorder? = null
+ private var isRecordingAudio = false
+ private var recordingStartTime = 0L
+ private val audioDir by lazy { File(cacheDir, "audio") }
+
+ private var _isRecordingAudio = MutableStateFlow(false)
+ val isRecordingAudioFlow: StateFlow = _isRecordingAudio
+
+ private var _recordingAmplitude = MutableStateFlow(0)
+ val recordingAmplitude: StateFlow = _recordingAmplitude
+
+ private var recordingTimerJob: Job? = null
+ private var recordingDuration by mutableStateOf(0)
+
+ private val gson = Gson()
+
+ private val mediaPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ val data = result.data
+ val fileUri = data?.data
+ fileUri?.let { handleSelectedMedia(it) }
+ }
+ }
+
+ private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
+ if (success) {
+ tempPhotoUri?.let { handleSelectedMedia(it) }
+ }
+ }
+
+ private fun startAudioRecording() {
+ if (!audioDir.exists()) audioDir.mkdirs()
+
+ audioRecorder = AudioRecorder { amplitude ->
+ _recordingAmplitude.value = amplitude
+ }
+
+ val file = audioRecorder?.startRecording(audioDir)
+ if (file != null) {
+ isRecordingAudio = true
+ recordingStartTime = System.currentTimeMillis()
+ _isRecordingAudio.value = true
+ startRecordingTimer()
+ }
+ }
+
+ private fun stopAudioRecording() {
+ val file = audioRecorder?.stopRecording()
+ isRecordingAudio = false
+ _isRecordingAudio.value = false
+ recordingTimerJob?.cancel()
+ _recordingAmplitude.value = 0
+
+ if (file != null && file.exists()) {
+ val duration = (System.currentTimeMillis() - recordingStartTime) / 1000
+ if (duration >= 1) {
+ sendAudioMessage(file, duration)
+ } else {
+ file.delete()
+ Toast.makeText(this, "Слишком короткое сообщение", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun startRecordingTimer() {
+ recordingTimerJob?.cancel()
+ recordingDuration = 0
+ recordingTimerJob = lifecycleScope.launch {
+ while (isRecordingAudio) {
+ delay(1000)
+ recordingDuration++
+ }
+ }
+ }
+
+ private fun sendAudioMessage(audioFile: File, duration: Long) {
+ val tempId = System.currentTimeMillis()
+
+ val tempMessage = ChatMessage(
+ id = tempId,
+ senderId = currentUserId,
+ senderName = sessionManager.getUsername() ?: "Я",
+ recipientId = recipientId,
+ content = "🎤 Голосовое сообщение (${duration}с)",
+ timestamp = listOf(0, 0, 0, 0, 0, 0, 0),
+ status = MessageStatus.PENDING,
+ read = false,
+ attachments = listOf(
+ MediaAttachment(
+ id = tempId,
+ localPath = audioFile.absolutePath,
+ mediaType = MediaType.AUDIO,
+ mimeType = "audio/3gp",
+ fileSize = audioFile.length(),
+ duration = duration,
+ isUploaded = false
+ )
+ )
+ )
+ _messages.value += tempMessage
+
+ uploadAudio(audioFile, tempId)
+ }
+
+ private fun uploadAudio(audioFile: File, tempId: Long) {
+ lifecycleScope.launch {
+ try {
+ val token = sessionManager.getSessionKey() ?: return@launch
+ val fileBytes = audioFile.readBytes()
+
+ val response = apiService.uploadMedia(
+ sessionKey = token,
+ recipientId = recipientId,
+ file = MultipartBody.Part.createFormData(
+ "file",
+ audioFile.name,
+ fileBytes.toRequestBody()
+ )
+ )
+
+ if (response.isSuccessful) {
+ updateMessageWithMediaUrl(tempId, response.body()?.url)
+ audioFile.delete()
+ } else {
+ updateMessageStatus(tempId, MessageStatus.ERROR)
+ }
+ } catch (e: Exception) {
+ updateMessageStatus(tempId, MessageStatus.ERROR)
+ }
+ }
+ }
+
+ private val messageReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val type = intent.getStringExtra("type") ?: return
+ val data = intent.getStringExtra("data") ?: return
+
+ try {
+ val json = JSONObject(data)
+ when (type) {
+ "new_message" -> {
+ val messageJson = json.getJSONObject("message").toString()
+ val message = gson.fromJson(messageJson, ChatMessage::class.java)
+ addNewMessage(message)
+ }
+ "message_sent" -> {
+ val messageId = json.getLong("messageId")
+ val tempId = if (json.has("tempId")) json.getLong("tempId") else null
+ updatePendingMessageStatus(tempId, messageId, MessageStatus.SENT)
+ }
+ "message_delivered" -> {
+ val messageId = json.getLong("messageId")
+ updateMessageStatus(messageId, MessageStatus.DELIVERED)
+ }
+ "message_read" -> {
+ val messageId = json.getLong("messageId")
+ updateMessageStatus(messageId, MessageStatus.READ)
+ }
+ "message_edited" -> {
+ val messageId = json.getLong("messageId")
+ val newContent = json.getString("content")
+ updateEditedMessage(messageId, newContent)
+ }
+ "message_deleted" -> {
+ val messageId = json.getLong("messageId")
+ deleteMessageLocally(messageId)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error: ${e.message}")
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ recipientId = intent.getLongExtra("RECIPIENT_ID", 0)
+ recipientName = intent.getStringExtra("RECIPIENT_NAME") ?: "Игрок"
+ currentUserId = sessionManager.getUserId()
+ isSecretChat = intent.getBooleanExtra("IS_SECRET_CHAT", false)
+ secretChatId = intent.getStringExtra("SECRET_CHAT_ID")
+
+ webSocketManager.setMessageListener { type, data ->
+ when (type) {
+ "new_message" -> {
+ val message = gson.fromJson(data.getJSONObject("message").toString(), ChatMessage::class.java)
+ addNewMessage(message)
+ }
+ "message_sent" -> {
+ val messageId = data.getLong("messageId")
+ val tempId = if (data.has("tempId")) data.getLong("tempId") else null
+ updatePendingMessageStatus(tempId, messageId, MessageStatus.SENT)
+ }
+ "message_delivered" -> {
+ val messageId = data.getLong("messageId")
+ updateMessageStatus(messageId, MessageStatus.DELIVERED)
+ }
+ "message_read" -> {
+ val messageId = data.getLong("messageId")
+ updateMessageStatus(messageId, MessageStatus.READ)
+ }
+ "message_edited" -> {
+ val messageId = data.getLong("messageId")
+ val newContent = data.getString("content")
+ updateEditedMessage(messageId, newContent)
+ }
+ "message_deleted" -> {
+ val messageId = data.getLong("messageId")
+ deleteMessageLocally(messageId)
+ }
+ "typing" -> {
+ val userId = data.getLong("userId")
+ val isTypingNow = data.getBoolean("isTyping")
+ if (userId == recipientId) {
+ _isTyping.value = isTypingNow
+ }
+ }
+ }
+ }
+
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ messageReceiver,
+ IntentFilter("WEBSOCKET_MESSAGE")
+ )
+
+ loadFirstPage()
+
+ if (isSecretChat && secretChatId != null) {
+ lifecycleScope.launch {
+ secretChatManager.secretChatState.collect { state ->
+ when (state) {
+ is SecretChatState.Active -> {
+ Toast.makeText(this@ChatActivity, "Секретный чат активирован", Toast.LENGTH_SHORT).show()
+ }
+ is SecretChatState.Error -> {
+ Toast.makeText(this@ChatActivity, state.message, Toast.LENGTH_LONG).show()
+ }
+ else -> {}
+ }
+ }
+ }
+ }
+
+ setContent {
+ MaterialTheme {
+ val messagesState by messages.collectAsState()
+ val isTypingState by isTyping.collectAsState()
+ val isRecordingState by isRecordingAudioFlow.collectAsState()
+ val amplitudeState by recordingAmplitude.collectAsState()
+
+ ChatScreenContent(
+ messages = messagesState,
+ recipientName = recipientName,
+ currentUserId = currentUserId,
+ isTyping = isTypingState,
+ isSecretChat = isSecretChat,
+ isRecordingAudio = isRecordingState,
+ recordingAmplitude = amplitudeState,
+ recordingDuration = recordingDuration,
+ onSendMessage = { content ->
+ sendMessage(content)
+ },
+ onBackClick = { finish() },
+ onLoadMore = {
+ if (hasMoreMessages && !isLoadingMore) {
+ loadMoreMessages()
+ }
+ },
+ isLoadingMore = isLoadingMore,
+ onTyping = { isTyping ->
+ handleTyping(isTyping)
+ },
+ onEditMessage = { messageId, newContent ->
+ editMessage(messageId, newContent)
+ },
+ onDeleteMessage = { messageId ->
+ deleteMessage(messageId)
+ },
+ onSelectMedia = { type ->
+ selectMedia(type)
+ },
+ onStartAudioRecording = {
+ startAudioRecording()
+ },
+ onStopAudioRecording = {
+ stopAudioRecording()
+ }
+ )
+ }
+ }
+ }
+
+ private fun loadFirstPage() {
+ lifecycleScope.launch {
+ try {
+ val token = sessionManager.getSessionKey()
+ if (token == null) return@launch
+
+ val response = apiService.getChatHistoryPaginated(recipientId, 0, PAGE_SIZE, token)
+ if (response.isSuccessful) {
+ val result = response.body()
+ if (result != null) {
+ val convertedMessages = result.messages.map { message ->
+ message.copy(
+ status = when {
+ message.read -> MessageStatus.READ
+ message.deliveredAt != null -> MessageStatus.DELIVERED
+ else -> MessageStatus.SENT
+ }
+ )
+ }
+ _messages.value = convertedMessages.reversed()
+ currentPage = result.currentPage
+ totalPages = result.totalPages
+ hasMoreMessages = result.hasNextPage
+ Log.d(TAG, "Loaded page $currentPage/${totalPages}, messages: ${_messages.value.size}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error loading first page: ${e.message}")
+ }
+ }
+ }
+
+ private fun loadMoreMessages() {
+ if (isLoadingMore || !hasMoreMessages) return
+
+ lifecycleScope.launch {
+ isLoadingMore = true
+ try {
+ val token = sessionManager.getSessionKey()
+ if (token == null) return@launch
+
+ val nextPage = currentPage + 1
+ val response = apiService.getChatHistoryPaginated(recipientId, nextPage, PAGE_SIZE, token)
+ if (response.isSuccessful) {
+ val result = response.body()
+ if (result != null && result.messages.isNotEmpty()) {
+ val convertedMessages = result.messages.map { message ->
+ message.copy(
+ status = when {
+ message.read -> MessageStatus.READ
+ message.deliveredAt != null -> MessageStatus.DELIVERED
+ else -> MessageStatus.SENT
+ }
+ )
+ }
+ val oldMessages = convertedMessages.reversed()
+ _messages.value = oldMessages + _messages.value
+ currentPage = result.currentPage
+ totalPages = result.totalPages
+ hasMoreMessages = result.hasNextPage
+ Log.d(TAG, "Loaded more messages, total: ${_messages.value.size}")
+ } else {
+ hasMoreMessages = false
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error loading more messages: ${e.message}")
+ } finally {
+ isLoadingMore = false
+ }
+ }
+ }
+
+ private fun addNewMessage(message: ChatMessage) {
+ val newMessage = message.copy(
+ status = when {
+ message.read -> MessageStatus.READ
+ else -> MessageStatus.SENT
+ }
+ )
+ _messages.value += newMessage
+
+ if (newMessage.senderId == recipientId && !newMessage.read) {
+ markAsRead(newMessage.id)
+ }
+ }
+
+ private fun markAsRead(messageId: Long) {
+ webSocketManager.sendMessage("read", mapOf(
+ "messageIds" to messageId.toString()
+ ))
+ updateMessageStatus(messageId, MessageStatus.READ)
+ }
+
+ private fun updatePendingMessageStatus(tempId: Long?, realId: Long, status: MessageStatus) {
+ if (tempId != null) {
+ _messages.value = _messages.value.map { message ->
+ if (message.id == tempId) {
+ message.copy(id = realId, status = status)
+ } else {
+ message
+ }
+ }
+ } else {
+ updateMessageStatus(realId, status)
+ }
+ }
+
+ private fun updateMessageStatus(messageId: Long, status: MessageStatus) {
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ message.copy(status = status)
+ } else {
+ message
+ }
+ }
+ }
+
+ private fun updateEditedMessage(messageId: Long, newContent: String) {
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ message.copy(
+ content = newContent,
+ isEdited = true,
+ status = MessageStatus.EDITED
+ )
+ } else {
+ message
+ }
+ }
+ }
+
+ private fun deleteMessageLocally(messageId: Long) {
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ message.copy(
+ isDeleted = true,
+ content = "Сообщение удалено",
+ status = MessageStatus.SENT
+ )
+ } else {
+ message
+ }
+ }
+ }
+
+ private fun sendMessage(content: String) {
+ if (isSecretChat && secretChatId != null) {
+ val success = secretChatManager.sendSecretMessage(secretChatId!!, content, recipientId)
+ if (success) {
+ val tempMessage = ChatMessage(
+ id = System.currentTimeMillis(),
+ senderId = currentUserId,
+ senderName = sessionManager.getUsername() ?: "Я",
+ recipientId = recipientId,
+ content = "🔐 Зашифрованное сообщение",
+ timestamp = listOf(0, 0, 0, 0, 0, 0, 0),
+ status = MessageStatus.SENT,
+ read = false,
+ isSecret = true
+ )
+ _messages.value = _messages.value + tempMessage
+ } else {
+ Toast.makeText(this, "Ошибка отправки в секретный чат", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ val tempId = System.currentTimeMillis()
+
+ val tempMessage = ChatMessage(
+ id = tempId,
+ senderId = currentUserId,
+ senderName = sessionManager.getUsername() ?: "Я",
+ recipientId = recipientId,
+ content = content,
+ timestamp = listOf(0, 0, 0, 0, 0, 0, 0),
+ status = if (webSocketManager.isConnected()) MessageStatus.PENDING else MessageStatus.QUEUED,
+ read = false
+ )
+
+ _messages.value = _messages.value + tempMessage
+
+ val message = JSONObject().apply {
+ put("type", "message")
+ put("recipientId", recipientId)
+ put("content", content)
+ put("tempId", tempId)
+ }
+
+ val success = webSocketManager.sendMessage(message.toString(), tempId)
+ if (!success && !webSocketManager.isConnected()) {
+ updateMessageStatus(tempId, MessageStatus.QUEUED)
+ Toast.makeText(this, "Нет соединения. Сообщение будет отправлено позже.", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun editMessage(messageId: Long, newContent: String) {
+ webSocketManager.sendMessage("edit", mapOf(
+ "messageId" to messageId,
+ "content" to newContent
+ ))
+
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ message.copy(
+ content = newContent,
+ isEdited = true,
+ status = MessageStatus.EDITED
+ )
+ } else {
+ message
+ }
+ }
+ }
+
+ private fun deleteMessage(messageId: Long) {
+ webSocketManager.sendMessage("delete", mapOf(
+ "messageId" to messageId
+ ))
+
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ message.copy(
+ isDeleted = true,
+ content = "Сообщение удалено",
+ status = MessageStatus.SENT
+ )
+ } else {
+ message
+ }
+ }
+ }
+
+ private fun handleTyping(typing: Boolean) {
+ if (!webSocketManager.isConnected()) return
+
+ val now = System.currentTimeMillis()
+
+ if (typing) {
+ typingJob?.cancel()
+ if (now - lastSentTypingTime > TYPING_COOLDOWN_MS) {
+ sendTypingStatus(true)
+ lastSentTypingTime = now
+ }
+ typingJob = lifecycleScope.launch {
+ delay(TYPING_DELAY_MS)
+ sendTypingStatus(false)
+ }
+ } else {
+ typingJob?.cancel()
+ sendTypingStatus(false)
+ }
+ }
+
+ private fun sendTypingStatus(isTyping: Boolean) {
+ val type = if (isTyping) "typing_start" else "typing_stop"
+ webSocketManager.sendMessage(type, mapOf(
+ "recipientId" to recipientId
+ ))
+ }
+
+ private fun selectMedia(type: String) {
+ when (type) {
+ "gallery" -> {
+ val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+ mediaPickerLauncher.launch(intent)
+ }
+ "camera" -> {
+ val photoFile = File(cacheDir, "temp_photo_${System.currentTimeMillis()}.jpg")
+ tempPhotoUri = FileProvider.getUriForFile(
+ this,
+ "${packageName}.fileprovider",
+ photoFile
+ )
+ cameraLauncher.launch(tempPhotoUri)
+ }
+ }
+ }
+
+ private fun handleSelectedMedia(uri: Uri) {
+ lifecycleScope.launch {
+ try {
+ val mimeType = contentResolver.getType(uri)
+ val tempId = System.currentTimeMillis()
+
+ val tempMessage = ChatMessage(
+ id = tempId,
+ senderId = currentUserId,
+ senderName = sessionManager.getUsername() ?: "Я",
+ recipientId = recipientId,
+ content = if (mimeType?.startsWith("image") == true) "📷 Фото" else "🎥 Видео",
+ timestamp = listOf(0, 0, 0, 0, 0, 0, 0),
+ status = MessageStatus.PENDING,
+ read = false,
+ attachments = listOf(
+ MediaAttachment(
+ id = tempId,
+ localPath = uri.toString(),
+ mediaType = if (mimeType?.startsWith("image") == true) MediaType.IMAGE else MediaType.VIDEO,
+ mimeType = mimeType ?: "image/jpeg",
+ fileSize = 0,
+ isUploaded = false
+ )
+ )
+ )
+ _messages.value = _messages.value + tempMessage
+
+ uploadMedia(uri, tempId, mimeType ?: "image/jpeg")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling media: ${e.message}")
+ Toast.makeText(this@ChatActivity, "Ошибка обработки медиа", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun uploadMedia(uri: Uri, tempId: Long, mimeType: String) {
+ lifecycleScope.launch {
+ try {
+ val token = sessionManager.getSessionKey()
+ if (token == null) return@launch
+
+ val inputStream = contentResolver.openInputStream(uri)
+ val fileBytes = inputStream?.readBytes()
+ inputStream?.close()
+
+ if (fileBytes == null) {
+ updateMessageStatus(tempId, MessageStatus.ERROR)
+ return@launch
+ }
+
+ val compressedBytes = if (mimeType.startsWith("image")) {
+ compressImage(fileBytes)
+ } else {
+ fileBytes
+ }
+
+ val response = apiService.uploadMedia(
+ sessionKey = token,
+ recipientId = recipientId,
+ file = MultipartBody.Part.createFormData(
+ "file",
+ "media_${System.currentTimeMillis()}.${getExtension(mimeType)}",
+ compressedBytes.toRequestBody()
+ )
+ )
+
+ if (response.isSuccessful) {
+ val mediaUrl = response.body()?.url
+ updateMessageWithMediaUrl(tempId, mediaUrl)
+ } else {
+ updateMessageStatus(tempId, MessageStatus.ERROR)
+ }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Upload error: ${e.message}")
+ updateMessageStatus(tempId, MessageStatus.ERROR)
+ }
+ }
+ }
+
+ private fun compressImage(bytes: ByteArray): ByteArray {
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 70, stream)
+ bitmap.recycle()
+ return stream.toByteArray()
+ }
+
+ private fun getExtension(mimeType: String): String {
+ return when {
+ mimeType.contains("jpeg") || mimeType.contains("jpg") -> "jpg"
+ mimeType.contains("png") -> "png"
+ mimeType.contains("gif") -> "gif"
+ mimeType.contains("mp4") -> "mp4"
+ else -> "bin"
+ }
+ }
+
+ private fun updateMessageWithMediaUrl(messageId: Long, mediaUrl: String?) {
+ _messages.value = _messages.value.map { message ->
+ if (message.id == messageId) {
+ val updatedAttachments = message.attachments.map { attachment ->
+ attachment.copy(remoteUrl = mediaUrl, isUploaded = true)
+ }
+ message.copy(
+ attachments = updatedAttachments,
+ status = MessageStatus.SENT
+ )
+ } else {
+ message
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ sendDeliveredConfirmation()
+ }
+
+ private fun sendDeliveredConfirmation() {
+ val undeliveredMessages = _messages.value.filter {
+ it.recipientId == currentUserId &&
+ it.status != MessageStatus.DELIVERED &&
+ it.status != MessageStatus.READ
+ }
+
+ if (undeliveredMessages.isNotEmpty()) {
+ val messageIds = undeliveredMessages.joinToString(",") { it.id.toString() }
+ webSocketManager.sendMessage("delivered", mapOf(
+ "messageIds" to messageIds
+ ))
+
+ undeliveredMessages.forEach { message ->
+ updateMessageStatus(message.id, MessageStatus.DELIVERED)
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ webSocketManager.removeMessageListener()
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(messageReceiver)
+ }
+}
+
+// ==================== UI COMPONENTS ====================
+
+@Composable
+fun ChatScreenContent(
+ messages: List,
+ recipientName: String,
+ currentUserId: Long,
+ isTyping: Boolean,
+ isSecretChat: Boolean,
+ isRecordingAudio: Boolean,
+ recordingAmplitude: Int,
+ recordingDuration: Int,
+ onSendMessage: (String) -> Unit,
+ onBackClick: () -> Unit,
+ onLoadMore: () -> Unit,
+ isLoadingMore: Boolean,
+ onTyping: (Boolean) -> Unit,
+ onEditMessage: (Long, String) -> Unit,
+ onDeleteMessage: (Long) -> Unit,
+ onSelectMedia: (String) -> Unit,
+ onStartAudioRecording: () -> Unit,
+ onStopAudioRecording: () -> Unit
+) {
+ var inputText by remember { mutableStateOf("") }
+ val listState = rememberLazyListState()
+ var showMediaDialog by remember { mutableStateOf(false) }
+ var lastText by remember { mutableStateOf("") } // 👈 ИСПРАВЛЕНО
+ var isPressing by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(inputText) {
+ if (inputText.isNotBlank() && lastText.isBlank()) {
+ onTyping(true)
+ } else if (inputText.isBlank() && lastText.isNotBlank()) {
+ onTyping(false)
+ }
+ lastText = inputText // 👈 ТЕПЕРЬ РАБОТАЕТ
+ }
+
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) {
+ listState.animateScrollToItem(messages.size - 1)
+ }
+ }
+
+ LaunchedEffect(listState.firstVisibleItemIndex) {
+ if (listState.firstVisibleItemIndex <= 2 && !isLoadingMore) {
+ onLoadMore()
+ }
+ }
+
+ if (showMediaDialog) {
+ AlertDialog(
+ onDismissRequest = { showMediaDialog = false },
+ title = { Text("Добавить медиа", color = Color.White) },
+ text = {
+ Column {
+ TextButton(onClick = { onSelectMedia("gallery"); showMediaDialog = false }) {
+ Text("📷 Выбрать из галереи", color = Color.White)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { onSelectMedia("camera"); showMediaDialog = false }) {
+ Text("📸 Сделать фото", color = Color.White)
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = { showMediaDialog = false }) {
+ Text("Отмена", color = Color.Gray)
+ }
+ },
+ containerColor = Color(0xFF2A3A40)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF3E4B54))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color(0xFF2A3A40))
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = "Назад",
+ tint = Color.White
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 8.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = recipientName,
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium
+ )
+ if (isSecretChat) {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Secret Chat",
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ if (isTyping) {
+ Text(
+ text = "печатает...",
+ color = Color(0xFF4CAF50),
+ fontSize = 12.sp
+ )
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(8.dp),
+ state = listState,
+ reverseLayout = false
+ ) {
+ if (isLoadingMore) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp),
+ color = Color.White
+ )
+ }
+ }
+ }
+
+ items(
+ items = messages,
+ key = { it.id }
+ ) { message ->
+ ChatBubbleItem(
+ message = message,
+ isMine = message.senderId == currentUserId,
+ onEdit = onEditMessage,
+ onDelete = onDeleteMessage
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+
+ if (isRecordingAudio) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val animatedHeight by animateDpAsState(
+ targetValue = (20 + recordingAmplitude / 5).dp,
+ animationSpec = tween(50)
+ )
+ Box(
+ modifier = Modifier
+ .width(4.dp)
+ .height(animatedHeight)
+ .background(Color(0xFF4CAF50), RoundedCornerShape(2.dp))
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "● Запись... ${recordingDuration}s",
+ color = Color.Red,
+ fontSize = 14.sp
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = { showMediaDialog = true }) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Добавить",
+ tint = Color.White
+ )
+ }
+
+ Box(
+ modifier = Modifier.weight(1f)
+ ) {
+ OutlinedTextField(
+ value = inputText,
+ onValueChange = { inputText = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ if (inputText.isBlank()) {
+ isPressing = true
+ onStartAudioRecording()
+ scope.launch {
+ delay(5000)
+ if (isPressing) {
+ onStopAudioRecording()
+ isPressing = false
+ }
+ }
+ }
+ },
+ onPress = {
+ try {
+ awaitRelease()
+ if (isPressing) {
+ onStopAudioRecording()
+ isPressing = false
+ }
+ } catch (e: Exception) {
+ // ignore
+ }
+ }
+ )
+ },
+ placeholder = { Text(if (isRecordingAudio) "Отпустите для отправки..." else "Сообщение или удерживайте для записи") },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White,
+ unfocusedBorderColor = Color.Gray,
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White
+ ),
+ singleLine = true
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (inputText.isNotBlank() && !isRecordingAudio) {
+ Button(
+ onClick = {
+ onSendMessage(inputText)
+ inputText = ""
+ onTyping(false)
+ },
+ shape = RoundedCornerShape(28.dp)
+ ) {
+ Text("➤", fontSize = 20.sp)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun AnimatedTypingDots() {
+ val infiniteTransition = rememberInfiniteTransition()
+ val alpha1 by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(300, 0),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ val alpha2 by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(300, 300),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ val alpha3 by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(300, 600),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+
+ Row(
+ modifier = Modifier
+ .background(Color(0xFF2A3A40), RoundedCornerShape(16.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "●",
+ color = Color.White.copy(alpha = alpha1),
+ fontSize = 12.sp
+ )
+ Text(
+ text = "●",
+ color = Color.White.copy(alpha = alpha2),
+ fontSize = 12.sp
+ )
+ Text(
+ text = "●",
+ color = Color.White.copy(alpha = alpha3),
+ fontSize = 12.sp
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "печатает",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 12.sp
+ )
+ }
+}
+
+@Composable
+fun ChatBubbleItem(
+ message: ChatMessage,
+ isMine: Boolean,
+ onEdit: (Long, String) -> Unit,
+ onDelete: (Long) -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ var showEditDialog by remember { mutableStateOf(false) }
+ var editText by remember { mutableStateOf(message.content) }
+
+ if (showEditDialog && isMine && !message.isDeleted) {
+ Dialog(
+ onDismissRequest = { showEditDialog = false },
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color(0xFF2A3A40)
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Редактировать сообщение",
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = editText,
+ onValueChange = { editText = it },
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White,
+ unfocusedBorderColor = Color.Gray,
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White
+ ),
+ singleLine = false,
+ maxLines = 5
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = { showEditDialog = false }) {
+ Text("Отмена", color = Color.Gray)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = {
+ if (editText.isNotBlank() && editText != message.content) {
+ onEdit(message.id, editText)
+ }
+ showEditDialog = false
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF2196F3)
+ )
+ ) {
+ Text("Сохранить")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier.pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ if (isMine && !message.isDeleted) {
+ showMenu = true
+ }
+ }
+ )
+ }
+ ) {
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.background(Color(0xFF2A3A40))
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Edit, contentDescription = null, tint = Color.White)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Редактировать", color = Color.White)
+ }
+ },
+ onClick = {
+ showMenu = false
+ showEditDialog = true
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Delete, contentDescription = null, tint = Color.Red)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Удалить", color = Color.Red)
+ }
+ },
+ onClick = {
+ showMenu = false
+ onDelete(message.id)
+ }
+ )
+ }
+ ChatBubbleItemContent(
+ message = message,
+ isMine = isMine
+ )
+ }
+}
+
+@Composable
+fun ChatBubbleItemContent(
+ message: ChatMessage,
+ isMine: Boolean
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isMine) Arrangement.End else Arrangement.Start
+ ) {
+ Card(
+ shape = RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomStart = if (isMine) 16.dp else 4.dp,
+ bottomEnd = if (isMine) 4.dp else 16.dp
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isMine) Color(0xFF2196F3) else Color(0xFF424242)
+ ),
+ modifier = Modifier
+ .widthIn(max = 250.dp)
+ .padding(4.dp)
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ if (!isMine) {
+ Text(
+ text = message.senderName,
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 12.sp
+ )
+ }
+
+ if (message.isSecret) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Secret",
+ tint = Color.White.copy(alpha = 0.7f),
+ modifier = Modifier.size(12.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = message.content,
+ color = Color.White,
+ fontSize = 14.sp
+ )
+ }
+ } else {
+ Text(
+ text = if (message.isDeleted) "Сообщение удалено" else message.content,
+ color = if (message.isDeleted) Color.White.copy(alpha = 0.5f) else Color.White,
+ fontSize = 14.sp,
+ fontStyle = if (message.isDeleted) androidx.compose.ui.text.font.FontStyle.Italic else androidx.compose.ui.text.font.FontStyle.Normal
+ )
+ }
+
+ message.attachments.forEach { attachment ->
+ when (attachment.mediaType) {
+ MediaType.IMAGE -> {
+ Text("📷 Изображение", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ }
+ MediaType.VIDEO -> {
+ Text("🎥 Видео", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ }
+ MediaType.AUDIO -> {
+ Text("🎤 Голосовое сообщение", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
+ }
+ else -> {}
+ }
+ }
+
+ if (message.isEdited && !message.isDeleted) {
+ Text(
+ text = "(ред.)",
+ color = Color.White.copy(alpha = 0.4f),
+ fontSize = 10.sp
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ horizontalArrangement = if (isMine) Arrangement.End else Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = message.getFormattedTime(),
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 10.sp
+ )
+
+ if (isMine) {
+ Spacer(modifier = Modifier.width(4.dp))
+
+ if (message.status == MessageStatus.READ) {
+ val infiniteTransition = rememberInfiniteTransition()
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 0.5f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(500, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ Text(
+ text = message.getStatusIcon(),
+ fontSize = 11.sp,
+ color = Color(message.getStatusIconColor()).copy(alpha = alpha)
+ )
+ } else {
+ Text(
+ text = message.getStatusIcon(),
+ fontSize = 11.sp,
+ color = Color(message.getStatusIconColor())
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/ChatHistoryResponse.kt b/app/src/main/java/com/example/myserver/ChatHistoryResponse.kt
new file mode 100644
index 0000000..c1fc8de
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/ChatHistoryResponse.kt
@@ -0,0 +1,10 @@
+package com.example.myserver
+
+data class ChatHistoryResponse(
+ val messages: List,
+ val currentPage: Int,
+ val totalPages: Int,
+ val totalMessages: Long,
+ val hasNextPage: Boolean,
+ val hasPreviousPage: Boolean
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/ChatMessage.kt b/app/src/main/java/com/example/myserver/ChatMessage.kt
new file mode 100644
index 0000000..9f93650
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/ChatMessage.kt
@@ -0,0 +1,100 @@
+package com.example.myserver
+
+// MediaType теперь в отдельном файле, не нужно объявлять здесь
+
+data class MediaAttachment(
+ val id: Long,
+ val localPath: String,
+ val remoteUrl: String? = null,
+ val mediaType: MediaType,
+ val mimeType: String,
+ val fileSize: Long,
+ val width: Int = 0,
+ val height: Int = 0,
+ val duration: Long = 0,
+ val thumbnailPath: String? = null,
+ val isUploaded: Boolean = false,
+ val uploadProgress: Int = 0
+)
+
+enum class MessageStatus {
+ PENDING,
+ SENT,
+ DELIVERED,
+ READ,
+ QUEUED,
+ ERROR,
+ EDITED
+}
+
+data class EncryptedDataUI(
+ val cipherText: String,
+ val iv: String,
+ val authTag: String
+)
+
+data class ChatMessage(
+ val id: Long,
+ val senderId: Long,
+ val senderName: String,
+ val recipientId: Long,
+ val content: String,
+ val timestamp: List,
+ val status: MessageStatus = MessageStatus.PENDING,
+ val read: Boolean = false,
+ val deliveredAt: Long? = null,
+ val readAt: Long? = null,
+ val isEdited: Boolean = false,
+ val isDeleted: Boolean = false,
+ val originalContent: String? = null,
+ val replyToMessageId: Long? = null,
+ val forwardedFromId: Long? = null,
+ val isSecret: Boolean = false,
+ val encryptedData: EncryptedDataUI? = null,
+ val attachments: List = emptyList(),
+ val secretChatId: String? = null
+) {
+ fun hasMedia(): Boolean = attachments.isNotEmpty()
+
+ fun isReply(): Boolean = replyToMessageId != null
+
+ fun isForwarded(): Boolean = forwardedFromId != null
+
+ fun getFormattedTime(): String {
+ return if (timestamp.size >= 6) {
+ "${timestamp[3].toString().padStart(2, '0')}:${timestamp[4].toString().padStart(2, '0')}"
+ } else {
+ ""
+ }
+ }
+
+ fun getStatusIcon(): String {
+ return when (status) {
+ MessageStatus.PENDING, MessageStatus.QUEUED -> "⏳"
+ MessageStatus.SENT -> "✓"
+ MessageStatus.DELIVERED -> "✓✓"
+ MessageStatus.READ -> "✓✓"
+ MessageStatus.ERROR -> "❌"
+ MessageStatus.EDITED -> "✏️"
+ }
+ }
+
+ fun getStatusIconColor(): Int {
+ return when (status) {
+ MessageStatus.READ -> 0xFF4CAF50.toInt()
+ MessageStatus.DELIVERED -> 0xFF2196F3.toInt()
+ MessageStatus.SENT -> 0xFF888888.toInt()
+ MessageStatus.PENDING, MessageStatus.QUEUED -> 0xFFFF9800.toInt()
+ MessageStatus.ERROR -> 0xFFF44336.toInt()
+ MessageStatus.EDITED -> 0xFF9C27B0.toInt()
+ }
+ }
+
+ fun getDisplayContent(): String {
+ return if (isDeleted) {
+ "Сообщение удалено"
+ } else {
+ content
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/ChatRepository.kt b/app/src/main/java/com/example/myserver/ChatRepository.kt
new file mode 100644
index 0000000..2277359
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/ChatRepository.kt
@@ -0,0 +1,107 @@
+package com.example.myserver
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Database(
+ entities = [MessageEntity::class],
+ version = 2,
+ exportSchema = false
+)
+abstract class ChatDatabase : RoomDatabase() {
+ abstract fun messageDao(): MessageDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: ChatDatabase? = null
+
+ fun getInstance(context: Context): ChatDatabase {
+ return INSTANCE ?: synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ ChatDatabase::class.java,
+ "chat_database"
+ ).fallbackToDestructiveMigration()
+ .build()
+ INSTANCE = instance
+ instance
+ }
+ }
+ }
+}
+
+@Singleton
+class ChatRepository @Inject constructor(
+ private val context: Context
+) {
+ private val database by lazy { ChatDatabase.getInstance(context) }
+ private val messageDao by lazy { database.messageDao() }
+
+ // Пагинированная загрузка
+ suspend fun getMessagesForChatPaginated(chatId: String, page: Int, pageSize: Int = 30): List {
+ val offset = page * pageSize
+ return messageDao.getMessagesForChatPaginated(chatId, pageSize, offset)
+ }
+
+ suspend fun getLastMessages(chatId: String, limit: Int = 30): List {
+ return messageDao.getLastMessages(chatId, limit)
+ }
+
+ suspend fun getMessagesBeforeTimestamp(chatId: String, beforeTimestamp: Long, limit: Int = 30): List {
+ return messageDao.getMessagesBeforeTimestamp(chatId, beforeTimestamp, limit)
+ }
+
+ suspend fun getMessagesAfterTimestamp(chatId: String, afterTimestamp: Long): List {
+ return messageDao.getMessagesAfterTimestamp(chatId, afterTimestamp)
+ }
+
+ suspend fun saveMessage(message: MessageEntity) {
+ messageDao.insertMessage(message)
+ }
+
+ suspend fun saveMessages(messages: List) {
+ messageDao.insertMessages(messages)
+ }
+
+ suspend fun updateMessageStatus(messageId: Long, status: MessageStatus) {
+ messageDao.updateMessageStatus(messageId, status.name)
+ }
+
+ suspend fun markAsRead(messageId: Long) {
+ messageDao.markAsRead(messageId, System.currentTimeMillis())
+ }
+
+ suspend fun markAsDelivered(messageId: Long) {
+ messageDao.markAsDelivered(messageId, System.currentTimeMillis())
+ }
+
+ suspend fun updateMessageContent(messageId: Long, newContent: String, oldContent: String) {
+ messageDao.updateMessageContent(messageId, newContent, oldContent)
+ }
+
+ suspend fun markAsDeleted(messageId: Long) {
+ messageDao.markAsDeleted(messageId)
+ }
+
+ suspend fun getUnreadCount(userId: Long): Int {
+ return messageDao.getUnreadCount(userId)
+ }
+
+ fun observeUnreadCount(userId: Long): Flow {
+ return messageDao.observeUnreadCount(userId)
+ }
+
+ suspend fun clearOldMessages(daysToKeep: Int = 30) {
+ val cutoffTimestamp = System.currentTimeMillis() - (daysToKeep * 24L * 60 * 60 * 1000)
+ messageDao.deleteOldMessages(cutoffTimestamp)
+ }
+
+ suspend fun getMessageCount(chatId: String): Int {
+ return messageDao.getMessageCount(chatId)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/CryptoUtils.kt b/app/src/main/java/com/example/myserver/CryptoUtils.kt
new file mode 100644
index 0000000..7855e8c
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/CryptoUtils.kt
@@ -0,0 +1,112 @@
+package com.example.myserver.crypto
+
+import android.util.Base64
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import java.security.*
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
+import javax.crypto.Cipher
+import javax.crypto.KeyAgreement
+import javax.crypto.SecretKey
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+object CryptoUtils {
+
+ init {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(BouncyCastleProvider())
+ }
+ }
+
+ fun generateDHKeyPair(): KeyPair {
+ val keyPairGenerator = KeyPairGenerator.getInstance("DH")
+ keyPairGenerator.initialize(2048)
+ return keyPairGenerator.generateKeyPair()
+ }
+
+ fun generateSharedSecret(privateKey: PrivateKey, peerPublicKey: PublicKey): ByteArray {
+ val keyAgreement = KeyAgreement.getInstance("DH")
+ keyAgreement.init(privateKey)
+ keyAgreement.doPhase(peerPublicKey, true)
+ return keyAgreement.generateSecret()
+ }
+
+ fun publicKeyToBase64(publicKey: PublicKey): String {
+ return Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP)
+ }
+
+ fun base64ToPublicKey(base64Key: String): PublicKey {
+ val keyBytes = Base64.decode(base64Key, Base64.NO_WRAP)
+ val keySpec = X509EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("DH")
+ return keyFactory.generatePublic(keySpec)
+ }
+
+ fun privateKeyToBase64(privateKey: PrivateKey): String {
+ return Base64.encodeToString(privateKey.encoded, Base64.NO_WRAP)
+ }
+
+ fun base64ToPrivateKey(base64Key: String): PrivateKey {
+ val keyBytes = Base64.decode(base64Key, Base64.NO_WRAP)
+ val keySpec = PKCS8EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("DH")
+ return keyFactory.generatePrivate(keySpec)
+ }
+
+ fun deriveAESKey(sharedSecret: ByteArray): SecretKey {
+ val digest = MessageDigest.getInstance("SHA-256")
+ val keyBytes = digest.digest(sharedSecret)
+ return SecretKeySpec(keyBytes, "AES")
+ }
+
+ fun encryptMessage(plainText: String, secretKey: SecretKey): EncryptedData {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ val iv = ByteArray(12)
+ SecureRandom().nextBytes(iv)
+
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
+ val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
+
+ return EncryptedData(
+ cipherText = Base64.encodeToString(cipherText, Base64.NO_WRAP),
+ iv = Base64.encodeToString(iv, Base64.NO_WRAP),
+ tag = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)
+ )
+ }
+
+ fun decryptMessage(encryptedData: EncryptedData, secretKey: SecretKey): String {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ val iv = Base64.decode(encryptedData.iv, Base64.NO_WRAP)
+ val cipherText = Base64.decode(encryptedData.cipherText, Base64.NO_WRAP)
+
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
+ val plainText = cipher.doFinal(cipherText)
+
+ return String(plainText, Charsets.UTF_8)
+ }
+
+ fun generateRSAKeyPair(): KeyPair {
+ val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
+ keyPairGenerator.initialize(2048)
+ return keyPairGenerator.generateKeyPair()
+ }
+
+ fun encryptWithRSA(data: ByteArray, publicKey: PublicKey): ByteArray {
+ val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
+ cipher.init(Cipher.ENCRYPT_MODE, publicKey)
+ return cipher.doFinal(data)
+ }
+
+ fun decryptWithRSA(encryptedData: ByteArray, privateKey: PrivateKey): ByteArray {
+ val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
+ cipher.init(Cipher.DECRYPT_MODE, privateKey)
+ return cipher.doFinal(encryptedData)
+ }
+}
+
+data class EncryptedData(
+ val cipherText: String,
+ val iv: String,
+ val tag: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/EditableChatBubble.kt b/app/src/main/java/com/example/myserver/EditableChatBubble.kt
new file mode 100644
index 0000000..e517621
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/EditableChatBubble.kt
@@ -0,0 +1,233 @@
+package com.example.myserver
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.text.input.KeyboardType
+
+@Composable
+fun EditableChatBubble(
+ message: ChatMessage,
+ isMine: Boolean,
+ onEdit: (Long, String) -> Unit,
+ onDelete: (Long) -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ var showEditDialog by remember { mutableStateOf(false) }
+ var editText by remember { mutableStateOf(message.content) }
+
+ // Диалог редактирования
+ if (showEditDialog && isMine && !message.isDeleted) {
+ Dialog(
+ onDismissRequest = { showEditDialog = false },
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color(0xFF2A3A40)
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Редактировать сообщение",
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = editText,
+ onValueChange = { editText = it },
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White,
+ unfocusedBorderColor = Color.Gray,
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White
+ ),
+ singleLine = false,
+ maxLines = 5
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = { showEditDialog = false }) {
+ Text("Отмена", color = Color.Gray)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = {
+ if (editText.isNotBlank() && editText != message.content) {
+ onEdit(message.id, editText)
+ }
+ showEditDialog = false
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF2196F3)
+ )
+ ) {
+ Text("Сохранить")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Контекстное меню при долгом нажатии
+ Box(
+ modifier = Modifier.pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ if (isMine && !message.isDeleted) {
+ showMenu = true
+ }
+ }
+ )
+ }
+ ) {
+ // Dropdown меню
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.background(Color(0xFF2A3A40))
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Edit, contentDescription = null, tint = Color.White)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Редактировать", color = Color.White)
+ }
+ },
+ onClick = {
+ showMenu = false
+ showEditDialog = true
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Delete, contentDescription = null, tint = Color.Red)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Удалить", color = Color.Red)
+ }
+ },
+ onClick = {
+ showMenu = false
+ onDelete(message.id)
+ }
+ )
+ }
+
+ // Сам пузырь сообщения
+ ChatBubbleContent(
+ message = message,
+ isMine = isMine
+ )
+ }
+}
+
+@Composable
+fun ChatBubbleContent(
+ message: ChatMessage,
+ isMine: Boolean
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isMine) Arrangement.End else Arrangement.Start
+ ) {
+ Card(
+ shape = RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomStart = if (isMine) 16.dp else 4.dp,
+ bottomEnd = if (isMine) 4.dp else 16.dp
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isMine) Color(0xFF2196F3) else Color(0xFF424242)
+ ),
+ modifier = Modifier
+ .widthIn(max = 250.dp)
+ .padding(4.dp)
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ if (!isMine) {
+ Text(
+ text = message.senderName,
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 12.sp
+ )
+ }
+
+ // Содержимое сообщения
+ Text(
+ text = message.getDisplayContent(),
+ color = if (message.isDeleted) Color.White.copy(alpha = 0.5f) else Color.White,
+ fontSize = 14.sp,
+ fontStyle = if (message.isDeleted) androidx.compose.ui.text.font.FontStyle.Italic else androidx.compose.ui.text.font.FontStyle.Normal
+ )
+
+ // Индикатор редактирования
+ if (message.isEdited && !message.isDeleted) {
+ Text(
+ text = "(ред.)",
+ color = Color.White.copy(alpha = 0.4f),
+ fontSize = 10.sp
+ )
+ }
+
+ // Нижняя строка: время + статус
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ horizontalArrangement = if (isMine) Arrangement.End else Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = message.getFormattedTime(),
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 10.sp
+ )
+
+ if (isMine) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = message.getStatusIcon(),
+ fontSize = 11.sp,
+ color = Color(message.getStatusIconColor())
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/EncryptedPreferences.kt b/app/src/main/java/com/example/myserver/EncryptedPreferences.kt
new file mode 100644
index 0000000..be0495c
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/EncryptedPreferences.kt
@@ -0,0 +1,39 @@
+package com.example.myserver
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKeys
+
+class EncryptedPreferences(context: Context) {
+
+ private val prefs: SharedPreferences
+
+ init {
+ val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
+
+ prefs = EncryptedSharedPreferences.create(
+ "secure_prefs",
+ masterKeyAlias,
+ context,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun saveString(key: String, value: String) {
+ prefs.edit().putString(key, value).apply()
+ }
+
+ fun getString(key: String, defaultValue: String? = null): String? {
+ return prefs.getString(key, defaultValue)
+ }
+
+ fun clear() {
+ prefs.edit().clear().apply()
+ }
+
+ fun contains(key: String): Boolean {
+ return prefs.contains(key)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/FcmTokenRequest.kt b/app/src/main/java/com/example/myserver/FcmTokenRequest.kt
new file mode 100644
index 0000000..48bfb8a
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/FcmTokenRequest.kt
@@ -0,0 +1,12 @@
+// FcmTokenRequest.kt
+package com.example.myserver
+
+data class FcmTokenRequest(
+ val token: String,
+ val deviceId: String // Уникальный ID устройства
+)
+
+data class FcmTokenResponse(
+ val success: Boolean,
+ val message: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/LogActivity.kt b/app/src/main/java/com/example/myserver/LogActivity.kt
index 50b471a..7adf640 100644
--- a/app/src/main/java/com/example/myserver/LogActivity.kt
+++ b/app/src/main/java/com/example/myserver/LogActivity.kt
@@ -1,8 +1,10 @@
package com.example.myserver
-import android.content.Context
import android.content.Intent
+import android.os.Build
import android.os.Bundle
+import android.provider.Settings
+import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -30,80 +32,186 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
+import com.example.myserver.services.WebSocketService
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.launch
+import javax.inject.Inject
@AndroidEntryPoint
class LogActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ lateinit var apiService: MyApiService
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE)
- if (prefs.getBoolean("isLoggedIn", false)) {
- startActivity(Intent(this, MainActivity::class.java))
- finish()
- return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 100)
}
setContent {
MaterialTheme {
- LoginScreen()
+ LoginScreen(
+ sessionManager = sessionManager,
+ apiService = apiService,
+ onAutoLoginSuccess = {
+ startMainActivity()
+ },
+ onFinishActivity = {
+ finish()
+ },
+ onSendFcmToken = { token ->
+ sendFcmTokenToServer(token)
+ },
+ onStartWebSocket = {
+ startWebSocketAndRegisterFcm()
+ }
+ )
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (sessionManager.isSessionValid()) {
+ startMainActivity()
+ }
+ }
+
+ private fun startMainActivity() {
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ }
+
+ private fun sendFcmTokenToServer(token: String) {
+ lifecycleScope.launch {
+ try {
+ val sessionKey = sessionManager.getSessionKey()
+ if (sessionKey != null) {
+ val request = FcmTokenRequest(
+ token = token,
+ deviceId = Settings.Secure.getString(
+ contentResolver,
+ Settings.Secure.ANDROID_ID
+ )
+ )
+ apiService.registerFcmToken(sessionKey, request)
+ Log.d("FCM", "Token sent to server")
+ }
+ } catch (e: Exception) {
+ Log.e("FCM", "Failed to send token", e)
+ }
+ }
+ }
+
+ fun startWebSocketAndRegisterFcm() {
+ val intent = Intent(this, WebSocketService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ val token = task.result
+ if (token != null) {
+ sendFcmTokenToServer(token)
+ }
}
}
}
}
@Composable
-fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
+fun LoginScreen(
+ sessionManager: SessionManager,
+ apiService: MyApiService,
+ onAutoLoginSuccess: () -> Unit,
+ onFinishActivity: () -> Unit,
+ onSendFcmToken: (String) -> Unit,
+ onStartWebSocket: () -> Unit,
+ viewModel: AuthViewModel = hiltViewModel()
+) {
val context = LocalContext.current
val authState by viewModel.authState.collectAsState()
var name by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var warningText by remember { mutableStateOf("") }
+ var isCheckingAutoLogin by remember { mutableStateOf(true) }
- // Обработка состояния авторизации
+ // Проверка автоматического входа
+ LaunchedEffect(Unit) {
+ viewModel.checkAutoLogin(
+ onSuccess = {
+ Log.d("LOGIN", "Авто-вход успешен")
+ isCheckingAutoLogin = false
+
+ onStartWebSocket()
+ onAutoLoginSuccess()
+ onFinishActivity()
+ },
+ onError = {
+ Log.d("LOGIN", "Авто-вход не удался")
+ isCheckingAutoLogin = false
+ }
+ )
+ }
+
+ // Обработка ручного входа
LaunchedEffect(authState) {
when (authState) {
is AuthState.Success -> {
- context.getSharedPreferences("AppPrefs", Context.MODE_PRIVATE).edit {
- putBoolean("isLoggedIn", true)
- putString("realname", name)
- }
- context.startActivity(Intent(context, MainActivity::class.java))
- (context as LogActivity).finish()
+ onStartWebSocket()
+ onAutoLoginSuccess()
+ onFinishActivity()
}
is AuthState.Error -> {
warningText = (authState as AuthState.Error).message
}
- else -> { /* Loading or Idle */ }
+ else -> {}
}
}
- LoginScreenContent(
- name = name,
- onNameChange = {
- name = it
- warningText = "" // Сбрасываем ошибку при вводе
- },
- password = password,
- onPasswordChange = {
- password = it
- warningText = "" // Сбрасываем ошибку при вводе
- },
- warningText = warningText,
- isLoading = authState is AuthState.Loading,
- onLoginClick = {
- if (validateFields(name, password, context)) {
- viewModel.authUser(UserRequest(name, password), isRegistration = false)
- }
- },
- onRegisterClick = {
- context.startActivity(Intent(context, RegActivity::class.java))
- (context as LogActivity).finish()
+ if (isCheckingAutoLogin) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(color = Color.White)
}
- )
+ } else {
+ LoginScreenContent(
+ name = name,
+ onNameChange = { name = it; warningText = "" },
+ password = password,
+ onPasswordChange = { password = it; warningText = "" },
+ warningText = warningText,
+ isLoading = authState is AuthState.Loading,
+ onLoginClick = {
+ if (validateFields(name, password, context)) {
+ val deviceInfo = sessionManager.getDeviceInfo()
+ viewModel.login(
+ UserRequest(name, password),
+ deviceInfo,
+ onSuccess = {},
+ onError = { warningText = it }
+ )
+ }
+ },
+ onRegisterClick = {
+ context.startActivity(Intent(context, RegActivity::class.java))
+ }
+ )
+ }
}
@Composable
@@ -117,21 +225,18 @@ fun LoginScreenContent(
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit
) {
- // ScrollView аналог
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF3E4B54))
.verticalScroll(rememberScrollState())
) {
- // LinearLayout аналог с padding="16dp"
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
- // TextView3 - Заголовок
Text(
text = "Авторизация",
color = Color.White,
@@ -143,7 +248,6 @@ fun LoginScreenContent(
textAlign = TextAlign.Center
)
- // ImageView - Логотип
Image(
painter = painterResource(id = R.drawable.img5),
contentDescription = "logo",
@@ -152,7 +256,6 @@ fun LoginScreenContent(
.size(132.dp, 124.dp)
)
- // TextView - Метка "Имя пользователя"
Text(
text = "Имя пользователя",
color = Color.White,
@@ -163,8 +266,6 @@ fun LoginScreenContent(
textAlign = TextAlign.Center
)
- // EditText - Поле ввода имени
- // Используем Card для имитации @drawable/player_card_bg
Card(
modifier = Modifier
.fillMaxWidth()
@@ -210,7 +311,6 @@ fun LoginScreenContent(
)
}
- // TextView2 - Метка "Пароль"
Text(
text = "Пароль",
color = Color.White,
@@ -221,7 +321,6 @@ fun LoginScreenContent(
textAlign = TextAlign.Center
)
- // EditText - Поле ввода пароля
Card(
modifier = Modifier
.fillMaxWidth()
@@ -268,7 +367,6 @@ fun LoginScreenContent(
)
}
- // txtWarn - Текст ошибки
if (warningText.isNotEmpty()) {
Text(
text = warningText,
@@ -281,14 +379,12 @@ fun LoginScreenContent(
)
}
- // Индикатор загрузки или кнопки
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(top = 20.dp),
color = Color.White
)
} else {
- // btnLogin - Кнопка входа
Button(
onClick = onLoginClick,
modifier = Modifier
@@ -308,7 +404,6 @@ fun LoginScreenContent(
)
}
- // textView10 - Текст-подсказка
Text(
text = "Нет аккаунта? Зарегистрируйтесь!",
color = Color.White,
@@ -319,7 +414,6 @@ fun LoginScreenContent(
textAlign = TextAlign.Center
)
- // btnRegister - Кнопка регистрации
Button(
onClick = onRegisterClick,
modifier = Modifier
@@ -341,8 +435,7 @@ fun LoginScreenContent(
}
}
-// Функция валидации (добавь, если её нет)
-fun validateFields(name: String, password: String, context: Context): Boolean {
+fun validateFields(name: String, password: String, context: android.content.Context): Boolean {
return when {
name.isBlank() -> {
Toast.makeText(context, "Введите имя пользователя", Toast.LENGTH_SHORT).show()
diff --git a/app/src/main/java/com/example/myserver/MainActivity.kt b/app/src/main/java/com/example/myserver/MainActivity.kt
index dcdc6e6..59272ea 100644
--- a/app/src/main/java/com/example/myserver/MainActivity.kt
+++ b/app/src/main/java/com/example/myserver/MainActivity.kt
@@ -2,12 +2,16 @@ package com.example.myserver
import android.content.Context
import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
import android.os.Bundle
-import android.widget.Toast
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.*
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -21,14 +25,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
+import androidx.lifecycle.lifecycleScope
+import com.example.myserver.crypto.SecretChatManager
import dagger.hilt.android.AndroidEntryPoint
+import com.example.myserver.services.OfflineMessageQueue
+import com.example.myserver.services.WebSocketManager
+import kotlinx.coroutines.launch
import javax.inject.Inject
-import kotlinx.coroutines.delay
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -36,16 +42,48 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var apiService: MyApiService
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ lateinit var webSocketManager: WebSocketManager
+
+ @Inject
+ lateinit var offlineMessageQueue: OfflineMessageQueue
+
+ @Inject
+ lateinit var secretChatManager: SecretChatManager
+
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ // Запускаем WebSocket сервис если есть сессия
+ if (sessionManager.isSessionValid()) {
+ webSocketManager.connect()
+
+ // Проверяем очередь офлайн-сообщений
+ if (offlineMessageQueue.hasMessages()) {
+ Log.d("MainActivity", "Found ${offlineMessageQueue.size()} queued messages")
+ webSocketManager.flushQueue()
+ }
+ }
+
+ // Регистрируем слушатель сети
+ registerNetworkCallback()
+
setContent {
MaterialTheme {
- // Простая структура без сложных вложений
MainScreenContent(
apiService = apiService,
+ sessionManager = sessionManager,
+ webSocketManager = webSocketManager,
+ offlineMessageQueue = offlineMessageQueue,
+ secretChatManager = secretChatManager, // 👈 ПЕРЕДАТЬ secretChatManager
onLogout = {
- val prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE)
- prefs.edit { clear() }
+ webSocketManager.disconnect()
+ sessionManager.clearSession()
startActivity(Intent(this@MainActivity, LogActivity::class.java))
finish()
}
@@ -53,54 +91,274 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ private fun registerNetworkCallback() {
+ val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ val networkRequest = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+ .build()
+
+ networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ super.onAvailable(network)
+ Log.d("Network", "✅ Network available")
+ // При восстановлении сети пробуем отправить очередь
+ lifecycleScope.launch {
+ if (webSocketManager.isConnected()) {
+ webSocketManager.flushQueue()
+ } else {
+ webSocketManager.connect()
+ }
+ }
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ Log.d("Network", "❌ Network lost")
+ }
+
+ override fun onCapabilitiesChanged(
+ network: Network,
+ networkCapabilities: NetworkCapabilities
+ ) {
+ super.onCapabilitiesChanged(network, networkCapabilities)
+ val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ Log.d("Network", "Internet capability: $hasInternet")
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ connectivityManager.registerDefaultNetworkCallback(networkCallback!!)
+ } else {
+ connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Проверяем соединение при возвращении в приложение
+ if (sessionManager.isSessionValid() && !webSocketManager.isConnected()) {
+ webSocketManager.connect()
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Отключаем слушатель сети
+ networkCallback?.let {
+ val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ connectivityManager.unregisterNetworkCallback(it)
+ }
+ }
+}
+
+@Composable
+fun MainScreenContent(
+ apiService: MyApiService,
+ sessionManager: SessionManager,
+ webSocketManager: WebSocketManager,
+ offlineMessageQueue: OfflineMessageQueue,
+ secretChatManager: SecretChatManager,
+ onLogout: () -> Unit
+) {
+ val context = LocalContext.current
+ var players by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+ var connectionStatus by remember { mutableStateOf(if (webSocketManager.isConnected()) "Online" else "Offline") }
+
+ val currentUserId = sessionManager.getUserId()
+ val currentUsername = sessionManager.getUsername() ?: ""
+
+ // Периодически проверяем статус соединения
+ LaunchedEffect(Unit) {
+ while (true) {
+ connectionStatus = if (webSocketManager.isConnected()) "Online" else "Offline"
+ kotlinx.coroutines.delay(1000)
+ }
+ }
+
+ // Загружаем список игроков
+ LaunchedEffect(Unit) {
+ isLoading = true
+ errorMessage = null
+ try {
+ val response = apiService.getPlayers()
+ if (response.isSuccessful) {
+ players = response.body() ?: emptyList()
+ Log.d("MAIN", "Загружено игроков: ${players.size}")
+ } else {
+ errorMessage = "Ошибка: ${response.code()}"
+ }
+ } catch (e: Exception) {
+ errorMessage = "Ошибка сети: ${e.message}"
+ } finally {
+ isLoading = false
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF3E4B54))
+ ) {
+ MainHeader(
+ onLogout = onLogout,
+ context = context,
+ connectionStatus = connectionStatus,
+ queuedMessagesCount = offlineMessageQueue.size()
+ )
+
+ when {
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = Color.White
+ )
+ }
+ errorMessage != null -> {
+ Text(
+ text = errorMessage!!,
+ color = Color.Red,
+ fontSize = 16.sp,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(16.dp)
+ )
+ }
+ players.isEmpty() -> {
+ Text(
+ text = "Список игроков пуст",
+ color = Color.Yellow,
+ fontSize = 18.sp,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(top = 210.dp)
+ )
+ }
+ else -> {
+ PlayersList(
+ players = players,
+ context = context,
+ currentUserId = currentUserId,
+ currentUsername = currentUsername,
+ onStartChat = { player, isSecret -> // 👈 ДОБАВИТЬ ОБРАБОТЧИК
+ if (isSecret) {
+ // Секретный чат
+ secretChatManager.initiateSecretChat(player.id, player.realname ?: player.username)
+
+ val intent = Intent(context, ChatActivity::class.java).apply {
+ putExtra("RECIPIENT_ID", player.id)
+ putExtra("RECIPIENT_NAME", player.realname ?: player.username)
+ putExtra("IS_SECRET_CHAT", true)
+ putExtra("SECRET_CHAT_ID", "secret_${sessionManager.getUserId()}_${player.id}")
+ }
+ context.startActivity(intent)
+ } else {
+ // Обычный чат
+ val intent = Intent(context, ChatActivity::class.java).apply {
+ putExtra("RECIPIENT_ID", player.id)
+ putExtra("RECIPIENT_NAME", player.realname ?: player.username)
+ putExtra("IS_SECRET_CHAT", false)
+ }
+ context.startActivity(intent)
+ }
+ }
+ )
+ }
+ }
+ }
}
@Composable
-fun MainScreenContent(
- apiService: MyApiService,
- onLogout: () -> Unit
+fun PlayersList(
+ players: List,
+ context: Context,
+ currentUserId: Long,
+ currentUsername: String,
+ onStartChat: (player: UserResponse, isSecret: Boolean) -> Unit
) {
- val context = LocalContext.current
- var players by remember { mutableStateOf>(emptyList()) }
- var isLoading by remember { mutableStateOf(true) }
-
- // Загружаем данные
- LaunchedEffect(Unit) {
- try {
- val response = apiService.getPlayers()
- if (response.isSuccessful) {
- players = response.body() ?: emptyList()
- }
- } catch (_: Exception) {
- Toast.makeText(context, "Ошибка сети", Toast.LENGTH_SHORT).show()
- } finally {
- isLoading = false
- }
- }
-
- // Простой Box с фоном
- Box(
+ LazyColumn(
modifier = Modifier
.fillMaxSize()
- .background(Color(0xFF3E4B54))
+ .padding(top = 210.dp)
+ .background(Color(0xFF3E4B54)),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- // Верхняя часть с кнопками
- MainHeader(
- onLogout = onLogout,
- context = context
- )
+ items(
+ items = players.filter { it.username != currentUsername },
+ key = { it.id }
+ ) { player ->
+ PlayerCard(
+ player = player,
+ onChatClick = { onStartChat(player, false) },
+ onSecretChatClick = { onStartChat(player, true) }
+ )
+ }
+ }
+}
- // Список игроков или индикатор загрузки
- if (isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.align(Alignment.Center),
- color = Color.White
- )
- } else {
- PlayersList(
- players = players,
- context = context
- )
+@Composable
+fun PlayerCard(
+ player: UserResponse,
+ onChatClick: () -> Unit,
+ onSecretChatClick: () -> Unit
+) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ shape = RoundedCornerShape(20.dp),
+ shadowElevation = 4.dp,
+ color = Color(0xFF3E4B54),
+ border = BorderStroke(2.dp, Color.White)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = player.realname ?: player.username,
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(
+ color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
+ shape = RoundedCornerShape(4.dp)
+ )
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = if (player.active) "В игре" else "Офлайн",
+ color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
+ fontSize = 12.sp
+ )
+ }
+ }
+
+ // Кнопка обычного чата
+ IconButton(onClick = onChatClick) {
+ Text("💬", color = Color(0xFF2196F3), fontSize = 24.sp)
+ }
+
+ // Кнопка секретного чата
+ IconButton(onClick = onSecretChatClick) {
+ Text("🔐", color = Color(0xFF4CAF50), fontSize = 24.sp)
+ }
}
}
}
@@ -108,39 +366,65 @@ fun MainScreenContent(
@Composable
fun MainHeader(
onLogout: () -> Unit,
- context: Context
+ context: Context,
+ connectionStatus: String,
+ queuedMessagesCount: Int
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF3E4B54))
- .padding(bottom = 16.dp)
) {
- // Верхний ряд с меню и заголовком
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
- // Кнопка меню
TopMenuButton(
modifier = Modifier.size(39.dp, 37.dp),
onLogout = onLogout
)
- // Текст MySERVER
- Text(
- text = "MySERVER",
- color = Color.White,
- fontSize = 16.sp,
- fontStyle = FontStyle.Italic,
- modifier = Modifier.padding(start = 8.dp)
- )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 8.dp)
+ ) {
+ Text(
+ text = "QuApp",
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(
+ color = if (connectionStatus == "Online") Color(0xFF4CAF50) else Color.Red,
+ shape = RoundedCornerShape(4.dp)
+ )
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = connectionStatus,
+ color = if (connectionStatus == "Online") Color(0xFF4CAF50) else Color.Red,
+ fontSize = 10.sp
+ )
- Spacer(modifier = Modifier.weight(1f))
+ if (queuedMessagesCount > 0) {
+ Text(
+ text = " • $queuedMessagesCount в очереди",
+ color = Color.Yellow,
+ fontSize = 10.sp
+ )
+ }
+ }
+ }
- // Изображение img21
Image(
painter = painterResource(id = R.drawable.img21),
contentDescription = null,
@@ -149,11 +433,10 @@ fun MainHeader(
)
}
- // Кнопки меню (Профиль, Карта мира, Информация)
MainMenuButtons(
modifier = Modifier
.fillMaxWidth()
- .padding(top = 16.dp),
+ .padding(top = 16.dp, bottom = 16.dp),
context = context
)
}
@@ -167,14 +450,17 @@ fun TopMenuButton(
var expanded by remember { mutableStateOf(false) }
Box {
- Image(
- painter = painterResource(id = R.drawable.img6),
- contentDescription = "Menu",
- modifier = modifier.clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) { expanded = true }
- )
+ // Убери clickable с Image, используй IconButton
+ IconButton(
+ onClick = { expanded = true },
+ modifier = modifier
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img6),
+ contentDescription = "Menu",
+ modifier = Modifier.fillMaxSize()
+ )
+ }
DropdownMenu(
expanded = expanded,
@@ -201,7 +487,6 @@ fun MainMenuButtons(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly
) {
- // Кнопка Профиль
MenuButton(
iconRes = R.drawable.img19,
backgroundRes = R.drawable.img16,
@@ -211,7 +496,6 @@ fun MainMenuButtons(
}
)
- // Кнопка Карта мира
MenuButton(
iconRes = R.drawable.img18,
backgroundRes = R.drawable.img16,
@@ -221,7 +505,6 @@ fun MainMenuButtons(
}
)
- // Кнопка Информация
MenuButton(
iconRes = R.drawable.img22,
backgroundRes = R.drawable.img16,
@@ -243,29 +526,26 @@ fun MenuButton(
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
- Box(
- contentAlignment = Alignment.Center,
+ IconButton(
+ onClick = onClick,
modifier = Modifier.size(90.dp)
) {
- // Фон
- Image(
- painter = painterResource(id = backgroundRes),
- contentDescription = null,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Fit
- )
-
- // Иконка
- Image(
- painter = painterResource(id = iconRes),
- contentDescription = label,
- modifier = Modifier
- .size(60.dp)
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) { onClick() }
- )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Image(
+ painter = painterResource(id = backgroundRes),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit
+ )
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = label,
+ modifier = Modifier.size(60.dp)
+ )
+ }
}
Text(
@@ -275,83 +555,4 @@ fun MenuButton(
fontWeight = FontWeight.Bold
)
}
-}
-
-@Composable
-fun PlayersList(
- players: List,
- context: Context
-) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(top = 210.dp) // Отступ для заголовка
- .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
- .background(Color(0xFF2F4F4F)),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- items(
- items = players // Добавляем ключ для стабильности
- ) { player ->
- PlayerCard(
- player = player,
- onClick = {
- val intent = Intent(context, ProfileActivity::class.java).apply {
- putExtra("PLAYER_NAME", player.realname ?: player.username)
- }
- context.startActivity(intent)
- }
- )
- }
- }
-}
-
-@Composable
-fun PlayerCard(
- player: UserResponse,
- onClick: () -> Unit
-) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) { onClick() },
- shape = RoundedCornerShape(20.dp),
- border = BorderStroke(2.dp, Color.LightGray),
- colors = CardDefaults.cardColors(
- containerColor = Color(0xFF3E4B54)
- )
- ) {
- Row(
- modifier = Modifier.padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column {
- player.realname?.let {
- Text(
- text = it,
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold
- )
- }
- Text(
- text = "Активен",
- color = Color(0xFF4CAF50),
- fontSize = 12.sp
- )
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- Text(
- text = "▶",
- color = Color.White,
- fontSize = 20.sp
- )
- }
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/MediaCaheManager.kt b/app/src/main/java/com/example/myserver/MediaCaheManager.kt
new file mode 100644
index 0000000..3a36f56
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/MediaCaheManager.kt
@@ -0,0 +1,108 @@
+package com.example.myserver.services
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.request.target.CustomTarget
+import com.bumptech.glide.request.transition.Transition
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+@Singleton
+class MediaCacheManager @Inject constructor(
+ private val context: Context
+) {
+ companion object {
+ private const val MAX_CACHE_SIZE_MB = 500L // Максимальный размер кэша 500MB
+ }
+
+ /**
+ * Загрузка изображения с кэшированием
+ */
+ suspend fun loadImageWithCache(url: String): Bitmap? = suspendCancellableCoroutine { continuation ->
+ Glide.with(context)
+ .asBitmap()
+ .load(url)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ .into(object : CustomTarget() {
+ override fun onResourceReady(resource: Bitmap, transition: Transition?) {
+ continuation.resume(resource)
+ }
+
+ override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
+ // Nothing to do
+ }
+
+ override fun onLoadFailed(errorDrawable: android.graphics.drawable.Drawable?) {
+ continuation.resumeWithException(Exception("Failed to load image"))
+ }
+ })
+ }
+
+ /**
+ * Очистка кэша Glide
+ */
+ fun clearGlideCache() {
+ Glide.get(context).clearDiskCache()
+ Glide.get(context).clearMemory()
+ }
+
+ /**
+ * Получение размера кэша Glide
+ */
+ fun getGlideCacheSize(): Long {
+ val glideCacheDir = File(context.cacheDir, "image_manager_disk_cache")
+ return if (glideCacheDir.exists()) {
+ glideCacheDir.walkTopDown().sumOf { it.length() }
+ } else {
+ 0
+ }
+ }
+
+ /**
+ * Общая очистка кэша
+ */
+ fun clearAllCache() {
+ clearGlideCache()
+ Glide.get(context).clearDiskCache()
+ val cacheDir = context.cacheDir
+ cacheDir.listFiles()?.forEach { file ->
+ if (file.isDirectory) {
+ file.deleteRecursively()
+ } else {
+ file.delete()
+ }
+ }
+ }
+
+ /**
+ * Получение общего размера кэша
+ */
+ fun getTotalCacheSize(): Long {
+ var size = 0L
+ val cacheDir = context.cacheDir
+ cacheDir.listFiles()?.forEach { file ->
+ if (file.isDirectory) {
+ size += file.walkTopDown().sumOf { it.length() }
+ } else {
+ size += file.length()
+ }
+ }
+ return size
+ }
+
+ /**
+ * Проверка и очистка при превышении лимита
+ */
+ fun checkAndCleanCache() {
+ if (getTotalCacheSize() > MAX_CACHE_SIZE_MB * 1024 * 1024) {
+ clearAllCache()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myserver/MediaCompressionService.kt b/app/src/main/java/com/example/myserver/MediaCompressionService.kt
new file mode 100644
index 0000000..3213322
--- /dev/null
+++ b/app/src/main/java/com/example/myserver/MediaCompressionService.kt
@@ -0,0 +1,143 @@
+package com.example.myserver.services
+
+import android.content.ContentResolver
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MediaCompressionService @Inject constructor(
+ private val context: Context
+) {
+ companion object {
+ private const val MAX_IMAGE_SIZE_KB = 500L
+ private const val MAX_IMAGE_WIDTH = 1280
+ private const val MAX_IMAGE_HEIGHT = 1280
+ }
+
+ private val _compressionProgress = MutableStateFlow