Change 25 04 2026 add chats
This commit is contained in:
Generated
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="com.example.myserver" />
|
||||
<option name="mobileSdkAppId" value="1:265598164282:android:4e6884c34c59a6b8ac0403" />
|
||||
<option name="projectId" value="quapp-5cd84" />
|
||||
<option name="projectNumber" value="265598164282" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
||||
+122
-24
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
useBuildCache = false
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+161
@@ -19,3 +19,164 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-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.* <methods>;
|
||||
}
|
||||
-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 {
|
||||
<init>(...);
|
||||
}
|
||||
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
|
||||
<init>(...);
|
||||
}
|
||||
|
||||
# ==================== RETROFIT & OKHTTP ====================
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclasseswithmembers class * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep interface okhttp3.** { *; }
|
||||
|
||||
# ==================== GSON ====================
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keep class com.example.myserver.** { *; } # Сохраняем наши модели
|
||||
-keepclassmembers class com.example.myserver.** {
|
||||
<init>(...);
|
||||
<fields>;
|
||||
}
|
||||
-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.* <methods>;
|
||||
}
|
||||
-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;
|
||||
<init>(...);
|
||||
<fields>;
|
||||
}
|
||||
|
||||
# Сохраняем 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 <methods>;
|
||||
}
|
||||
@@ -4,9 +4,16 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -14,9 +21,24 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MySERVER"
|
||||
android:theme="@style/Theme.QuApp"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name=".MyApplication">
|
||||
tools:targetApi="31">
|
||||
|
||||
<service
|
||||
android:name=".services.WebSocketService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".MyFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ProfileActivity"
|
||||
android:exported="false" />
|
||||
@@ -35,15 +57,29 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ChatActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".LogActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FileProvider для камеры -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>(AuthState.Idle)
|
||||
val authState = _authState.asStateFlow()
|
||||
val authState: StateFlow<AuthState> = _authState
|
||||
|
||||
fun authUser(request: UserRequest, isRegistration: Boolean) {
|
||||
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Inactive)
|
||||
val sessionState: StateFlow<SessionState> = _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 {
|
||||
apiService.login(request)
|
||||
_authState.value = AuthState.Error(result?.message ?: "Ошибка регистрации")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
package com.example.myserver
|
||||
|
||||
data class ChatHistoryResponse(
|
||||
val messages: List<ChatMessage>,
|
||||
val currentPage: Int,
|
||||
val totalPages: Int,
|
||||
val totalMessages: Long,
|
||||
val hasNextPage: Boolean,
|
||||
val hasPreviousPage: Boolean
|
||||
)
|
||||
@@ -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<Int>,
|
||||
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<MediaAttachment> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MessageEntity> {
|
||||
val offset = page * pageSize
|
||||
return messageDao.getMessagesForChatPaginated(chatId, pageSize, offset)
|
||||
}
|
||||
|
||||
suspend fun getLastMessages(chatId: String, limit: Int = 30): List<MessageEntity> {
|
||||
return messageDao.getLastMessages(chatId, limit)
|
||||
}
|
||||
|
||||
suspend fun getMessagesBeforeTimestamp(chatId: String, beforeTimestamp: Long, limit: Int = 30): List<MessageEntity> {
|
||||
return messageDao.getMessagesBeforeTimestamp(chatId, beforeTimestamp, limit)
|
||||
}
|
||||
|
||||
suspend fun getMessagesAfterTimestamp(chatId: String, afterTimestamp: Long): List<MessageEntity> {
|
||||
return messageDao.getMessagesAfterTimestamp(chatId, afterTimestamp)
|
||||
}
|
||||
|
||||
suspend fun saveMessage(message: MessageEntity) {
|
||||
messageDao.insertMessage(message)
|
||||
}
|
||||
|
||||
suspend fun saveMessages(messages: List<MessageEntity>) {
|
||||
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<Int> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,81 +32,187 @@ 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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingAutoLogin) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else {
|
||||
LoginScreenContent(
|
||||
name = name,
|
||||
onNameChange = {
|
||||
name = it
|
||||
warningText = "" // Сбрасываем ошибку при вводе
|
||||
},
|
||||
onNameChange = { name = it; warningText = "" },
|
||||
password = password,
|
||||
onPasswordChange = {
|
||||
password = it
|
||||
warningText = "" // Сбрасываем ошибку при вводе
|
||||
},
|
||||
onPasswordChange = { password = it; warningText = "" },
|
||||
warningText = warningText,
|
||||
isLoading = authState is AuthState.Loading,
|
||||
onLoginClick = {
|
||||
if (validateFields(name, password, context)) {
|
||||
viewModel.authUser(UserRequest(name, password), isRegistration = false)
|
||||
val deviceInfo = sessionManager.getDeviceInfo()
|
||||
viewModel.login(
|
||||
UserRequest(name, password),
|
||||
deviceInfo,
|
||||
onSuccess = {},
|
||||
onError = { warningText = it }
|
||||
)
|
||||
}
|
||||
},
|
||||
onRegisterClick = {
|
||||
context.startActivity(Intent(context, RegActivity::class.java))
|
||||
(context as LogActivity).finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreenContent(
|
||||
@@ -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()
|
||||
|
||||
@@ -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,94 +91,340 @@ 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<List<UserResponse>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(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 (_: Exception) {
|
||||
Toast.makeText(context, "Ошибка сети", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Ошибка сети: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Простой Box с фоном
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF3E4B54))
|
||||
) {
|
||||
// Верхняя часть с кнопками
|
||||
MainHeader(
|
||||
onLogout = onLogout,
|
||||
context = context
|
||||
context = context,
|
||||
connectionStatus = connectionStatus,
|
||||
queuedMessagesCount = offlineMessageQueue.size()
|
||||
)
|
||||
|
||||
// Список игроков или индикатор загрузки
|
||||
if (isLoading) {
|
||||
when {
|
||||
isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
}
|
||||
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
|
||||
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 PlayersList(
|
||||
players: List<UserResponse>,
|
||||
context: Context,
|
||||
currentUserId: Long,
|
||||
currentUsername: String,
|
||||
onStartChat: (player: UserResponse, isSecret: Boolean) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 210.dp)
|
||||
.background(Color(0xFF3E4B54)),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = players.filter { it.username != currentUsername },
|
||||
key = { it.id }
|
||||
) { player ->
|
||||
PlayerCard(
|
||||
player = player,
|
||||
onChatClick = { onStartChat(player, false) },
|
||||
onSecretChatClick = { onStartChat(player, true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "MySERVER",
|
||||
text = "QuApp",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
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 {
|
||||
// Убери clickable с Image, используй IconButton
|
||||
IconButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = modifier
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.img6),
|
||||
contentDescription = "Menu",
|
||||
modifier = modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { expanded = true }
|
||||
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,30 +526,27 @@ fun MenuButton(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(90.dp)
|
||||
) {
|
||||
// Фон
|
||||
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)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
@@ -276,82 +556,3 @@ fun MenuButton(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlayersList(
|
||||
players: List<UserResponse>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Map<Long, Int>>(emptyMap())
|
||||
val compressionProgress: StateFlow<Map<Long, Int>> = _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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.myserver
|
||||
|
||||
enum class MediaType {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
DOCUMENT
|
||||
}
|
||||
@@ -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<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp DESC LIMIT :limit")
|
||||
suspend fun getLastMessages(chatId: String, limit: Int): List<MessageEntity>
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chat_id = :chatId AND timestamp > :afterTimestamp ORDER BY timestamp ASC")
|
||||
suspend fun getMessagesAfterTimestamp(chatId: String, afterTimestamp: Long): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC")
|
||||
suspend fun getAllMessagesForChat(chatId: String): List<MessageEntity>
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessage(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC")
|
||||
fun observeMessages(chatId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0")
|
||||
fun observeUnreadCount(userId: Long): Flow<Int>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AuthResponse>
|
||||
|
||||
@POST("api/auth/login")
|
||||
suspend fun login(@Body request: UserRequest): Response<UserResponse>
|
||||
suspend fun login(@Body userRequest: UserRequest): Response<LoginResponse>
|
||||
|
||||
@POST("api/auth/register")
|
||||
suspend fun register(@Body request: UserRequest): Response<UserResponse>
|
||||
@GET("api/auth/check-session")
|
||||
suspend fun checkSession(@Header("X-Session-Key") sessionKey: String): Response<SessionCheckResponse>
|
||||
|
||||
@POST("api/auth/logout")
|
||||
suspend fun logout(@Header("X-Session-Key") sessionKey: String): Response<Void>
|
||||
|
||||
@GET("api/auth/players")
|
||||
suspend fun getPlayers(): Response<List<UserResponse>>
|
||||
|
||||
@POST("api/auth/update")
|
||||
suspend fun updateUser(@Body request: UserRequest): Response<UserResponse>
|
||||
@GET("api/auth/chat/history/{userId}")
|
||||
suspend fun getChatHistory(
|
||||
@Path("userId") userId: Long,
|
||||
@Header("X-Session-Key") sessionKey: String
|
||||
): Response<List<ChatMessage>>
|
||||
// Добавить в MyApiService.kt:
|
||||
|
||||
@POST("api/auth/fcm/register")
|
||||
suspend fun registerFcmToken(
|
||||
@Header("X-Session-Key") sessionKey: String,
|
||||
@Body request: FcmTokenRequest
|
||||
): Response<FcmTokenResponse>
|
||||
|
||||
@DELETE("api/auth/fcm/unregister")
|
||||
suspend fun unregisterFcmToken(
|
||||
@Header("X-Session-Key") sessionKey: String
|
||||
): Response<Void>
|
||||
// Добавить в 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<ChatHistoryResponse>
|
||||
@Multipart
|
||||
@POST("api/media/upload")
|
||||
suspend fun uploadMedia(
|
||||
@Header("X-Session-Key") sessionKey: String,
|
||||
@Part("recipientId") recipientId: Long,
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<UploadResponse>
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
@@ -1,7 +1,34 @@
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>) {
|
||||
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<String, String>) {
|
||||
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<String, String>) {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<QueuedMessage> {
|
||||
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<QueuedMessage> {
|
||||
val json = prefs.getString(KEY_QUEUE, null)
|
||||
if (json == null) return emptyList()
|
||||
|
||||
return try {
|
||||
val type = object : TypeToken<List<QueuedMessage>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить очередь в SharedPreferences
|
||||
*/
|
||||
private fun saveQueue(queue: List<QueuedMessage>) {
|
||||
val json = gson.toJson(queue)
|
||||
prefs.edit().putString(KEY_QUEUE, json).apply()
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>(SecretChatState.Idle)
|
||||
val secretChatState: StateFlow<SecretChatState> = _secretChatState.asStateFlow()
|
||||
|
||||
// Активные секретные чаты
|
||||
private val _activeSecretChats = MutableStateFlow<List<SecretChatSession>>(emptyList())
|
||||
val activeSecretChats: StateFlow<List<SecretChatSession>> = _activeSecretChats.asStateFlow()
|
||||
|
||||
// Кэш ключей для секретных чатов
|
||||
private val secretKeys = mutableMapOf<String, SecretKey>()
|
||||
|
||||
// Моя пара ключей
|
||||
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<List<SecretChatSession>>() {}.type
|
||||
_activeSecretChats.value = gson.fromJson(json, type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveActiveChats(chats: List<SecretChatSession>) {
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>(SessionState.Inactive)
|
||||
val sessionState: StateFlow<SessionState> = _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
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.myserver
|
||||
|
||||
data class StatusRequest(
|
||||
val isActive: Boolean
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<String, Any>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24,7,6v2H6c-1.1,0-2,0.9-2,2v10c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V10c0-1.1-0.9-2-2-2zM12,17c-1.1,0-2-0.9-2-2s0.9-2,2-2s2,0.9,2,2S13.1,17,12,17zM15.1,8H8.9V6c0-1.71,1.39-3.1,3.1-3.1s3.1,1.39,3.1,3.1V8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF"> <!-- Белый цвет -->
|
||||
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10C20,8.9 19.1,8 18,8z M12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2s2,0.9 2,2S13.1,17 12,17z"
|
||||
android:fillType="nonZero"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,17c1.1,0,2-0.9,2-2s-0.9-2-2-2s-2,0.9-2,2S10.9,17,12,17zM18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24,7,6h1.9c0-1.71,1.39-3.1,3.1-3.1s3.1,1.39,3.1,3.1v2H6c-1.1,0-2,0.9-2,2v10c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V10c0-1.1-0.9-2-2-2zM18,18H6V10h12V18z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="/" />
|
||||
<files-path name="files" path="/" />
|
||||
</paths>
|
||||
+6
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+3
-8
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user