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.kotlin.android)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.kapt)
|
alias(libs.plugins.kapt)
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -17,27 +21,48 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
arguments("-DANDROID_STL=c++_shared")
|
|
||||||
cppFlags("-Wl,-z,max-page-size=16384")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
debug {
|
||||||
|
//applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"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 {
|
compileOptions {
|
||||||
// Compose и Hilt лучше работают на Java 17
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -51,7 +76,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.1" // Соответствует Kotlin 1.9.0
|
//kotlinCompilerExtensionVersion = "1.5.1"
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.15"
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -59,22 +85,21 @@ android {
|
|||||||
excludes += "/META-INF/{AL2.0,LGPL2.1,androidx.compose.material3_material3.version}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1,androidx.compose.material3_material3.version}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
externalNativeBuild {
|
kapt {
|
||||||
cmake {
|
correctErrorTypes = true
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
useBuildCache = false
|
||||||
version = "3.22.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// 1. Сначала объявляем BOM (один раз в самом начале)
|
implementation(libs.play.services.cast.framework)
|
||||||
|
// 1. BOM
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.02.01")
|
val composeBom = platform("androidx.compose:compose-bom:2024.02.01")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
|
|
||||||
// 2. Jetpack Compose (версии берутся из BOM)
|
// 2. Compose
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.graphics)
|
implementation(libs.compose.ui.graphics)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
@@ -82,29 +107,102 @@ dependencies {
|
|||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.foundation)
|
implementation(libs.foundation)
|
||||||
|
|
||||||
// 3. Material 3 (обязательно для PullToRefreshBox)
|
// 3. Material 3
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material3:material3-window-size-class")
|
implementation("androidx.compose.material3:material3-window-size-class")
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
// 4. Сеть (Retrofit)
|
// 4. Network
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.retrofit.gson)
|
implementation(libs.retrofit.gson)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
// 5. Dagger Hilt
|
// 5. Hilt
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
// 6. Остальное
|
// 6. AndroidX
|
||||||
implementation(libs.appcompat)
|
implementation(libs.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
|
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
|
||||||
|
|
||||||
// Тесты и отладка
|
// 7. Tests
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.ext.junit)
|
androidTestImplementation(libs.ext.junit)
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
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
+162
-1
@@ -18,4 +18,165 @@
|
|||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.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
|
<application
|
||||||
|
android:name=".MyApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@@ -14,9 +21,24 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.MySERVER"
|
android:theme="@style/Theme.QuApp"
|
||||||
android:usesCleartextTraffic="true"
|
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
|
<activity
|
||||||
android:name=".ProfileActivity"
|
android:name=".ProfileActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@@ -35,15 +57,29 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ChatActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".LogActivity"
|
android:name=".LogActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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 androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val apiService: MyApiService
|
private val apiService: MyApiService,
|
||||||
|
private val sessionManager: SessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// Состояние экрана (загрузка, успех, ошибка)
|
|
||||||
private val _authState = MutableStateFlow<AuthState>(AuthState.Idle)
|
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 {
|
viewModelScope.launch {
|
||||||
_authState.value = AuthState.Loading
|
_authState.value = AuthState.Loading
|
||||||
try {
|
try {
|
||||||
val response = if (isRegistration) {
|
val response = apiService.register(userRequest)
|
||||||
apiService.register(request)
|
if (response.isSuccessful) {
|
||||||
|
val result = response.body()
|
||||||
|
if (result?.success == true) {
|
||||||
|
_authState.value = AuthState.Success
|
||||||
|
} else {
|
||||||
|
_authState.value = AuthState.Error(result?.message ?: "Ошибка регистрации")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
apiService.login(request)
|
_authState.value = AuthState.Error("Ошибка сервера: ${response.code()}")
|
||||||
}
|
|
||||||
|
|
||||||
if (response.isSuccessful && response.body() != null) {
|
|
||||||
_authState.value = AuthState.Success(response.body()!!)
|
|
||||||
} else {
|
|
||||||
_authState.value = AuthState.Error("Ошибка: ${response.code()}")
|
|
||||||
}
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_authState.value = AuthState.Error("Нет соединения с сервером")
|
||||||
} catch (e: Exception) {
|
} 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 {
|
sealed class AuthState {
|
||||||
object Idle : AuthState()
|
object Idle : AuthState()
|
||||||
object Loading : AuthState()
|
object Loading : AuthState()
|
||||||
data class Success(val user: UserResponse) : AuthState()
|
object Success : AuthState()
|
||||||
data class Error(val message: String) : 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
|
package com.example.myserver
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -30,80 +32,186 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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
|
@AndroidEntryPoint
|
||||||
class LogActivity : ComponentActivity() {
|
class LogActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sessionManager: SessionManager
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var apiService: MyApiService
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
if (prefs.getBoolean("isLoggedIn", false)) {
|
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 100)
|
||||||
startActivity(Intent(this, MainActivity::class.java))
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
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
|
@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 context = LocalContext.current
|
||||||
val authState by viewModel.authState.collectAsState()
|
val authState by viewModel.authState.collectAsState()
|
||||||
|
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var warningText 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) {
|
LaunchedEffect(authState) {
|
||||||
when (authState) {
|
when (authState) {
|
||||||
is AuthState.Success -> {
|
is AuthState.Success -> {
|
||||||
context.getSharedPreferences("AppPrefs", Context.MODE_PRIVATE).edit {
|
onStartWebSocket()
|
||||||
putBoolean("isLoggedIn", true)
|
onAutoLoginSuccess()
|
||||||
putString("realname", name)
|
onFinishActivity()
|
||||||
}
|
|
||||||
context.startActivity(Intent(context, MainActivity::class.java))
|
|
||||||
(context as LogActivity).finish()
|
|
||||||
}
|
}
|
||||||
is AuthState.Error -> {
|
is AuthState.Error -> {
|
||||||
warningText = (authState as AuthState.Error).message
|
warningText = (authState as AuthState.Error).message
|
||||||
}
|
}
|
||||||
else -> { /* Loading or Idle */ }
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoginScreenContent(
|
if (isCheckingAutoLogin) {
|
||||||
name = name,
|
Box(
|
||||||
onNameChange = {
|
modifier = Modifier.fillMaxSize(),
|
||||||
name = it
|
contentAlignment = Alignment.Center
|
||||||
warningText = "" // Сбрасываем ошибку при вводе
|
) {
|
||||||
},
|
CircularProgressIndicator(color = Color.White)
|
||||||
password = password,
|
|
||||||
onPasswordChange = {
|
|
||||||
password = it
|
|
||||||
warningText = "" // Сбрасываем ошибку при вводе
|
|
||||||
},
|
|
||||||
warningText = warningText,
|
|
||||||
isLoading = authState is AuthState.Loading,
|
|
||||||
onLoginClick = {
|
|
||||||
if (validateFields(name, password, context)) {
|
|
||||||
viewModel.authUser(UserRequest(name, password), isRegistration = false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRegisterClick = {
|
|
||||||
context.startActivity(Intent(context, RegActivity::class.java))
|
|
||||||
(context as LogActivity).finish()
|
|
||||||
}
|
}
|
||||||
)
|
} else {
|
||||||
|
LoginScreenContent(
|
||||||
|
name = name,
|
||||||
|
onNameChange = { name = it; warningText = "" },
|
||||||
|
password = password,
|
||||||
|
onPasswordChange = { password = it; warningText = "" },
|
||||||
|
warningText = warningText,
|
||||||
|
isLoading = authState is AuthState.Loading,
|
||||||
|
onLoginClick = {
|
||||||
|
if (validateFields(name, password, context)) {
|
||||||
|
val deviceInfo = sessionManager.getDeviceInfo()
|
||||||
|
viewModel.login(
|
||||||
|
UserRequest(name, password),
|
||||||
|
deviceInfo,
|
||||||
|
onSuccess = {},
|
||||||
|
onError = { warningText = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRegisterClick = {
|
||||||
|
context.startActivity(Intent(context, RegActivity::class.java))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -117,21 +225,18 @@ fun LoginScreenContent(
|
|||||||
onLoginClick: () -> Unit,
|
onLoginClick: () -> Unit,
|
||||||
onRegisterClick: () -> Unit
|
onRegisterClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
// ScrollView аналог
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF3E4B54))
|
.background(Color(0xFF3E4B54))
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
// LinearLayout аналог с padding="16dp"
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
// TextView3 - Заголовок
|
|
||||||
Text(
|
Text(
|
||||||
text = "Авторизация",
|
text = "Авторизация",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -143,7 +248,6 @@ fun LoginScreenContent(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageView - Логотип
|
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.img5),
|
painter = painterResource(id = R.drawable.img5),
|
||||||
contentDescription = "logo",
|
contentDescription = "logo",
|
||||||
@@ -152,7 +256,6 @@ fun LoginScreenContent(
|
|||||||
.size(132.dp, 124.dp)
|
.size(132.dp, 124.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// TextView - Метка "Имя пользователя"
|
|
||||||
Text(
|
Text(
|
||||||
text = "Имя пользователя",
|
text = "Имя пользователя",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -163,8 +266,6 @@ fun LoginScreenContent(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// EditText - Поле ввода имени
|
|
||||||
// Используем Card для имитации @drawable/player_card_bg
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -210,7 +311,6 @@ fun LoginScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextView2 - Метка "Пароль"
|
|
||||||
Text(
|
Text(
|
||||||
text = "Пароль",
|
text = "Пароль",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -221,7 +321,6 @@ fun LoginScreenContent(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// EditText - Поле ввода пароля
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -268,7 +367,6 @@ fun LoginScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// txtWarn - Текст ошибки
|
|
||||||
if (warningText.isNotEmpty()) {
|
if (warningText.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = warningText,
|
text = warningText,
|
||||||
@@ -281,14 +379,12 @@ fun LoginScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Индикатор загрузки или кнопки
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.padding(top = 20.dp),
|
modifier = Modifier.padding(top = 20.dp),
|
||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// btnLogin - Кнопка входа
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onLoginClick,
|
onClick = onLoginClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -308,7 +404,6 @@ fun LoginScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// textView10 - Текст-подсказка
|
|
||||||
Text(
|
Text(
|
||||||
text = "Нет аккаунта? Зарегистрируйтесь!",
|
text = "Нет аккаунта? Зарегистрируйтесь!",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -319,7 +414,6 @@ fun LoginScreenContent(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// btnRegister - Кнопка регистрации
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onRegisterClick,
|
onClick = onRegisterClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -341,8 +435,7 @@ fun LoginScreenContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция валидации (добавь, если её нет)
|
fun validateFields(name: String, password: String, context: android.content.Context): Boolean {
|
||||||
fun validateFields(name: String, password: String, context: Context): Boolean {
|
|
||||||
return when {
|
return when {
|
||||||
name.isBlank() -> {
|
name.isBlank() -> {
|
||||||
Toast.makeText(context, "Введите имя пользователя", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Введите имя пользователя", Toast.LENGTH_SHORT).show()
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package com.example.myserver
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.os.Bundle
|
||||||
import android.widget.Toast
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.example.myserver.crypto.SecretChatManager
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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 javax.inject.Inject
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -36,16 +42,48 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var apiService: MyApiService
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Запускаем WebSocket сервис если есть сессия
|
||||||
|
if (sessionManager.isSessionValid()) {
|
||||||
|
webSocketManager.connect()
|
||||||
|
|
||||||
|
// Проверяем очередь офлайн-сообщений
|
||||||
|
if (offlineMessageQueue.hasMessages()) {
|
||||||
|
Log.d("MainActivity", "Found ${offlineMessageQueue.size()} queued messages")
|
||||||
|
webSocketManager.flushQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем слушатель сети
|
||||||
|
registerNetworkCallback()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
// Простая структура без сложных вложений
|
|
||||||
MainScreenContent(
|
MainScreenContent(
|
||||||
apiService = apiService,
|
apiService = apiService,
|
||||||
|
sessionManager = sessionManager,
|
||||||
|
webSocketManager = webSocketManager,
|
||||||
|
offlineMessageQueue = offlineMessageQueue,
|
||||||
|
secretChatManager = secretChatManager, // 👈 ПЕРЕДАТЬ secretChatManager
|
||||||
onLogout = {
|
onLogout = {
|
||||||
val prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE)
|
webSocketManager.disconnect()
|
||||||
prefs.edit { clear() }
|
sessionManager.clearSession()
|
||||||
startActivity(Intent(this@MainActivity, LogActivity::class.java))
|
startActivity(Intent(this@MainActivity, LogActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -53,54 +91,274 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun registerNetworkCallback() {
|
||||||
|
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
val networkRequest = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
super.onAvailable(network)
|
||||||
|
Log.d("Network", "✅ Network available")
|
||||||
|
// При восстановлении сети пробуем отправить очередь
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (webSocketManager.isConnected()) {
|
||||||
|
webSocketManager.flushQueue()
|
||||||
|
} else {
|
||||||
|
webSocketManager.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
super.onLost(network)
|
||||||
|
Log.d("Network", "❌ Network lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
networkCapabilities: NetworkCapabilities
|
||||||
|
) {
|
||||||
|
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||||
|
val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
Log.d("Network", "Internet capability: $hasInternet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
connectivityManager.registerDefaultNetworkCallback(networkCallback!!)
|
||||||
|
} else {
|
||||||
|
connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Проверяем соединение при возвращении в приложение
|
||||||
|
if (sessionManager.isSessionValid() && !webSocketManager.isConnected()) {
|
||||||
|
webSocketManager.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
// Отключаем слушатель сети
|
||||||
|
networkCallback?.let {
|
||||||
|
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
connectivityManager.unregisterNetworkCallback(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreenContent(
|
||||||
|
apiService: MyApiService,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
webSocketManager: WebSocketManager,
|
||||||
|
offlineMessageQueue: OfflineMessageQueue,
|
||||||
|
secretChatManager: SecretChatManager,
|
||||||
|
onLogout: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var players by remember { mutableStateOf<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 (e: Exception) {
|
||||||
|
errorMessage = "Ошибка сети: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF3E4B54))
|
||||||
|
) {
|
||||||
|
MainHeader(
|
||||||
|
onLogout = onLogout,
|
||||||
|
context = context,
|
||||||
|
connectionStatus = connectionStatus,
|
||||||
|
queuedMessagesCount = offlineMessageQueue.size()
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errorMessage != null -> {
|
||||||
|
Text(
|
||||||
|
text = errorMessage!!,
|
||||||
|
color = Color.Red,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
players.isEmpty() -> {
|
||||||
|
Text(
|
||||||
|
text = "Список игроков пуст",
|
||||||
|
color = Color.Yellow,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(top = 210.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
PlayersList(
|
||||||
|
players = players,
|
||||||
|
context = context,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
currentUsername = currentUsername,
|
||||||
|
onStartChat = { player, isSecret -> // 👈 ДОБАВИТЬ ОБРАБОТЧИК
|
||||||
|
if (isSecret) {
|
||||||
|
// Секретный чат
|
||||||
|
secretChatManager.initiateSecretChat(player.id, player.realname ?: player.username)
|
||||||
|
|
||||||
|
val intent = Intent(context, ChatActivity::class.java).apply {
|
||||||
|
putExtra("RECIPIENT_ID", player.id)
|
||||||
|
putExtra("RECIPIENT_NAME", player.realname ?: player.username)
|
||||||
|
putExtra("IS_SECRET_CHAT", true)
|
||||||
|
putExtra("SECRET_CHAT_ID", "secret_${sessionManager.getUserId()}_${player.id}")
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} else {
|
||||||
|
// Обычный чат
|
||||||
|
val intent = Intent(context, ChatActivity::class.java).apply {
|
||||||
|
putExtra("RECIPIENT_ID", player.id)
|
||||||
|
putExtra("RECIPIENT_NAME", player.realname ?: player.username)
|
||||||
|
putExtra("IS_SECRET_CHAT", false)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreenContent(
|
fun PlayersList(
|
||||||
apiService: MyApiService,
|
players: List<UserResponse>,
|
||||||
onLogout: () -> Unit
|
context: Context,
|
||||||
|
currentUserId: Long,
|
||||||
|
currentUsername: String,
|
||||||
|
onStartChat: (player: UserResponse, isSecret: Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
LazyColumn(
|
||||||
var players by remember { mutableStateOf<List<UserResponse>>(emptyList()) }
|
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// Загружаем данные
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
try {
|
|
||||||
val response = apiService.getPlayers()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
players = response.body() ?: emptyList()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Toast.makeText(context, "Ошибка сети", Toast.LENGTH_SHORT).show()
|
|
||||||
} finally {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Простой Box с фоном
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF3E4B54))
|
.padding(top = 210.dp)
|
||||||
|
.background(Color(0xFF3E4B54)),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Верхняя часть с кнопками
|
items(
|
||||||
MainHeader(
|
items = players.filter { it.username != currentUsername },
|
||||||
onLogout = onLogout,
|
key = { it.id }
|
||||||
context = context
|
) { player ->
|
||||||
)
|
PlayerCard(
|
||||||
|
player = player,
|
||||||
|
onChatClick = { onStartChat(player, false) },
|
||||||
|
onSecretChatClick = { onStartChat(player, true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Список игроков или индикатор загрузки
|
@Composable
|
||||||
if (isLoading) {
|
fun PlayerCard(
|
||||||
CircularProgressIndicator(
|
player: UserResponse,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
onChatClick: () -> Unit,
|
||||||
color = Color.White
|
onSecretChatClick: () -> Unit
|
||||||
)
|
) {
|
||||||
} else {
|
Surface(
|
||||||
PlayersList(
|
modifier = Modifier
|
||||||
players = players,
|
.fillMaxWidth()
|
||||||
context = context
|
.padding(8.dp),
|
||||||
)
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
color = Color(0xFF3E4B54),
|
||||||
|
border = BorderStroke(2.dp, Color.White)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = player.realname ?: player.username,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(
|
||||||
|
color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (player.active) "В игре" else "Офлайн",
|
||||||
|
color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка обычного чата
|
||||||
|
IconButton(onClick = onChatClick) {
|
||||||
|
Text("💬", color = Color(0xFF2196F3), fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка секретного чата
|
||||||
|
IconButton(onClick = onSecretChatClick) {
|
||||||
|
Text("🔐", color = Color(0xFF4CAF50), fontSize = 24.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,39 +366,65 @@ fun MainScreenContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainHeader(
|
fun MainHeader(
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
context: Context
|
context: Context,
|
||||||
|
connectionStatus: String,
|
||||||
|
queuedMessagesCount: Int
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xFF3E4B54))
|
.background(Color(0xFF3E4B54))
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Верхний ряд с меню и заголовком
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Кнопка меню
|
|
||||||
TopMenuButton(
|
TopMenuButton(
|
||||||
modifier = Modifier.size(39.dp, 37.dp),
|
modifier = Modifier.size(39.dp, 37.dp),
|
||||||
onLogout = onLogout
|
onLogout = onLogout
|
||||||
)
|
)
|
||||||
|
|
||||||
// Текст MySERVER
|
Column(
|
||||||
Text(
|
modifier = Modifier
|
||||||
text = "MySERVER",
|
.weight(1f)
|
||||||
color = Color.White,
|
.padding(start = 8.dp)
|
||||||
fontSize = 16.sp,
|
) {
|
||||||
fontStyle = FontStyle.Italic,
|
Text(
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
text = "QuApp",
|
||||||
)
|
color = Color.White,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(
|
||||||
|
color = if (connectionStatus == "Online") Color(0xFF4CAF50) else Color.Red,
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = connectionStatus,
|
||||||
|
color = if (connectionStatus == "Online") Color(0xFF4CAF50) else Color.Red,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
if (queuedMessagesCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = " • $queuedMessagesCount в очереди",
|
||||||
|
color = Color.Yellow,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Изображение img21
|
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.img21),
|
painter = painterResource(id = R.drawable.img21),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -149,11 +433,10 @@ fun MainHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопки меню (Профиль, Карта мира, Информация)
|
|
||||||
MainMenuButtons(
|
MainMenuButtons(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 16.dp),
|
.padding(top = 16.dp, bottom = 16.dp),
|
||||||
context = context
|
context = context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -167,14 +450,17 @@ fun TopMenuButton(
|
|||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
Image(
|
// Убери clickable с Image, используй IconButton
|
||||||
painter = painterResource(id = R.drawable.img6),
|
IconButton(
|
||||||
contentDescription = "Menu",
|
onClick = { expanded = true },
|
||||||
modifier = modifier.clickable(
|
modifier = modifier
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
) {
|
||||||
indication = null
|
Image(
|
||||||
) { expanded = true }
|
painter = painterResource(id = R.drawable.img6),
|
||||||
)
|
contentDescription = "Menu",
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -201,7 +487,6 @@ fun MainMenuButtons(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
// Кнопка Профиль
|
|
||||||
MenuButton(
|
MenuButton(
|
||||||
iconRes = R.drawable.img19,
|
iconRes = R.drawable.img19,
|
||||||
backgroundRes = R.drawable.img16,
|
backgroundRes = R.drawable.img16,
|
||||||
@@ -211,7 +496,6 @@ fun MainMenuButtons(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Кнопка Карта мира
|
|
||||||
MenuButton(
|
MenuButton(
|
||||||
iconRes = R.drawable.img18,
|
iconRes = R.drawable.img18,
|
||||||
backgroundRes = R.drawable.img16,
|
backgroundRes = R.drawable.img16,
|
||||||
@@ -221,7 +505,6 @@ fun MainMenuButtons(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Кнопка Информация
|
|
||||||
MenuButton(
|
MenuButton(
|
||||||
iconRes = R.drawable.img22,
|
iconRes = R.drawable.img22,
|
||||||
backgroundRes = R.drawable.img16,
|
backgroundRes = R.drawable.img16,
|
||||||
@@ -243,29 +526,26 @@ fun MenuButton(
|
|||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
IconButton(
|
||||||
contentAlignment = Alignment.Center,
|
onClick = onClick,
|
||||||
modifier = Modifier.size(90.dp)
|
modifier = Modifier.size(90.dp)
|
||||||
) {
|
) {
|
||||||
// Фон
|
Box(
|
||||||
Image(
|
contentAlignment = Alignment.Center,
|
||||||
painter = painterResource(id = backgroundRes),
|
modifier = Modifier.fillMaxSize()
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Image(
|
||||||
contentScale = ContentScale.Fit
|
painter = painterResource(id = backgroundRes),
|
||||||
)
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Иконка
|
contentScale = ContentScale.Fit
|
||||||
Image(
|
)
|
||||||
painter = painterResource(id = iconRes),
|
Image(
|
||||||
contentDescription = label,
|
painter = painterResource(id = iconRes),
|
||||||
modifier = Modifier
|
contentDescription = label,
|
||||||
.size(60.dp)
|
modifier = Modifier.size(60.dp)
|
||||||
.clickable(
|
)
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
}
|
||||||
indication = null
|
|
||||||
) { onClick() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
@@ -275,83 +555,4 @@ fun MenuButton(
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
package com.example.myserver
|
||||||
|
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.*
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
interface MyApiService {
|
interface MyApiService {
|
||||||
|
@POST("api/auth/register")
|
||||||
|
suspend fun register(@Body userRequest: UserRequest): Response<AuthResponse>
|
||||||
|
|
||||||
@POST("api/auth/login")
|
@POST("api/auth/login")
|
||||||
suspend fun login(@Body request: UserRequest): Response<UserResponse>
|
suspend fun login(@Body userRequest: UserRequest): Response<LoginResponse>
|
||||||
|
|
||||||
@POST("api/auth/register")
|
@GET("api/auth/check-session")
|
||||||
suspend fun register(@Body request: UserRequest): Response<UserResponse>
|
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")
|
@GET("api/auth/players")
|
||||||
suspend fun getPlayers(): Response<List<UserResponse>>
|
suspend fun getPlayers(): Response<List<UserResponse>>
|
||||||
|
|
||||||
@POST("api/auth/update")
|
@GET("api/auth/chat/history/{userId}")
|
||||||
suspend fun updateUser(@Body request: UserRequest): Response<UserResponse>
|
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
|
package com.example.myserver
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import android.util.Log
|
||||||
|
import com.google.firebase.FirebaseApp
|
||||||
@HiltAndroidApp
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
class MyApplication : Application()
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MyApplication : Application() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
lateinit var instance: MyApplication
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
|
||||||
|
FirebaseApp.initializeApp(this)
|
||||||
|
Log.d("FCM", "Firebase инициализирован")
|
||||||
|
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
Log.d("FCM", "🔥 FCM Токен: $token")
|
||||||
|
// TODO: Отправить токен на сервер после логина
|
||||||
|
} else {
|
||||||
|
Log.e("FCM", "❌ Ошибка получения токена", task.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
@Singleton
|
||||||
fun provideApiService(): MyApiService {
|
fun provideApiService(): MyApiService {
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("http://quantumblocks.hopto.org:45678") // Твой URL со слэшем на конце
|
.baseUrl("http://quantumblocks.hopto.org:45678/") // Твой URL со слэшем на конце
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
.create(MyApiService::class.java)
|
.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
|
package com.example.myserver
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Patterns
|
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.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -42,7 +40,7 @@ class RegActivity : ComponentActivity() {
|
|||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
RegistrationScreen(
|
RegistrationScreen(
|
||||||
onNavigateToLogin = {
|
onNavigateToLogin = {
|
||||||
startActivity(Intent(this, MainActivity::class.java))
|
startActivity(Intent(this, LogActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -64,7 +62,6 @@ fun RegistrationScreen(
|
|||||||
|
|
||||||
val authState by viewModel.authState.collectAsState()
|
val authState by viewModel.authState.collectAsState()
|
||||||
|
|
||||||
// Слушатель состояния регистрации
|
|
||||||
LaunchedEffect(authState) {
|
LaunchedEffect(authState) {
|
||||||
when (authState) {
|
when (authState) {
|
||||||
is AuthState.Success -> {
|
is AuthState.Success -> {
|
||||||
@@ -74,25 +71,22 @@ fun RegistrationScreen(
|
|||||||
is AuthState.Error -> {
|
is AuthState.Error -> {
|
||||||
warningText = (authState as AuthState.Error).message
|
warningText = (authState as AuthState.Error).message
|
||||||
}
|
}
|
||||||
else -> { /* Loading or Idle */ }
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrollView аналог с фоном
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF3E4B54)) // android:background="#3E4B54"
|
.background(Color(0xFF3E4B54))
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
// LinearLayout аналог с padding
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp), // android:padding="16dp"
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
// Заголовок "Регистрация" (textView3)
|
|
||||||
Text(
|
Text(
|
||||||
text = "Регистрация",
|
text = "Регистрация",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -104,7 +98,6 @@ fun RegistrationScreen(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// Метка "Придумайте имя" (textView)
|
|
||||||
Text(
|
Text(
|
||||||
text = "Придумайте имя",
|
text = "Придумайте имя",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -115,8 +108,7 @@ fun RegistrationScreen(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// Поле ввода имени (editTextText)
|
CustomRegTextField(
|
||||||
CustomTextField(
|
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
name = it
|
name = it
|
||||||
@@ -129,7 +121,18 @@ fun RegistrationScreen(
|
|||||||
.padding(top = 8.dp)
|
.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(
|
||||||
text = "Придумайте пароль",
|
text = "Придумайте пароль",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -140,8 +143,7 @@ fun RegistrationScreen(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// Поле ввода пароля (editTextTextPassword2)
|
CustomRegTextField(
|
||||||
CustomTextField(
|
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
password = it
|
password = it
|
||||||
@@ -155,7 +157,6 @@ fun RegistrationScreen(
|
|||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Метка "E-mail" (textView9)
|
|
||||||
Text(
|
Text(
|
||||||
text = "E-mail",
|
text = "E-mail",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -166,8 +167,7 @@ fun RegistrationScreen(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// Поле ввода email (editTextEmail)
|
CustomRegTextField(
|
||||||
CustomTextField(
|
|
||||||
value = email,
|
value = email,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
email = it
|
email = it
|
||||||
@@ -181,7 +181,6 @@ fun RegistrationScreen(
|
|||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Текст ошибки (txtWarn)
|
|
||||||
if (warningText.isNotEmpty()) {
|
if (warningText.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = warningText,
|
text = warningText,
|
||||||
@@ -200,16 +199,12 @@ fun RegistrationScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Кнопка регистрации (btnRegister)
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (validateRegFields(name, email, password, context,
|
if (validateRegFields(name, email, password,
|
||||||
onError = { warningText = it }
|
onError = { warningText = it }
|
||||||
)) {
|
)) {
|
||||||
viewModel.authUser(
|
viewModel.register(UserRequest(username = name, password = password, email = email))
|
||||||
UserRequest(username = name, password = password, email = email),
|
|
||||||
isRegistration = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -218,18 +213,17 @@ fun RegistrationScreen(
|
|||||||
.padding(top = 16.dp, bottom = 20.dp)
|
.padding(top = 16.dp, bottom = 20.dp)
|
||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFF4CAF50) // Зеленый цвет из XML
|
containerColor = Color(0xFF4CAF50)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Зарегистрироваться", // Исправил опечатку "Зарегестрироваться"
|
text = "Зарегистрироваться",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Текст-подсказка (textView11)
|
|
||||||
Text(
|
Text(
|
||||||
text = "Уже есть аккаунт? Войдите!",
|
text = "Уже есть аккаунт? Войдите!",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
@@ -240,7 +234,6 @@ fun RegistrationScreen(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
// Кнопка входа (btnLogin)
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onNavigateToLogin,
|
onClick = onNavigateToLogin,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -249,7 +242,7 @@ fun RegistrationScreen(
|
|||||||
.padding(top = 16.dp, bottom = 20.dp)
|
.padding(top = 16.dp, bottom = 20.dp)
|
||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFF2196F3) // Синий цвет из XML
|
containerColor = Color(0xFF2196F3)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
@@ -265,7 +258,7 @@ fun RegistrationScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CustomTextField(
|
fun CustomRegTextField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
@@ -301,9 +294,9 @@ fun CustomTextField(
|
|||||||
if (value.isEmpty()) {
|
if (value.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = placeholder,
|
text = placeholder,
|
||||||
color = Color(0xFF666666), // android:textColorHint="#666666"
|
color = Color(0xFF666666),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontStyle = FontStyle.Italic // android:textStyle="italic"
|
fontStyle = FontStyle.Italic
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
innerTextField()
|
innerTextField()
|
||||||
@@ -321,35 +314,29 @@ private fun validateRegFields(
|
|||||||
name: String,
|
name: String,
|
||||||
email: String,
|
email: String,
|
||||||
pass: String,
|
pass: String,
|
||||||
context: Context,
|
|
||||||
onError: (String) -> Unit
|
onError: (String) -> Unit
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (name.isEmpty() || pass.isEmpty() || email.isEmpty()) {
|
if (name.isEmpty() || pass.isEmpty() || email.isEmpty()) {
|
||||||
onError("Заполните все поля!")
|
onError("Заполните все поля!")
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pass.length < 8) {
|
if (pass.length < 8) {
|
||||||
onError("Пароль должен быть минимум 8 символов")
|
onError("Пароль должен быть минимум 8 символов")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
|
if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
|
||||||
onError("Некорректный Email")
|
onError("Некорректный Email")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Предпросмотр для разработки
|
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 id: Long,
|
||||||
val username: String,
|
val username: String,
|
||||||
val realname: String? = null,
|
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.
|
// Top-level build file where you can add configuration options common to all subprojects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
id("org.jetbrains.kotlin.android") 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.51.1" apply false
|
id("com.google.dagger.hilt.android") version "2.55" apply false
|
||||||
id("org.jetbrains.kotlin.kapt") version "1.9.0" 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]
|
[versions]
|
||||||
agp = "8.13.2"
|
agp = "8.13.2"
|
||||||
kotlin = "1.9.0"
|
kotlin = "2.1.0"
|
||||||
hilt = "2.51.1"
|
hilt = "2.55"
|
||||||
composeBom = "2023.10.01"
|
composeBom = "2024.10.01"
|
||||||
retrofit = "2.9.0"
|
retrofit = "2.9.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
activityCompose = "1.8.2"
|
activityCompose = "1.8.2"
|
||||||
@@ -13,6 +13,7 @@ espressoCore = "3.5.1"
|
|||||||
appcompat = "1.6.1"
|
appcompat = "1.6.1"
|
||||||
material = "1.11.0"
|
material = "1.11.0"
|
||||||
foundation = "1.10.4"
|
foundation = "1.10.4"
|
||||||
|
playServicesCastFramework = "22.3.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
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" }
|
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
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" }
|
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 {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google {
|
google()
|
||||||
content {
|
|
||||||
includeGroupByRegex("com\\.android.*")
|
|
||||||
includeGroupByRegex("com\\.google.*")
|
|
||||||
includeGroupByRegex("androidx.*")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
|
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user