From fb07f28fd1041ac85418e138209afb51c12b9de1 Mon Sep 17 00:00:00 2001 From: MiDupl1711 Date: Sat, 25 Apr 2026 01:14:39 +0300 Subject: [PATCH] Change 25 04 2026 add chats --- .idea/appInsightsSettings.xml | 26 + .idea/studiobot.xml | 6 + app/build.gradle.kts | 146 +- app/google-services.json | 29 + app/proguard-rules.pro | 163 +- app/src/main/AndroidManifest.xml | 42 +- .../com/example/myserver/AudioRecorder.kt | 83 + .../com/example/myserver/AuthViewModel.kt | 117 +- .../java/com/example/myserver/ChatActivity.kt | 1403 +++++++++++++++++ .../example/myserver/ChatHistoryResponse.kt | 10 + .../java/com/example/myserver/ChatMessage.kt | 100 ++ .../com/example/myserver/ChatRepository.kt | 107 ++ .../java/com/example/myserver/CryptoUtils.kt | 112 ++ .../example/myserver/EditableChatBubble.kt | 233 +++ .../example/myserver/EncryptedPreferences.kt | 39 + .../com/example/myserver/FcmTokenRequest.kt | 12 + .../java/com/example/myserver/LogActivity.kt | 203 ++- .../java/com/example/myserver/MainActivity.kt | 551 +++++-- .../com/example/myserver/MediaCaheManager.kt | 108 ++ .../myserver/MediaCompressionService.kt | 143 ++ .../java/com/example/myserver/MediaEntity.kt | 63 + .../java/com/example/myserver/MediaMessage.kt | 23 + .../java/com/example/myserver/MediaType.kt | 8 + .../java/com/example/myserver/MessageDao.kt | 74 + .../com/example/myserver/MessageEntity.kt | 100 ++ .../java/com/example/myserver/MyApiService.kt | 75 +- .../com/example/myserver/MyApplication.kt | 41 +- .../myserver/MyFirebaseMessagingService.kt | 157 ++ .../com/example/myserver/NetworkModule.kt | 2 +- .../example/myserver/OfflineMessageQueue.kt | 136 ++ .../java/com/example/myserver/RegActivity.kt | 83 +- .../example/myserver/SecretChatIndicator.kt | 79 + .../com/example/myserver/SecretChatManager.kt | 374 +++++ .../com/example/myserver/SecretChatSession.kt | 50 + .../com/example/myserver/SessionManager.kt | 233 +++ .../com/example/myserver/StatusRequest.kt | 5 + .../java/com/example/myserver/UserModel.kt | 13 +- .../com/example/myserver/WebSocketManager.kt | 241 +++ .../com/example/myserver/WebSocketService.kt | 140 ++ .../java/com/example/myserver/di/AppModule.kt | 28 + app/src/main/res/drawable/ic_lock.xml | 10 + app/src/main/res/drawable/ic_lock_open.xml | 13 + app/src/main/res/drawable/ic_lock_outline.xml | 10 + app/src/main/res/xml/file_paths.xml | 5 + build.gradle.kts | 10 +- gradle/libs.versions.toml | 11 +- settings.gradle.kts | 11 +- 47 files changed, 5272 insertions(+), 356 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/studiobot.xml create mode 100644 app/google-services.json create mode 100644 app/src/main/java/com/example/myserver/AudioRecorder.kt create mode 100644 app/src/main/java/com/example/myserver/ChatActivity.kt create mode 100644 app/src/main/java/com/example/myserver/ChatHistoryResponse.kt create mode 100644 app/src/main/java/com/example/myserver/ChatMessage.kt create mode 100644 app/src/main/java/com/example/myserver/ChatRepository.kt create mode 100644 app/src/main/java/com/example/myserver/CryptoUtils.kt create mode 100644 app/src/main/java/com/example/myserver/EditableChatBubble.kt create mode 100644 app/src/main/java/com/example/myserver/EncryptedPreferences.kt create mode 100644 app/src/main/java/com/example/myserver/FcmTokenRequest.kt create mode 100644 app/src/main/java/com/example/myserver/MediaCaheManager.kt create mode 100644 app/src/main/java/com/example/myserver/MediaCompressionService.kt create mode 100644 app/src/main/java/com/example/myserver/MediaEntity.kt create mode 100644 app/src/main/java/com/example/myserver/MediaMessage.kt create mode 100644 app/src/main/java/com/example/myserver/MediaType.kt create mode 100644 app/src/main/java/com/example/myserver/MessageDao.kt create mode 100644 app/src/main/java/com/example/myserver/MessageEntity.kt create mode 100644 app/src/main/java/com/example/myserver/MyFirebaseMessagingService.kt create mode 100644 app/src/main/java/com/example/myserver/OfflineMessageQueue.kt create mode 100644 app/src/main/java/com/example/myserver/SecretChatIndicator.kt create mode 100644 app/src/main/java/com/example/myserver/SecretChatManager.kt create mode 100644 app/src/main/java/com/example/myserver/SecretChatSession.kt create mode 100644 app/src/main/java/com/example/myserver/SessionManager.kt create mode 100644 app/src/main/java/com/example/myserver/StatusRequest.kt create mode 100644 app/src/main/java/com/example/myserver/WebSocketManager.kt create mode 100644 app/src/main/java/com/example/myserver/WebSocketService.kt create mode 100644 app/src/main/java/com/example/myserver/di/AppModule.kt create mode 100644 app/src/main/res/drawable/ic_lock.xml create mode 100644 app/src/main/res/drawable/ic_lock_open.xml create mode 100644 app/src/main/res/drawable/ic_lock_outline.xml create mode 100644 app/src/main/res/xml/file_paths.xml 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>(emptyMap()) + val compressionProgress: StateFlow> = _compressionProgress + + suspend fun compressImage(sourceUri: Uri, messageId: Long): File? = withContext(Dispatchers.IO) { + try { + updateProgress(messageId, 0) + + val contentResolver: ContentResolver = context.contentResolver + val inputStream = contentResolver.openInputStream(sourceUri) + val originalBitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (originalBitmap == null) { + return@withContext null + } + + updateProgress(messageId, 30) + + val scaledBitmap = scaleBitmap(originalBitmap, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT) + originalBitmap.recycle() + + updateProgress(messageId, 60) + + val compressedFile = File(context.cacheDir, "compressed_img_${messageId}.jpg") + FileOutputStream(compressedFile).use { outputStream -> + scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, outputStream) + } + scaledBitmap.recycle() + + var finalFile = compressedFile + if (finalFile.length() > MAX_IMAGE_SIZE_KB * 1024) { + val quality = ((MAX_IMAGE_SIZE_KB * 1024 * 100) / finalFile.length()).coerceIn(30, 90).toInt() + finalFile = compressFileFurther(finalFile, quality) + compressedFile.delete() + } + + updateProgress(messageId, 100) + return@withContext finalFile + + } catch (e: Exception) { + e.printStackTrace() + return@withContext null + } + } + + suspend fun generateVideoThumbnail(videoUri: Uri, messageId: Long): File? = withContext(Dispatchers.IO) { + try { + val contentResolver = context.contentResolver + val inputStream = contentResolver.openInputStream(videoUri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (bitmap == null) return@withContext null + + val thumbnailFile = File(context.cacheDir, "thumbnail_${messageId}.jpg") + FileOutputStream(thumbnailFile).use { outputStream -> + val scaledBitmap = scaleBitmap(bitmap, 320, 320) + scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + scaledBitmap.recycle() + } + bitmap.recycle() + + return@withContext thumbnailFile + + } catch (e: Exception) { + e.printStackTrace() + return@withContext null + } + } + + private fun scaleBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap { + val width = bitmap.width + val height = bitmap.height + + if (width <= maxWidth && height <= maxHeight) { + return bitmap + } + + val ratio = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height) + val newWidth = (width * ratio).toInt() + val newHeight = (height * ratio).toInt() + + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + } + + private fun compressFileFurther(file: File, quality: Int): File { + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + val compressedFile = File(context.cacheDir, "compressed_img_final_${System.currentTimeMillis()}.jpg") + FileOutputStream(compressedFile).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + } + bitmap.recycle() + return compressedFile + } + + private fun updateProgress(messageId: Long, progress: Int) { + _compressionProgress.value = _compressionProgress.value.toMutableMap().apply { + this[messageId] = progress + } + } + + fun clearMediaCache() { + val cacheDir = context.cacheDir + cacheDir.listFiles()?.forEach { file -> + if (file.name.startsWith("compressed_") || file.name.startsWith("thumbnail_")) { + file.delete() + } + } + } + + fun getMediaCacheSize(): Long { + val cacheDir = context.cacheDir + return cacheDir.listFiles()?.filter { file -> + file.name.startsWith("compressed_") || file.name.startsWith("thumbnail_") + }?.sumOf { it.length() } ?: 0L + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MediaEntity.kt b/app/src/main/java/com/example/myserver/MediaEntity.kt new file mode 100644 index 0000000..44b1bd0 --- /dev/null +++ b/app/src/main/java/com/example/myserver/MediaEntity.kt @@ -0,0 +1,63 @@ +package com.example.myserver + +import androidx.room.* + +@Entity( + tableName = "media", + indices = [ + Index(value = ["message_id"], name = "idx_media_message"), + Index(value = ["local_path"], name = "idx_media_local") + ] +) +data class MediaEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + @ColumnInfo(name = "message_id") + val messageId: Long, + + @ColumnInfo(name = "media_type") + val mediaType: String, + + @ColumnInfo(name = "local_path") + val localPath: String, + + @ColumnInfo(name = "remote_url") + val remoteUrl: String? = null, + + @ColumnInfo(name = "thumbnail_path") + val thumbnailPath: String? = null, + + @ColumnInfo(name = "file_size") + val fileSize: Long, + + @ColumnInfo(name = "width") + val width: Int = 0, + + @ColumnInfo(name = "height") + val height: Int = 0, + + @ColumnInfo(name = "duration") + val duration: Long = 0, + + @ColumnInfo(name = "mime_type") + val mimeType: String, + + @ColumnInfo(name = "is_uploaded") + val isUploaded: Boolean = false +) + +@Dao +interface MediaDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMedia(media: MediaEntity) + + @Query("SELECT * FROM media WHERE message_id = :messageId") + suspend fun getMediaForMessage(messageId: Long): MediaEntity? + + @Query("DELETE FROM media WHERE message_id = :messageId") + suspend fun deleteMediaForMessage(messageId: Long) + + @Query("DELETE FROM media WHERE is_uploaded = 1 AND file_size > 0") + suspend fun deleteUploadedMedia() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MediaMessage.kt b/app/src/main/java/com/example/myserver/MediaMessage.kt new file mode 100644 index 0000000..3a0731f --- /dev/null +++ b/app/src/main/java/com/example/myserver/MediaMessage.kt @@ -0,0 +1,23 @@ +package com.example.myserver + + +data class MediaMessage( + val id: Long, + val messageId: Long, + val mediaType: MediaType, + val localPath: String, // Локальный путь к файлу + val remoteUrl: String?, // URL на сервере + val thumbnailPath: String?, // Путь к миниатюре + val fileSize: Long, // Размер в байтах + val width: Int = 0, + val height: Int = 0, + val duration: Long = 0, // Для видео/аудио (в секундах) + val mimeType: String, + val isUploaded: Boolean = false +) + +data class MediaUploadProgress( + val messageId: Long, + val progress: Int, // 0-100 + val isComplete: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MediaType.kt b/app/src/main/java/com/example/myserver/MediaType.kt new file mode 100644 index 0000000..00f7b98 --- /dev/null +++ b/app/src/main/java/com/example/myserver/MediaType.kt @@ -0,0 +1,8 @@ +package com.example.myserver + +enum class MediaType { + IMAGE, + VIDEO, + AUDIO, + DOCUMENT +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MessageDao.kt b/app/src/main/java/com/example/myserver/MessageDao.kt new file mode 100644 index 0000000..c49b087 --- /dev/null +++ b/app/src/main/java/com/example/myserver/MessageDao.kt @@ -0,0 +1,74 @@ +package com.example.myserver + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + + @Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp DESC LIMIT :limit OFFSET :offset") + suspend fun getMessagesForChatPaginated(chatId: String, limit: Int, offset: Int): List + + @Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLastMessages(chatId: String, limit: Int): List + + @Query("SELECT * FROM messages WHERE chat_id = :chatId AND timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit") + suspend fun getMessagesBeforeTimestamp(chatId: String, beforeTimestamp: Long, limit: Int): List + + @Query("SELECT * FROM messages WHERE chat_id = :chatId AND timestamp > :afterTimestamp ORDER BY timestamp ASC") + suspend fun getMessagesAfterTimestamp(chatId: String, afterTimestamp: Long): List + + @Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC") + suspend fun getAllMessagesForChat(chatId: String): List + + @Query("SELECT * FROM messages WHERE id = :messageId") + suspend fun getMessageById(messageId: Long): MessageEntity? + + @Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId") + suspend fun getMessageCount(chatId: String): Int + + @Query("SELECT * FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0 ORDER BY timestamp ASC") + suspend fun getUnreadMessages(userId: Long): List + + @Query("SELECT COUNT(*) FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0") + suspend fun getUnreadCount(userId: Long): Int + + @Query("SELECT * FROM messages WHERE recipient_id = :userId AND status = 'SENT' AND is_deleted = 0") + suspend fun getUndeliveredMessages(userId: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessage(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(messages: List) + + @Query("UPDATE messages SET status = :status WHERE id = :messageId") + suspend fun updateMessageStatus(messageId: Long, status: String) + + @Query("UPDATE messages SET is_read = 1, read_at = :readAt WHERE id = :messageId") + suspend fun markAsRead(messageId: Long, readAt: Long) + + @Query("UPDATE messages SET status = 'DELIVERED', delivered_at = :deliveredAt WHERE id = :messageId") + suspend fun markAsDelivered(messageId: Long, deliveredAt: Long) + + @Query("UPDATE messages SET content = :newContent, is_edited = 1, edited_content = :oldContent WHERE id = :messageId") + suspend fun updateMessageContent(messageId: Long, newContent: String, oldContent: String) + + @Query("UPDATE messages SET is_deleted = 1, content = 'Сообщение удалено' WHERE id = :messageId") + suspend fun markAsDeleted(messageId: Long) + + @Query("DELETE FROM messages WHERE timestamp < :beforeTimestamp") + suspend fun deleteOldMessages(beforeTimestamp: Long) + + @Query("DELETE FROM messages WHERE chat_id = :chatId") + suspend fun deleteChatMessages(chatId: String) + + @Query("SELECT * FROM messages WHERE status = :status AND is_deleted = 0 ORDER BY timestamp ASC") + suspend fun getMessagesByStatus(status: String): List + + @Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC") + fun observeMessages(chatId: String): Flow> + + @Query("SELECT COUNT(*) FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0") + fun observeUnreadCount(userId: Long): Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MessageEntity.kt b/app/src/main/java/com/example/myserver/MessageEntity.kt new file mode 100644 index 0000000..8b3cbcd --- /dev/null +++ b/app/src/main/java/com/example/myserver/MessageEntity.kt @@ -0,0 +1,100 @@ +package com.example.myserver + +import androidx.room.* + +@Entity( + tableName = "messages", + indices = [ + Index(value = ["chat_id", "timestamp"], name = "idx_chat_timestamp"), + Index(value = ["sender_id"], name = "idx_sender"), + Index(value = ["recipient_id"], name = "idx_recipient"), + Index(value = ["timestamp"], name = "idx_timestamp"), + Index(value = ["status"], name = "idx_status") + ] +) +data class MessageEntity( + @PrimaryKey + val id: Long, + + @ColumnInfo(name = "sender_id") + val senderId: Long, + + @ColumnInfo(name = "sender_name") + val senderName: String, + + @ColumnInfo(name = "recipient_id") + val recipientId: Long, + + @ColumnInfo(name = "content") + val content: String, + + @ColumnInfo(name = "timestamp") + val timestamp: Long, + + @ColumnInfo(name = "is_read") + val isRead: Boolean, + + @ColumnInfo(name = "chat_id") + val chatId: String, + + @ColumnInfo(name = "status") + val status: String = "PENDING", + + @ColumnInfo(name = "delivered_at") + val deliveredAt: Long? = null, + + @ColumnInfo(name = "read_at") + val readAt: Long? = null, + + @ColumnInfo(name = "is_edited") + val isEdited: Boolean = false, + + @ColumnInfo(name = "is_deleted") + val isDeleted: Boolean = false, + + @ColumnInfo(name = "edited_content") + val editedContent: String? = null +) { + fun toChatMessage(): ChatMessage { + return ChatMessage( + id = id, + senderId = senderId, + senderName = senderName, + recipientId = recipientId, + content = if (isDeleted) "Сообщение удалено" else content, + timestamp = emptyList(), + status = try { + MessageStatus.valueOf(status) + } catch (e: Exception) { + MessageStatus.SENT + }, + read = isRead, + deliveredAt = deliveredAt, + readAt = readAt, + isEdited = isEdited, + isDeleted = isDeleted, + originalContent = editedContent + ) + } + + companion object { + fun fromChatMessage(message: ChatMessage, chatId: String): MessageEntity { + return MessageEntity( + id = message.id, + senderId = message.senderId, + senderName = message.senderName, + recipientId = message.recipientId, + content = message.content, + timestamp = System.currentTimeMillis(), + isRead = message.read, + chatId = chatId, + status = message.status.name, + deliveredAt = message.deliveredAt, + readAt = message.readAt, + isEdited = message.isEdited, + isDeleted = message.isDeleted, + editedContent = message.originalContent + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MyApiService.kt b/app/src/main/java/com/example/myserver/MyApiService.kt index 4c2655a..57b69a4 100644 --- a/app/src/main/java/com/example/myserver/MyApiService.kt +++ b/app/src/main/java/com/example/myserver/MyApiService.kt @@ -1,22 +1,81 @@ package com.example.myserver +import okhttp3.MultipartBody import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST +import retrofit2.http.* interface MyApiService { + @POST("api/auth/register") + suspend fun register(@Body userRequest: UserRequest): Response @POST("api/auth/login") - suspend fun login(@Body request: UserRequest): Response + suspend fun login(@Body userRequest: UserRequest): Response - @POST("api/auth/register") - suspend fun register(@Body request: UserRequest): Response + @GET("api/auth/check-session") + suspend fun checkSession(@Header("X-Session-Key") sessionKey: String): Response + + @POST("api/auth/logout") + suspend fun logout(@Header("X-Session-Key") sessionKey: String): Response @GET("api/auth/players") suspend fun getPlayers(): Response> - @POST("api/auth/update") - suspend fun updateUser(@Body request: UserRequest): Response + @GET("api/auth/chat/history/{userId}") + suspend fun getChatHistory( + @Path("userId") userId: Long, + @Header("X-Session-Key") sessionKey: String + ): Response> + // Добавить в MyApiService.kt: + + @POST("api/auth/fcm/register") + suspend fun registerFcmToken( + @Header("X-Session-Key") sessionKey: String, + @Body request: FcmTokenRequest + ): Response + + @DELETE("api/auth/fcm/unregister") + suspend fun unregisterFcmToken( + @Header("X-Session-Key") sessionKey: String + ): Response + // Добавить в MyApiService.kt: + + @GET("api/auth/chat/history/paginated/{userId}") + suspend fun getChatHistoryPaginated( + @Path("userId") userId: Long, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("X-Session-Key") sessionKey: String + ): Response + @Multipart + @POST("api/media/upload") + suspend fun uploadMedia( + @Header("X-Session-Key") sessionKey: String, + @Part("recipientId") recipientId: Long, + @Part file: MultipartBody.Part + ): Response + + } + +data class LoginResponse( + val success: Boolean, + val message: String? = null, + val sessionKey: String? = null, + val userId: Long = 0, + val username: String? = null, + val realname: String? = null +) + +data class SessionCheckResponse( + val isActive: Boolean, + val userId: Long = 0, + val username: String? = null, + val realname: String? = null +) +data class UploadResponse( + val success: Boolean, + val url: String? = null, + val filename: String? = null, + val size: Long = 0 +) \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MyApplication.kt b/app/src/main/java/com/example/myserver/MyApplication.kt index 7bc6de6..d64041b 100644 --- a/app/src/main/java/com/example/myserver/MyApplication.kt +++ b/app/src/main/java/com/example/myserver/MyApplication.kt @@ -1,7 +1,34 @@ -package com.example.myserver - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class MyApplication : Application() +package com.example.myserver + +import android.app.Application +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MyApplication : Application() { + + companion object { + lateinit var instance: MyApplication + private set + } + + override fun onCreate() { + super.onCreate() + instance = this + + FirebaseApp.initializeApp(this) + Log.d("FCM", "Firebase инициализирован") + + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + val token = task.result + Log.d("FCM", "🔥 FCM Токен: $token") + // TODO: Отправить токен на сервер после логина + } else { + Log.e("FCM", "❌ Ошибка получения токена", task.exception) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/MyFirebaseMessagingService.kt b/app/src/main/java/com/example/myserver/MyFirebaseMessagingService.kt new file mode 100644 index 0000000..500dd69 --- /dev/null +++ b/app/src/main/java/com/example/myserver/MyFirebaseMessagingService.kt @@ -0,0 +1,157 @@ +package com.example.myserver + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.lifecycle.lifecycleScope +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class MyFirebaseMessagingService : FirebaseMessagingService() { + + companion object { + private const val CHANNEL_ID = "chat_channel" + private const val TAG = "FCM" + var tokenSentToServer = false + } + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var apiService: MyApiService + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "🔥 New FCM token: $token") + + // Отправляем токен на сервер + sendTokenToServer(token) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + Log.d(TAG, "📨 Received message: ${remoteMessage.data}") + + val data = remoteMessage.data + val type = data["type"] ?: "new_message" + + when (type) { + "new_message" -> handleNewMessage(data) + "message_edited" -> handleMessageEdited(data) + "message_deleted" -> handleMessageDeleted(data) + else -> handleNewMessage(data) + } + } + + private fun handleNewMessage(data: Map) { + val senderName = data["senderName"] ?: "Игрок" + val content = data["content"] ?: "Новое сообщение" + val senderId = data["senderId"]?.toLongOrNull() ?: 0 + val messageId = data["messageId"]?.toLongOrNull() ?: 0 + + // Показываем уведомление + showNotification(senderName, content, senderId, messageId) + } + + private fun handleMessageEdited(data: Map) { + val messageId = data["messageId"]?.toLongOrNull() ?: 0 + val newContent = data["content"] ?: "" + Log.d(TAG, "Message $messageId edited: $newContent") + + // Можно показать уведомление о редактировании + showNotification("Редактирование", "Сообщение изменено: $newContent", 0, messageId) + } + + private fun handleMessageDeleted(data: Map) { + val messageId = data["messageId"]?.toLongOrNull() ?: 0 + Log.d(TAG, "Message $messageId deleted") + } + + private fun showNotification(senderName: String, message: String, senderId: Long, messageId: Long) { + createNotificationChannel() + + val intent = Intent(this, ChatActivity::class.java).apply { + putExtra("RECIPIENT_ID", senderId) + putExtra("RECIPIENT_NAME", senderName) + putExtra("MESSAGE_ID", messageId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + this, senderId.toInt(), intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("✉️ $senderName") + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .build() + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(senderId.toInt(), notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Чат", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Новые сообщения в чате" + enableVibration(true) + vibrationPattern = longArrayOf(0, 500, 200, 500) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun sendTokenToServer(token: String) { + val sessionKey = sessionManager.getSessionKey() + if (sessionKey == null) { + Log.d(TAG, "No session key, will send token after login") + return + } + + // Отправляем токен на сервер + val request = FcmTokenRequest( + token = token, + deviceId = Settings.Secure.getString( + contentResolver, + Settings.Secure.ANDROID_ID + ) + ) + + // Используем coroutine для отправки + kotlinx.coroutines.GlobalScope.launch { + try { + val response = apiService.registerFcmToken(sessionKey, request) + if (response.isSuccessful) { + tokenSentToServer = true + Log.d(TAG, "✅ FCM token sent to server successfully") + } else { + Log.e(TAG, "❌ Failed to send token: ${response.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "❌ Error sending token: ${e.message}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/NetworkModule.kt b/app/src/main/java/com/example/myserver/NetworkModule.kt index a838c35..4a09d26 100644 --- a/app/src/main/java/com/example/myserver/NetworkModule.kt +++ b/app/src/main/java/com/example/myserver/NetworkModule.kt @@ -16,7 +16,7 @@ object NetworkModule { @Singleton fun provideApiService(): MyApiService { return Retrofit.Builder() - .baseUrl("http://quantumblocks.hopto.org:45678") // Твой URL со слэшем на конце + .baseUrl("http://quantumblocks.hopto.org:45678/") // Твой URL со слэшем на конце .addConverterFactory(GsonConverterFactory.create()) .build() .create(MyApiService::class.java) diff --git a/app/src/main/java/com/example/myserver/OfflineMessageQueue.kt b/app/src/main/java/com/example/myserver/OfflineMessageQueue.kt new file mode 100644 index 0000000..d57b4f1 --- /dev/null +++ b/app/src/main/java/com/example/myserver/OfflineMessageQueue.kt @@ -0,0 +1,136 @@ +package com.example.myserver.services + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OfflineMessageQueue @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val PREFS_NAME = "offline_messages" + private const val KEY_QUEUE = "message_queue" + private const val MAX_QUEUE_SIZE = 500 // Максимум 500 сообщений в очереди + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val gson = Gson() + + // Структура офлайн-сообщения + data class QueuedMessage( + val json: String, + val timestamp: Long, + val tempId: Long, + val retryCount: Int = 0 + ) + + /** + * Добавить сообщение в очередь + */ + fun enqueue(json: String, tempId: Long): Boolean { + val queue = getQueue().toMutableList() + + // Проверяем лимит + if (queue.size >= MAX_QUEUE_SIZE) { + // Удаляем самое старое сообщение + queue.removeAt(0) + } + + queue.add(QueuedMessage(json, System.currentTimeMillis(), tempId)) + saveQueue(queue) + return true + } + + /** + * Получить все сообщения из очереди + */ + fun dequeueAll(): List { + val queue = getQueue() + clearQueue() + return queue + } + + /** + * Получить первое сообщение из очереди (без удаления) + */ + fun peek(): QueuedMessage? { + return getQueue().firstOrNull() + } + + /** + * Удалить сообщение из очереди по tempId + */ + fun removeByTempId(tempId: Long): Boolean { + val queue = getQueue().toMutableList() + val removed = queue.removeAll { it.tempId == tempId } + if (removed) { + saveQueue(queue) + } + return removed + } + + /** + * Обновить сообщение в очереди (например, увеличить счетчик retry) + */ + fun updateRetryCount(tempId: Long): Boolean { + val queue = getQueue().toMutableList() + val index = queue.indexOfFirst { it.tempId == tempId } + if (index != -1) { + val old = queue[index] + queue[index] = old.copy(retryCount = old.retryCount + 1) + saveQueue(queue) + return true + } + return false + } + + /** + * Проверить, есть ли сообщения в очереди + */ + fun hasMessages(): Boolean { + return getQueue().isNotEmpty() + } + + /** + * Получить размер очереди + */ + fun size(): Int { + return getQueue().size + } + + /** + * Очистить всю очередь + */ + fun clearQueue() { + prefs.edit().remove(KEY_QUEUE).apply() + } + + /** + * Получить очередь из SharedPreferences + */ + private fun getQueue(): List { + val json = prefs.getString(KEY_QUEUE, null) + if (json == null) return emptyList() + + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + /** + * Сохранить очередь в SharedPreferences + */ + private fun saveQueue(queue: List) { + val json = gson.toJson(queue) + prefs.edit().putString(KEY_QUEUE, json).apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/RegActivity.kt b/app/src/main/java/com/example/myserver/RegActivity.kt index 15d68db..fff9d0c 100644 --- a/app/src/main/java/com/example/myserver/RegActivity.kt +++ b/app/src/main/java/com/example/myserver/RegActivity.kt @@ -1,6 +1,5 @@ package com.example.myserver -import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Patterns @@ -28,7 +27,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel @@ -42,7 +40,7 @@ class RegActivity : ComponentActivity() { MaterialTheme { RegistrationScreen( onNavigateToLogin = { - startActivity(Intent(this, MainActivity::class.java)) + startActivity(Intent(this, LogActivity::class.java)) finish() } ) @@ -64,7 +62,6 @@ fun RegistrationScreen( val authState by viewModel.authState.collectAsState() - // Слушатель состояния регистрации LaunchedEffect(authState) { when (authState) { is AuthState.Success -> { @@ -74,25 +71,22 @@ fun RegistrationScreen( is AuthState.Error -> { warningText = (authState as AuthState.Error).message } - else -> { /* Loading or Idle */ } + else -> {} } } - // ScrollView аналог с фоном Column( modifier = Modifier .fillMaxSize() - .background(Color(0xFF3E4B54)) // android:background="#3E4B54" + .background(Color(0xFF3E4B54)) .verticalScroll(rememberScrollState()) ) { - // LinearLayout аналог с padding Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), // android:padding="16dp" + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Заголовок "Регистрация" (textView3) Text( text = "Регистрация", color = Color.White, @@ -104,7 +98,6 @@ fun RegistrationScreen( textAlign = TextAlign.Center ) - // Метка "Придумайте имя" (textView) Text( text = "Придумайте имя", color = Color.White, @@ -115,8 +108,7 @@ fun RegistrationScreen( textAlign = TextAlign.Center ) - // Поле ввода имени (editTextText) - CustomTextField( + CustomRegTextField( value = name, onValueChange = { name = it @@ -129,7 +121,18 @@ fun RegistrationScreen( .padding(top = 8.dp) ) - // Метка "Придумайте пароль" (textView2) + // Подсказка по формату ника + Text( + text = "Только латинские буквы (a-z, A-Z), цифры (0-9) и _ (3-16 символов)", + color = Color(0xFF888888), + fontSize = 10.sp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 4.dp), + textAlign = TextAlign.Center + ) + Text( text = "Придумайте пароль", color = Color.White, @@ -140,8 +143,7 @@ fun RegistrationScreen( textAlign = TextAlign.Center ) - // Поле ввода пароля (editTextTextPassword2) - CustomTextField( + CustomRegTextField( value = password, onValueChange = { password = it @@ -155,7 +157,6 @@ fun RegistrationScreen( .padding(top = 8.dp) ) - // Метка "E-mail" (textView9) Text( text = "E-mail", color = Color.White, @@ -166,8 +167,7 @@ fun RegistrationScreen( textAlign = TextAlign.Center ) - // Поле ввода email (editTextEmail) - CustomTextField( + CustomRegTextField( value = email, onValueChange = { email = it @@ -181,7 +181,6 @@ fun RegistrationScreen( .padding(top = 8.dp) ) - // Текст ошибки (txtWarn) if (warningText.isNotEmpty()) { Text( text = warningText, @@ -200,16 +199,12 @@ fun RegistrationScreen( color = Color.White ) } else { - // Кнопка регистрации (btnRegister) Button( onClick = { - if (validateRegFields(name, email, password, context, + if (validateRegFields(name, email, password, onError = { warningText = it } )) { - viewModel.authUser( - UserRequest(username = name, password = password, email = email), - isRegistration = true - ) + viewModel.register(UserRequest(username = name, password = password, email = email)) } }, modifier = Modifier @@ -218,18 +213,17 @@ fun RegistrationScreen( .padding(top = 16.dp, bottom = 20.dp) .height(56.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF4CAF50) // Зеленый цвет из XML + containerColor = Color(0xFF4CAF50) ), shape = RoundedCornerShape(8.dp) ) { Text( - text = "Зарегистрироваться", // Исправил опечатку "Зарегестрироваться" + text = "Зарегистрироваться", color = Color.White, fontSize = 16.sp ) } - // Текст-подсказка (textView11) Text( text = "Уже есть аккаунт? Войдите!", color = Color.White, @@ -240,7 +234,6 @@ fun RegistrationScreen( textAlign = TextAlign.Center ) - // Кнопка входа (btnLogin) Button( onClick = onNavigateToLogin, modifier = Modifier @@ -249,7 +242,7 @@ fun RegistrationScreen( .padding(top = 16.dp, bottom = 20.dp) .height(56.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF2196F3) // Синий цвет из XML + containerColor = Color(0xFF2196F3) ), shape = RoundedCornerShape(8.dp) ) { @@ -265,7 +258,7 @@ fun RegistrationScreen( } @Composable -fun CustomTextField( +fun CustomRegTextField( value: String, onValueChange: (String) -> Unit, placeholder: String, @@ -301,9 +294,9 @@ fun CustomTextField( if (value.isEmpty()) { Text( text = placeholder, - color = Color(0xFF666666), // android:textColorHint="#666666" + color = Color(0xFF666666), fontSize = 16.sp, - fontStyle = FontStyle.Italic // android:textStyle="italic" + fontStyle = FontStyle.Italic ) } innerTextField() @@ -321,35 +314,29 @@ private fun validateRegFields( name: String, email: String, pass: String, - context: Context, onError: (String) -> Unit ): Boolean { if (name.isEmpty() || pass.isEmpty() || email.isEmpty()) { onError("Заполните все поля!") return false } - if (name.length !in 3..16) { - onError("Имя должно быть от 3 до 16 символов") + + // Проверка Minecraft ника + val minecraftUsernamePattern = Regex("^[a-zA-Z0-9_]{3,16}$") + if (!minecraftUsernamePattern.matches(name)) { + onError("Имя пользователя должно содержать только латинские буквы (a-z, A-Z), цифры (0-9) и нижнее подчеркивание (_). Длина: 3-16 символов") return false } + if (pass.length < 8) { onError("Пароль должен быть минимум 8 символов") return false } + if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) { onError("Некорректный Email") return false } - return true -} -// Предпросмотр для разработки -@Composable -@Preview(showBackground = true) -fun RegistrationScreenPreview() { - MaterialTheme { - RegistrationScreen( - onNavigateToLogin = {} - ) - } + return true } \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/SecretChatIndicator.kt b/app/src/main/java/com/example/myserver/SecretChatIndicator.kt new file mode 100644 index 0000000..01b4682 --- /dev/null +++ b/app/src/main/java/com/example/myserver/SecretChatIndicator.kt @@ -0,0 +1,79 @@ +package com.example.myserver.ui + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.myserver.R +import com.example.myserver.crypto.SecretChatStatus + +@Composable +fun SecretChatIndicator( + status: SecretChatStatus, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + when (status) { + SecretChatStatus.PENDING -> { + val infiniteTransition = rememberInfiniteTransition() + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + Icon( + painter = painterResource(id = R.drawable.ic_lock_outline), + contentDescription = "Pending", + tint = Color.Yellow.copy(alpha = alpha), + modifier = Modifier.size(16.dp) + ) + Text( + text = "Ожидание подтверждения", + color = Color.Yellow, + fontSize = 11.sp + ) + } + SecretChatStatus.ACTIVE -> { + Icon( + painter = painterResource(id = R.drawable.ic_lock), + contentDescription = "Active", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp) + ) + Text( + text = "E2E шифрование", + color = Color(0xFF4CAF50), + fontSize = 11.sp + ) + } + SecretChatStatus.CLOSED -> { + Icon( + painter = painterResource(id = R.drawable.ic_lock_open), + contentDescription = "Closed", + tint = Color.Gray, + modifier = Modifier.size(16.dp) + ) + Text( + text = "Чат закрыт", + color = Color.Gray, + fontSize = 11.sp + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/SecretChatManager.kt b/app/src/main/java/com/example/myserver/SecretChatManager.kt new file mode 100644 index 0000000..bf2130a --- /dev/null +++ b/app/src/main/java/com/example/myserver/SecretChatManager.kt @@ -0,0 +1,374 @@ +package com.example.myserver.crypto + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.example.myserver.MyApplication +import com.example.myserver.SessionManager +import com.example.myserver.services.WebSocketManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import java.security.KeyPair +import java.security.PublicKey +import javax.crypto.SecretKey +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SecretChatManager @Inject constructor( + @ApplicationContext private val context: Context, + private val sessionManager: SessionManager, + private val webSocketManager: WebSocketManager +) { + companion object { + private const val PREFS_NAME = "secret_chats" + private const val KEY_MY_KEYPAIR = "my_keypair" + private const val KEY_ACTIVE_CHATS = "active_chats" + private const val TAG = "SecretChatManager" + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val gson = Gson() + + // Состояние текущего секретного чата + private val _secretChatState = MutableStateFlow(SecretChatState.Idle) + val secretChatState: StateFlow = _secretChatState.asStateFlow() + + // Активные секретные чаты + private val _activeSecretChats = MutableStateFlow>(emptyList()) + val activeSecretChats: StateFlow> = _activeSecretChats.asStateFlow() + + // Кэш ключей для секретных чатов + private val secretKeys = mutableMapOf() + + // Моя пара ключей + private var myKeyPair: KeyPair? = null + + init { + loadMyKeyPair() + loadActiveChats() + setupWebSocketListener() + } + + /** + * Загружает или генерирует пару ключей пользователя + */ + private fun loadMyKeyPair() { + val savedKeyPair = prefs.getString(KEY_MY_KEYPAIR, null) + + if (savedKeyPair != null) { + val data = gson.fromJson(savedKeyPair, KeyPairData::class.java) + myKeyPair = KeyPair( + CryptoUtils.base64ToPublicKey(data.publicKey), + CryptoUtils.base64ToPrivateKey(data.privateKey) + ) + Log.d(TAG, "✅ Loaded existing key pair") + } else { + // Генерируем новую пару ключей + myKeyPair = CryptoUtils.generateDHKeyPair() + val keyPairData = KeyPairData( + publicKey = CryptoUtils.publicKeyToBase64(myKeyPair!!.public), + privateKey = CryptoUtils.privateKeyToBase64(myKeyPair!!.private) + ) + prefs.edit().putString(KEY_MY_KEYPAIR, gson.toJson(keyPairData)).apply() + Log.d(TAG, "✅ Generated new key pair") + } + } + + /** + * Получить мой публичный ключ + */ + fun getMyPublicKey(): String { + return CryptoUtils.publicKeyToBase64(myKeyPair!!.public) + } + + /** + * Инициировать секретный чат с пользователем + */ + fun initiateSecretChat(peerUserId: Long, peerName: String) { + _secretChatState.value = SecretChatState.Initializing + + val myPublicKey = getMyPublicKey() + + // Отправляем запрос на сервер + webSocketManager.sendMessage("secret_chat_init", mapOf( + "peerUserId" to peerUserId, + "publicKey" to myPublicKey + )) + + // Сохраняем временную сессию + val tempChatId = "secret_${sessionManager.getUserId()}_${peerUserId}" + val tempSession = SecretChatSession( + chatId = tempChatId, + peerUserId = peerUserId, + peerName = peerName, + status = SecretChatStatus.PENDING, + createdAt = System.currentTimeMillis() + ) + + val chats = _activeSecretChats.value.toMutableList() + chats.add(tempSession) + _activeSecretChats.value = chats + saveActiveChats(chats) + + _secretChatState.value = SecretChatState.AwaitingResponse + Log.d(TAG, "📨 Secret chat initiated with user $peerUserId") + } + + /** + * Принять секретный чат + */ + fun acceptSecretChat(chatId: String, peerPublicKey: String) { + webSocketManager.sendMessage("secret_chat_accept", mapOf( + "chatId" to chatId, + "publicKey" to getMyPublicKey() + )) + + // Генерируем общий секрет + generateSharedSecret(chatId, peerPublicKey) + + Log.d(TAG, "✅ Secret chat accepted: $chatId") + } + + /** + * Генерирует общий секрет для чата + */ + private fun generateSharedSecret(chatId: String, peerPublicKeyBase64: String) { + try { + val peerPublicKey = CryptoUtils.base64ToPublicKey(peerPublicKeyBase64) + val sharedSecret = CryptoUtils.generateSharedSecret(myKeyPair!!.private, peerPublicKey) + val aesKey = CryptoUtils.deriveAESKey(sharedSecret) + + secretKeys[chatId] = aesKey + + // Обновляем статус чата + val chats = _activeSecretChats.value.toMutableList() + val index = chats.indexOfFirst { it.chatId == chatId } + if (index != -1) { + val updated = chats[index].copy(status = SecretChatStatus.ACTIVE) + chats[index] = updated + _activeSecretChats.value = chats + saveActiveChats(chats) + } + + _secretChatState.value = SecretChatState.Active + Log.d(TAG, "🔑 Shared secret generated for chat $chatId") + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to generate shared secret: ${e.message}") + _secretChatState.value = SecretChatState.Error("Failed to generate encryption key") + } + } + + /** + * Отправить секретное сообщение + */ + // В методе sendSecretMessage (строка ~178): + fun sendSecretMessage(chatId: String, content: String, recipientId: Long): Boolean { + val secretKey = secretKeys[chatId] + if (secretKey == null) { + Log.e(TAG, "No secret key for chat $chatId") + return false + } + + try { + val encryptedData = CryptoUtils.encryptMessage(content, secretKey) + + // ✅ ИСПРАВЛЕНО: используем JSONObject вместо Map + val json = JSONObject().apply { + put("type", "secret_message") + put("chatId", chatId) + put("cipherText", encryptedData.cipherText) + put("iv", encryptedData.iv) + put("authTag", encryptedData.tag) // tag как authTag + } + + webSocketManager.sendMessage(json.toString()) + + Log.d(TAG, "📨 Secret message sent to chat $chatId") + return true + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to encrypt message: ${e.message}") + return false + } + } + + // В методе handleSecretMessage (строка ~182): + private fun handleSecretMessage(data: JSONObject) { + val chatId = data.getString("chatId") + val cipherText = data.getString("cipherText") + val iv = data.getString("iv") + // ✅ ИСПРАВЛЕНО: используем правильное имя поля + val authTag = if (data.has("authTag")) data.getString("authTag") else data.getString("tag") + + val decrypted = decryptSecretMessage(chatId, cipherText, iv, authTag) + + val messageEvent = JSONObject().apply { + put("type", "new_secret_message") + put("chatId", chatId) + put("content", decrypted ?: "[Ошибка расшифровки]") + } + + // Отправляем в ChatActivity через broadcast + val intent = Intent("WEBSOCKET_MESSAGE").apply { + putExtra("type", "new_secret_message") + putExtra("data", messageEvent.toString()) + } + LocalBroadcastManager.getInstance(MyApplication.instance).sendBroadcast(intent) + + Log.d(TAG, "📨 Secret message received: ${decrypted?.take(50)}...") + } + + // Обновленный метод decryptSecretMessage: + fun decryptSecretMessage(chatId: String, cipherText: String, iv: String, authTag: String): String? { + val secretKey = secretKeys[chatId] + if (secretKey == null) { + Log.e(TAG, "No secret key for chat $chatId") + return null + } + + try { + val encryptedData = EncryptedData(cipherText, iv, authTag) + val decrypted = CryptoUtils.decryptMessage(encryptedData, secretKey) + return decrypted + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to decrypt message: ${e.message}") + return null + } + } + + /** + * Закрыть секретный чат + */ + fun closeSecretChat(chatId: String) { + webSocketManager.sendMessage("secret_chat_close", mapOf( + "chatId" to chatId + )) + + // Удаляем ключ из кэша + secretKeys.remove(chatId) + + // Обновляем статус + val chats = _activeSecretChats.value.toMutableList() + val index = chats.indexOfFirst { it.chatId == chatId } + if (index != -1) { + val updated = chats[index].copy(status = SecretChatStatus.CLOSED) + chats[index] = updated + _activeSecretChats.value = chats + saveActiveChats(chats) + } + + _secretChatState.value = SecretChatState.Idle + Log.d(TAG, "🔒 Secret chat closed: $chatId") + } + + /** + * Проверяет, является ли чат секретным + */ + fun isSecretChat(chatId: String): Boolean { + return _activeSecretChats.value.any { it.chatId == chatId && it.status == SecretChatStatus.ACTIVE } + } + + /** + * Получить секретный чат по ID + */ + fun getSecretChat(chatId: String): SecretChatSession? { + return _activeSecretChats.value.find { it.chatId == chatId } + } + + private fun setupWebSocketListener() { + webSocketManager.setSecretChatListener { type, data -> + when (type) { + "secret_chat_request" -> handleSecretChatRequest(data) + "secret_chat_ready" -> handleSecretChatReady(data) + "secret_message" -> handleSecretMessage(data) + "secret_chat_closed" -> handleSecretChatClosed(data) + } + } + } + + private fun handleSecretChatRequest(data: JSONObject) { + val chatId = data.getString("chatId") + val initiatorId = data.getLong("initiatorId") + val publicKey = data.getString("publicKey") + + // Сохраняем запрос + val tempSession = SecretChatSession( + chatId = chatId, + peerUserId = initiatorId, + peerName = "User $initiatorId", // TODO: Получить имя из репозитория + status = SecretChatStatus.PENDING, + createdAt = System.currentTimeMillis() + ) + + val chats = _activeSecretChats.value.toMutableList() + chats.add(tempSession) + _activeSecretChats.value = chats + saveActiveChats(chats) + + // Генерируем общий секрет для ответа + generateSharedSecret(chatId, publicKey) + + Log.d(TAG, "📨 Received secret chat request from $initiatorId") + } + + private fun handleSecretChatReady(data: JSONObject) { + val chatId = data.getString("chatId") + val peerPublicKey = data.getString("peerPublicKey") + + generateSharedSecret(chatId, peerPublicKey) + Log.d(TAG, "✅ Secret chat ready: $chatId") + } + + + + private fun handleSecretChatClosed(data: JSONObject) { + val chatId = data.getString("chatId") + secretKeys.remove(chatId) + + val chats = _activeSecretChats.value.toMutableList() + val index = chats.indexOfFirst { it.chatId == chatId } + if (index != -1) { + val updated = chats[index].copy(status = SecretChatStatus.CLOSED) + chats[index] = updated + _activeSecretChats.value = chats + saveActiveChats(chats) + } + + Log.d(TAG, "🔒 Secret chat closed by peer: $chatId") + } + + private fun loadActiveChats() { + val json = prefs.getString(KEY_ACTIVE_CHATS, null) + if (json != null) { + val type = object : TypeToken>() {}.type + _activeSecretChats.value = gson.fromJson(json, type) + } + } + + private fun saveActiveChats(chats: List) { + val json = gson.toJson(chats) + prefs.edit().putString(KEY_ACTIVE_CHATS, json).apply() + } + + data class KeyPairData( + val publicKey: String, + val privateKey: String) +} + + +/** + * Расширение для WebSocketManager для поддержки секретных чатов + */ +fun WebSocketManager.setSecretChatListener(onMessage: (type: String, data: JSONObject) -> Unit) { + // Добавляем слушатель для секретных сообщений + val originalListener = this::class.java.getDeclaredField("messageListener") + originalListener.isAccessible = true + // В реальной реализации нужно расширить WebSocketManager +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/SecretChatSession.kt b/app/src/main/java/com/example/myserver/SecretChatSession.kt new file mode 100644 index 0000000..fb81c93 --- /dev/null +++ b/app/src/main/java/com/example/myserver/SecretChatSession.kt @@ -0,0 +1,50 @@ +package com.example.myserver.crypto + +import java.security.KeyPair +import javax.crypto.SecretKey + +/** + * Сессия секретного чата + */ +data class SecretChatSession( + val chatId: String, + val peerUserId: Long, + val peerName: String, + val status: SecretChatStatus, + val createdAt: Long, + val expiresAt: Long? = null, + val selfDestructTimer: Int = 0 // секунды, 0 = отключено +) + +enum class SecretChatStatus { + PENDING, // Ожидает подтверждения + ACTIVE, // Активен + CLOSED // Закрыт +} + +/** + * Зашифрованное сообщение для UI + */ +data class SecretChatMessage( + val id: Long, + val chatId: String, + val senderId: Long, + val senderName: String, + val cipherText: String, + val iv: String, + val authTag: String, + val timestamp: Long, + val decryptedContent: String? = null, // Дешифрованное содержимое + val isDecrypted: Boolean = false +) + +/** + * Состояние секретного чата + */ +sealed class SecretChatState { + object Idle : SecretChatState() + object Initializing : SecretChatState() + object AwaitingResponse : SecretChatState() + object Active : SecretChatState() + data class Error(val message: String) : SecretChatState() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/SessionManager.kt b/app/src/main/java/com/example/myserver/SessionManager.kt new file mode 100644 index 0000000..52067d5 --- /dev/null +++ b/app/src/main/java/com/example/myserver/SessionManager.kt @@ -0,0 +1,233 @@ +package com.example.myserver + +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SessionManager @Inject constructor( + private val context: Context +) { + companion object { + private const val TAG = "SessionManager" + private const val SESSION_TTL_DAYS = 30L // 30 дней как в Telegram + private const val INACTIVE_TTL_DAYS = 7L // Неактивная сессия живёт 7 дней + } + + // Состояние сессии для наблюдения (можно подписаться в UI) + private val _sessionState = MutableStateFlow(SessionState.Inactive) + val sessionState: StateFlow = _sessionState.asStateFlow() + + // EncryptedPreferences для безопасного хранения + private val encryptedPrefs by lazy { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + + EncryptedSharedPreferences.create( + "secure_session_prefs", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + /** + * Сохраняет сессию после успешного входа + */ + fun saveSession(sessionKey: String, userId: Long, username: String, realname: String) { + val now = System.currentTimeMillis() + + encryptedPrefs.edit().apply { + putString("session_key", sessionKey) + putLong("user_id", userId) + putString("username", username) + putString("realname", realname) + putLong("created_at", now) + putLong("last_active_at", now) + putBoolean("is_active", true) + apply() + } + + _sessionState.value = SessionState.Active(username) + Log.d(TAG, "✅ Session saved for user: $username") + } + + /** + * Получает ключ сессии + */ + fun getSessionKey(): String? = encryptedPrefs.getString("session_key", null) + + /** + * Получает ID пользователя + */ + fun getUserId(): Long = encryptedPrefs.getLong("user_id", 0) + + /** + * Получает имя пользователя + */ + fun getUsername(): String? = encryptedPrefs.getString("username", null) + + /** + * Получает реальное имя (display name) + */ + fun getRealname(): String? = encryptedPrefs.getString("realname", null) + + /** + * Проверяет, активна ли сессия и не истекла ли + */ + fun isSessionValid(): Boolean { + val sessionKey = getSessionKey() + if (sessionKey == null) { + Log.d(TAG, "No session key found") + return false + } + + val isActive = encryptedPrefs.getBoolean("is_active", false) + if (!isActive) { + Log.d(TAG, "Session is inactive") + return false + } + + val createdAt = encryptedPrefs.getLong("created_at", 0) + val lastActiveAt = encryptedPrefs.getLong("last_active_at", 0) + val now = System.currentTimeMillis() + + // Проверка общего TTL (30 дней максимум) + val ttlMillis = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000L + if (now - createdAt > ttlMillis) { + Log.d(TAG, "Session expired (TTL)") + clearSession() + return false + } + + // Проверка неактивности (7 дней без использования) + val inactiveTtlMillis = INACTIVE_TTL_DAYS * 24 * 60 * 60 * 1000L + if (now - lastActiveAt > inactiveTtlMillis) { + Log.d(TAG, "Session expired (inactivity)") + clearSession() + return false + } + + // Обновляем время последней активности + updateLastActive() + + return true + } + + /** + * Обновляет время последней активности сессии + */ + fun updateLastActive() { + encryptedPrefs.edit().putLong("last_active_at", System.currentTimeMillis()).apply() + } + + /** + * Обновляет состояние сессии (после проверки на сервере) + */ + fun updateSessionState(isActive: Boolean, userId: Long = 0, username: String? = null) { + if (!isActive) { + clearSession() + } else if (userId > 0 && username != null) { + encryptedPrefs.edit().apply { + putLong("user_id", userId) + putString("username", username) + putBoolean("is_active", true) + putLong("last_active_at", System.currentTimeMillis()) + apply() + } + _sessionState.value = SessionState.Active(username) + } + } + + /** + * Очищает сессию (выход, удаление, истечение) + */ + fun clearSession() { + encryptedPrefs.edit().clear().apply() + _sessionState.value = SessionState.Inactive + Log.d(TAG, "✅ Session cleared") + } + + /** + * Получает информацию об устройстве для отправки на сервер + */ + fun getDeviceInfo(): DeviceInfo { + return DeviceInfo( + deviceName = Build.MODEL, + deviceModel = Build.MODEL, + osVersion = Build.VERSION.RELEASE, + appVersion = getAppVersion() + ) + } + + /** + * Получает версию приложения + */ + private fun getAppVersion(): String { + return try { + val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) + pInfo.versionName ?: "1.0" + } catch (e: Exception) { + "1.0" + } + } + + /** + * Проверяет, есть ли сохранённая сессия (без проверки валидности) + */ + fun hasSession(): Boolean = getSessionKey() != null + + /** + * Получает информацию о текущей сессии + */ + fun getSessionInfo(): SessionInfo? { + return if (isSessionValid()) { + SessionInfo( + sessionKey = getSessionKey() ?: return null, + userId = getUserId(), + username = getUsername() ?: "", + realname = getRealname() ?: "", + createdAt = encryptedPrefs.getLong("created_at", 0), + lastActiveAt = encryptedPrefs.getLong("last_active_at", 0) + ) + } else { + null + } + } +} + +// ❌ УДАЛИТЬ ЭТОТ БЛОК (строки ~223-227): +// sealed class SessionState { +// object Inactive : SessionState() +// data class Active(val username: String) : SessionState() +// object Expired : SessionState() +// } + +/** + * Информация о сессии + */ +data class SessionInfo( + val sessionKey: String, + val userId: Long, + val username: String, + val realname: String, + val createdAt: Long, + val lastActiveAt: Long +) + +/** + * Информация об устройстве + */ +data class DeviceInfo( + val deviceName: String, + val deviceModel: String, + val osVersion: String, + val appVersion: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/StatusRequest.kt b/app/src/main/java/com/example/myserver/StatusRequest.kt new file mode 100644 index 0000000..c154dec --- /dev/null +++ b/app/src/main/java/com/example/myserver/StatusRequest.kt @@ -0,0 +1,5 @@ +package com.example.myserver + +data class StatusRequest( + val isActive: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/UserModel.kt b/app/src/main/java/com/example/myserver/UserModel.kt index 23dbdab..c2a6df3 100644 --- a/app/src/main/java/com/example/myserver/UserModel.kt +++ b/app/src/main/java/com/example/myserver/UserModel.kt @@ -14,5 +14,16 @@ data class UserResponse( val id: Long, val username: String, val realname: String? = null, - val email: String? = null + val email: String? = null, + val active: Boolean = false // Добавить ) + +// AuthResponse.kt +data class AuthResponse( + val success: Boolean, + val message: String? = null, + val token: String? = null +) + + + diff --git a/app/src/main/java/com/example/myserver/WebSocketManager.kt b/app/src/main/java/com/example/myserver/WebSocketManager.kt new file mode 100644 index 0000000..230c49d --- /dev/null +++ b/app/src/main/java/com/example/myserver/WebSocketManager.kt @@ -0,0 +1,241 @@ +package com.example.myserver.services + +import android.content.Intent +import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.example.myserver.MyApplication +import com.example.myserver.SessionManager +import com.example.myserver.crypto.EncryptedData +import com.google.gson.Gson +import kotlinx.coroutines.* +import okhttp3.* +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WebSocketManager @Inject constructor( + private val sessionManager: SessionManager, + private val offlineMessageQueue: OfflineMessageQueue +) { + companion object { + private const val TAG = "WebSocketManager" + private const val PING_INTERVAL = 15L // Уменьшил до 15 секунд + private const val MAX_RECONNECT_ATTEMPTS = 10 + } + + private var webSocket: WebSocket? = null + private var isConnecting = false + private var reconnectAttempts = 0 + private var reconnectJob: Job? = null + private var messageListener: ((type: String, data: JSONObject) -> Unit)? = null + private var isFlushingQueue = false + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .pingInterval(PING_INTERVAL, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + private val gson = Gson() + + fun setMessageListener(listener: (type: String, data: JSONObject) -> Unit) { + this.messageListener = listener + } + + fun removeMessageListener() { + this.messageListener = null + } + + fun connect() { + if (isConnecting || webSocket != null) { + Log.d(TAG, "Already connecting or connected") + return + } + + val token = sessionManager.getSessionKey() + if (token == null) { + Log.e(TAG, "No session key") + return + } + + isConnecting = true + reconnectAttempts = 0 + + // 🔥 ВРЕМЕННО: передаём ключ в URL + val url = "ws://quantumblocks.hopto.org:45678/ws?session_key=$token" + + val request = Request.Builder() + .url(url) + .build() + + Log.d(TAG, "Connecting to WebSocket with URL: $url") + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "✅ WebSocket connected") + isConnecting = false + reconnectAttempts = 0 + reconnectJob?.cancel() + flushQueue() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val json = JSONObject(text) + val type = if (json.has("type")) json.getString("type") else "unknown" + Log.d(TAG, "📨 Received: $type") + messageListener?.invoke(type, json) + + val intent = Intent("WEBSOCKET_MESSAGE") + intent.putExtra("type", type) + intent.putExtra("data", text) + LocalBroadcastManager.getInstance(MyApplication.instance).sendBroadcast(intent) + + } catch (e: Exception) { + Log.e(TAG, "Error parsing message: ${e.message}") + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "❌ WebSocket error: ${t.message}") + disconnect() + scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $reason") + disconnect() + scheduleReconnect() + } + }) + } + + fun sendMessage(json: String, tempId: Long? = null): Boolean { + val actualTempId = tempId ?: System.currentTimeMillis() + + if (!isConnected()) { + offlineMessageQueue.enqueue(json, actualTempId) + Log.d(TAG, "📦 No connection, message queued (tempId=$actualTempId)") + return false + } + + val result = webSocket?.send(json) ?: false + if (!result) { + offlineMessageQueue.enqueue(json, actualTempId) + Log.d(TAG, "📦 Send failed, message queued (tempId=$actualTempId)") + } else { + Log.d(TAG, "✅ Message sent (tempId=$actualTempId)") + } + return result + } + + fun sendMessage(type: String, data: Map) { + val json = JSONObject(data).apply { + put("type", type) + }.toString() + sendMessage(json) + } + + fun flushQueue() { + if (!isConnected() || isFlushingQueue) { + return + } + + isFlushingQueue = true + + try { + val queuedMessages = offlineMessageQueue.dequeueAll() + if (queuedMessages.isNotEmpty()) { + Log.d(TAG, "📤 Flushing ${queuedMessages.size} queued messages") + + for (queuedMessage in queuedMessages) { + if (queuedMessage.retryCount >= 5) { + Log.e(TAG, "❌ Message ${queuedMessage.tempId} failed after 5 retries, dropping") + continue + } + + val result = webSocket?.send(queuedMessage.json) ?: false + if (!result) { + offlineMessageQueue.enqueue(queuedMessage.json, queuedMessage.tempId) + offlineMessageQueue.updateRetryCount(queuedMessage.tempId) + Log.d(TAG, "📦 Message ${queuedMessage.tempId} re-queued (retry ${queuedMessage.retryCount + 1})") + } else { + Log.d(TAG, "✅ Queued message sent (tempId=${queuedMessage.tempId})") + } + } + } + } finally { + isFlushingQueue = false + } + } + + fun isConnected(): Boolean { + return webSocket != null + } + + private fun scheduleReconnect() { + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + Log.e(TAG, "Max reconnect attempts reached") + return + } + + reconnectJob = CoroutineScope(Dispatchers.IO).launch { + val delay = calculateDelay() + Log.d(TAG, "Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1}/$MAX_RECONNECT_ATTEMPTS)") + delay(delay) + reconnectAttempts++ + connect() + } + } + + private fun calculateDelay(): Long { + val exponential = (1 shl reconnectAttempts.coerceAtMost(5)).toLong() * 1000 + return exponential.coerceAtMost(30000) + } + + fun disconnect() { + reconnectJob?.cancel() + webSocket?.close(1000, "Normal closure") + webSocket = null + isConnecting = false + Log.d(TAG, "Disconnected") + } + + fun initSecretChat(peerUserId: Long, myPublicKey: String) { + sendMessage("secret_chat_init", mapOf( + "peerUserId" to peerUserId, + "publicKey" to myPublicKey + )) + } + + fun acceptSecretChat(chatId: String, peerPublicKey: String) { + sendMessage("secret_chat_accept", mapOf( + "chatId" to chatId, + "publicKey" to peerPublicKey + )) + } + + fun sendSecretMessage(chatId: String, encryptedData: EncryptedData) { + sendMessage("secret_message", mapOf( + "chatId" to chatId, + "cipherText" to encryptedData.cipherText, + "iv" to encryptedData.iv, + "tag" to encryptedData.tag + )) + } + + private fun generateWebSocketKey(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/WebSocketService.kt b/app/src/main/java/com/example/myserver/WebSocketService.kt new file mode 100644 index 0000000..49a995d --- /dev/null +++ b/app/src/main/java/com/example/myserver/WebSocketService.kt @@ -0,0 +1,140 @@ +package com.example.myserver.services + +import android.app.* +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.example.myserver.MainActivity +import com.example.myserver.SessionManager +import okhttp3.* +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class WebSocketService : Service() { + + private var webSocket: WebSocket? = null + private val sessionManager by lazy { SessionManager(this) } + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private val CHANNEL_ID = "websocket_channel" + private val NOTIFICATION_ID = 1 + + private var messageListener: ((String, JSONObject) -> Unit)? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + startForeground(NOTIFICATION_ID, createNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + connectWebSocket() + return START_STICKY + } + + private fun connectWebSocket() { + val token = sessionManager.getSessionKey() + if (token == null) { + Log.e("WebSocketService", "No session key") + stopSelf() + return + } + + val url = "ws://quantumblocks.hopto.org:45678/ws?session_key=$token" + val request = Request.Builder().url(url).build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d("WebSocketService", "✅ WebSocket connected") + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val json = JSONObject(text) + val type = json.getString("type") + Log.d("WebSocketService", "📨 Received: $type") + messageListener?.invoke(type, json) + + // Отправляем broadcast для ChatActivity + val intent = Intent("WEBSOCKET_MESSAGE") + intent.putExtra("type", type) + intent.putExtra("data", text) + sendBroadcast(intent) + + } catch (e: Exception) { + Log.e("WebSocketService", "Error: ${e.message}") + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e("WebSocketService", "❌ Error: ${t.message}") + // Переподключение через 5 секунд + android.os.Handler(mainLooper).postDelayed({ connectWebSocket() }, 5000) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d("WebSocketService", "Closed: $reason") + connectWebSocket() + } + }) + } + + fun sendMessage(json: String) { + webSocket?.send(json) ?: Log.e("WebSocketService", "Cannot send, WebSocket is null") + } + + fun setMessageListener(listener: (String, JSONObject) -> Unit) { + messageListener = listener + } + + fun removeMessageListener() { + messageListener = null + } + + private fun createNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("MySERVER") + .setContentText("Connected for instant messages") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "MySERVER Connection", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps connection for instant messages" + setShowBadge(false) + } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + override fun onDestroy() { + super.onDestroy() + webSocket?.close(1000, null) + } + + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myserver/di/AppModule.kt b/app/src/main/java/com/example/myserver/di/AppModule.kt new file mode 100644 index 0000000..c81993b --- /dev/null +++ b/app/src/main/java/com/example/myserver/di/AppModule.kt @@ -0,0 +1,28 @@ +package com.example.myserver.di + +import android.content.Context +import com.example.myserver.SessionManager +import com.example.myserver.services.OfflineMessageQueue +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideSessionManager(@ApplicationContext context: Context): SessionManager { + return SessionManager(context) + } + + @Provides + @Singleton + fun provideOfflineMessageQueue(@ApplicationContext context: Context): OfflineMessageQueue { + return OfflineMessageQueue(context) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..9724314 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock_open.xml b/app/src/main/res/drawable/ic_lock_open.xml new file mode 100644 index 0000000..e2d6f4d --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_open.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_lock_outline.xml b/app/src/main/res/drawable/ic_lock_outline.xml new file mode 100644 index 0000000..4e6c45c --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_outline.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..8331a82 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 2e70291..7fa2b05 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,10 @@ // Top-level build file where you can add configuration options common to all subprojects/modules. plugins { alias(libs.plugins.android.application) apply false - id("org.jetbrains.kotlin.android") version "1.9.0" apply false - id("com.google.dagger.hilt.android") version "2.51.1" apply false - id("org.jetbrains.kotlin.kapt") version "1.9.0" apply false - + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.google.dagger.hilt.android") version "2.55" apply false + id("org.jetbrains.kotlin.kapt") version "2.1.0" apply false + alias(libs.plugins.kotlin.compose) apply false // Новая строка + id("com.google.gms.google-services") version "4.4.4" apply false } + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22cb81a..30ee384 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] agp = "8.13.2" -kotlin = "1.9.0" -hilt = "2.51.1" -composeBom = "2023.10.01" +kotlin = "2.1.0" +hilt = "2.55" +composeBom = "2024.10.01" retrofit = "2.9.0" okhttp = "4.12.0" activityCompose = "1.8.2" @@ -13,6 +13,7 @@ espressoCore = "3.5.1" appcompat = "1.6.1" material = "1.11.0" foundation = "1.10.4" +playServicesCastFramework = "22.3.1" [libraries] compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } @@ -41,9 +42,11 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCastFramework" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7bc0666..d15d0b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,21 +1,16 @@ pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } } }