Change 25 04 2026 add chats

This commit is contained in:
MiDupl1711
2026-04-25 01:14:39 +03:00
parent d69fe7e21f
commit fb07f28fd1
47 changed files with 5272 additions and 356 deletions
+26
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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")
+29
View File
@@ -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"
}
+161
View File
@@ -19,3 +19,164 @@
# 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>;
}
+39 -3
View File
@@ -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 { } else {
apiService.login(request) _authState.value = AuthState.Error(result?.message ?: "Ошибка регистрации")
}
} else {
_authState.value = AuthState.Error("Ошибка сервера: ${response.code()}")
}
} catch (e: IOException) {
_authState.value = AuthState.Error("Нет соединения с сервером")
} catch (e: Exception) {
_authState.value = AuthState.Error("Неизвестная ошибка")
}
}
} }
if (response.isSuccessful && response.body() != null) { fun login(userRequest: UserRequest, deviceInfo: DeviceInfo, onSuccess: () -> Unit, onError: (String) -> Unit) {
_authState.value = AuthState.Success(response.body()!!) 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 { } else {
_authState.value = AuthState.Error("Ошибка: ${response.code()}") onError(result?.message ?: "Ошибка входа")
} }
} else {
onError("Ошибка сервера: ${response.code()}")
}
} catch (e: IOException) {
onError("Нет соединения с сервером")
} catch (e: HttpException) {
onError("Ошибка сервера")
} catch (e: Exception) { } catch (e: Exception) {
_authState.value = AuthState.Error("Сеть недоступна: ${e.message}") 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 -> {}
} }
} }
if (isCheckingAutoLogin) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color.White)
}
} else {
LoginScreenContent( LoginScreenContent(
name = name, name = name,
onNameChange = { onNameChange = { name = it; warningText = "" },
name = it
warningText = "" // Сбрасываем ошибку при вводе
},
password = password, password = password,
onPasswordChange = { onPasswordChange = { password = it; warningText = "" },
password = it
warningText = "" // Сбрасываем ошибку при вводе
},
warningText = warningText, warningText = warningText,
isLoading = authState is AuthState.Loading, isLoading = authState is AuthState.Loading,
onLoginClick = { onLoginClick = {
if (validateFields(name, password, context)) { if (validateFields(name, password, context)) {
viewModel.authUser(UserRequest(name, password), isRegistration = false) val deviceInfo = sessionManager.getDeviceInfo()
viewModel.login(
UserRequest(name, password),
deviceInfo,
onSuccess = {},
onError = { warningText = it }
)
} }
}, },
onRegisterClick = { onRegisterClick = {
context.startActivity(Intent(context, RegActivity::class.java)) context.startActivity(Intent(context, RegActivity::class.java))
(context as LogActivity).finish()
} }
) )
}
} }
@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,94 +91,340 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
private fun registerNetworkCallback() {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("Network", "✅ Network available")
// При восстановлении сети пробуем отправить очередь
lifecycleScope.launch {
if (webSocketManager.isConnected()) {
webSocketManager.flushQueue()
} else {
webSocketManager.connect()
}
}
}
override fun onLost(network: Network) {
super.onLost(network)
Log.d("Network", "❌ Network lost")
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
Log.d("Network", "Internet capability: $hasInternet")
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.registerDefaultNetworkCallback(networkCallback!!)
} else {
connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!)
}
}
override fun onResume() {
super.onResume()
// Проверяем соединение при возвращении в приложение
if (sessionManager.isSessionValid() && !webSocketManager.isConnected()) {
webSocketManager.connect()
}
}
override fun onDestroy() {
super.onDestroy()
// Отключаем слушатель сети
networkCallback?.let {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.unregisterNetworkCallback(it)
}
}
} }
@Composable @Composable
fun MainScreenContent( fun MainScreenContent(
apiService: MyApiService, apiService: MyApiService,
sessionManager: SessionManager,
webSocketManager: WebSocketManager,
offlineMessageQueue: OfflineMessageQueue,
secretChatManager: SecretChatManager,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var players by remember { mutableStateOf<List<UserResponse>>(emptyList()) } var players by remember { mutableStateOf<List<UserResponse>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } 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) { LaunchedEffect(Unit) {
while (true) {
connectionStatus = if (webSocketManager.isConnected()) "Online" else "Offline"
kotlinx.coroutines.delay(1000)
}
}
// Загружаем список игроков
LaunchedEffect(Unit) {
isLoading = true
errorMessage = null
try { try {
val response = apiService.getPlayers() val response = apiService.getPlayers()
if (response.isSuccessful) { if (response.isSuccessful) {
players = response.body() ?: emptyList() players = response.body() ?: emptyList()
Log.d("MAIN", "Загружено игроков: ${players.size}")
} else {
errorMessage = "Ошибка: ${response.code()}"
} }
} catch (_: Exception) { } catch (e: Exception) {
Toast.makeText(context, "Ошибка сети", Toast.LENGTH_SHORT).show() errorMessage = "Ошибка сети: ${e.message}"
} finally { } finally {
isLoading = false isLoading = false
} }
} }
// Простой Box с фоном
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF3E4B54)) .background(Color(0xFF3E4B54))
) { ) {
// Верхняя часть с кнопками
MainHeader( MainHeader(
onLogout = onLogout, onLogout = onLogout,
context = context context = context,
connectionStatus = connectionStatus,
queuedMessagesCount = offlineMessageQueue.size()
) )
// Список игроков или индикатор загрузки when {
if (isLoading) { isLoading -> {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
color = Color.White color = Color.White
) )
} else { }
errorMessage != null -> {
Text(
text = errorMessage!!,
color = Color.Red,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
)
}
players.isEmpty() -> {
Text(
text = "Список игроков пуст",
color = Color.Yellow,
fontSize = 18.sp,
modifier = Modifier
.align(Alignment.Center)
.padding(top = 210.dp)
)
}
else -> {
PlayersList( PlayersList(
players = players, players = players,
context = context context = context,
currentUserId = currentUserId,
currentUsername = currentUsername,
onStartChat = { player, isSecret -> // 👈 ДОБАВИТЬ ОБРАБОТЧИК
if (isSecret) {
// Секретный чат
secretChatManager.initiateSecretChat(player.id, player.realname ?: player.username)
val intent = Intent(context, ChatActivity::class.java).apply {
putExtra("RECIPIENT_ID", player.id)
putExtra("RECIPIENT_NAME", player.realname ?: player.username)
putExtra("IS_SECRET_CHAT", true)
putExtra("SECRET_CHAT_ID", "secret_${sessionManager.getUserId()}_${player.id}")
}
context.startActivity(intent)
} else {
// Обычный чат
val intent = Intent(context, ChatActivity::class.java).apply {
putExtra("RECIPIENT_ID", player.id)
putExtra("RECIPIENT_NAME", player.realname ?: player.username)
putExtra("IS_SECRET_CHAT", false)
}
context.startActivity(intent)
}
}
) )
} }
} }
}
}
@Composable
fun PlayersList(
players: List<UserResponse>,
context: Context,
currentUserId: Long,
currentUsername: String,
onStartChat: (player: UserResponse, isSecret: Boolean) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 210.dp)
.background(Color(0xFF3E4B54)),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = players.filter { it.username != currentUsername },
key = { it.id }
) { player ->
PlayerCard(
player = player,
onChatClick = { onStartChat(player, false) },
onSecretChatClick = { onStartChat(player, true) }
)
}
}
}
@Composable
fun PlayerCard(
player: UserResponse,
onChatClick: () -> Unit,
onSecretChatClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = RoundedCornerShape(20.dp),
shadowElevation = 4.dp,
color = Color(0xFF3E4B54),
border = BorderStroke(2.dp, Color.White)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = player.realname ?: player.username,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
shape = RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (player.active) "В игре" else "Офлайн",
color = if (player.active) Color(0xFF4CAF50) else Color.Gray,
fontSize = 12.sp
)
}
}
// Кнопка обычного чата
IconButton(onClick = onChatClick) {
Text("💬", color = Color(0xFF2196F3), fontSize = 24.sp)
}
// Кнопка секретного чата
IconButton(onClick = onSecretChatClick) {
Text("🔐", color = Color(0xFF4CAF50), fontSize = 24.sp)
}
}
}
} }
@Composable @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(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text( Text(
text = "MySERVER", text = "QuApp",
color = Color.White, color = Color.White,
fontSize = 16.sp, fontSize = 16.sp,
fontStyle = FontStyle.Italic, fontWeight = FontWeight.Bold
modifier = Modifier.padding(start = 8.dp) )
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 {
// Убери clickable с Image, используй IconButton
IconButton(
onClick = { expanded = true },
modifier = modifier
) {
Image( Image(
painter = painterResource(id = R.drawable.img6), painter = painterResource(id = R.drawable.img6),
contentDescription = "Menu", contentDescription = "Menu",
modifier = modifier.clickable( modifier = Modifier.fillMaxSize()
interactionSource = remember { MutableInteractionSource() },
indication = null
) { expanded = true }
) )
}
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,30 +526,27 @@ 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(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Image( Image(
painter = painterResource(id = backgroundRes), painter = painterResource(id = backgroundRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
) )
// Иконка
Image( Image(
painter = painterResource(id = iconRes), painter = painterResource(id = iconRes),
contentDescription = label, contentDescription = label,
modifier = Modifier modifier = Modifier.size(60.dp)
.size(60.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClick() }
) )
} }
}
Text( Text(
text = label, text = label,
@@ -276,82 +556,3 @@ fun MenuButton(
) )
} }
} }
@Composable
fun PlayersList(
players: List<UserResponse>,
context: Context
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 210.dp) // Отступ для заголовка
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.background(Color(0xFF2F4F4F)),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = players // Добавляем ключ для стабильности
) { player ->
PlayerCard(
player = player,
onClick = {
val intent = Intent(context, ProfileActivity::class.java).apply {
putExtra("PLAYER_NAME", player.realname ?: player.username)
}
context.startActivity(intent)
}
)
}
}
}
@Composable
fun PlayerCard(
player: UserResponse,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClick() },
shape = RoundedCornerShape(20.dp),
border = BorderStroke(2.dp, Color.LightGray),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF3E4B54)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
player.realname?.let {
Text(
text = it,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = "Активен",
color = Color(0xFF4CAF50),
fontSize = 12.sp
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "",
color = Color.White,
fontSize = 20.sp
)
}
}
}
@@ -0,0 +1,108 @@
package com.example.myserver.services
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class MediaCacheManager @Inject constructor(
private val context: Context
) {
companion object {
private const val MAX_CACHE_SIZE_MB = 500L // Максимальный размер кэша 500MB
}
/**
* Загрузка изображения с кэшированием
*/
suspend fun loadImageWithCache(url: String): Bitmap? = suspendCancellableCoroutine { continuation ->
Glide.with(context)
.asBitmap()
.load(url)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
continuation.resume(resource)
}
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
// Nothing to do
}
override fun onLoadFailed(errorDrawable: android.graphics.drawable.Drawable?) {
continuation.resumeWithException(Exception("Failed to load image"))
}
})
}
/**
* Очистка кэша Glide
*/
fun clearGlideCache() {
Glide.get(context).clearDiskCache()
Glide.get(context).clearMemory()
}
/**
* Получение размера кэша Glide
*/
fun getGlideCacheSize(): Long {
val glideCacheDir = File(context.cacheDir, "image_manager_disk_cache")
return if (glideCacheDir.exists()) {
glideCacheDir.walkTopDown().sumOf { it.length() }
} else {
0
}
}
/**
* Общая очистка кэша
*/
fun clearAllCache() {
clearGlideCache()
Glide.get(context).clearDiskCache()
val cacheDir = context.cacheDir
cacheDir.listFiles()?.forEach { file ->
if (file.isDirectory) {
file.deleteRecursively()
} else {
file.delete()
}
}
}
/**
* Получение общего размера кэша
*/
fun getTotalCacheSize(): Long {
var size = 0L
val cacheDir = context.cacheDir
cacheDir.listFiles()?.forEach { file ->
if (file.isDirectory) {
size += file.walkTopDown().sumOf { it.length() }
} else {
size += file.length()
}
}
return size
}
/**
* Проверка и очистка при превышении лимита
*/
fun checkAndCleanCache() {
if (getTotalCacheSize() > MAX_CACHE_SIZE_MB * 1024 * 1024) {
clearAllCache()
}
}
}
@@ -0,0 +1,143 @@
package com.example.myserver.services
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaCompressionService @Inject constructor(
private val context: Context
) {
companion object {
private const val MAX_IMAGE_SIZE_KB = 500L
private const val MAX_IMAGE_WIDTH = 1280
private const val MAX_IMAGE_HEIGHT = 1280
}
private val _compressionProgress = MutableStateFlow<Map<Long, Int>>(emptyMap())
val compressionProgress: StateFlow<Map<Long, Int>> = _compressionProgress
suspend fun compressImage(sourceUri: Uri, messageId: Long): File? = withContext(Dispatchers.IO) {
try {
updateProgress(messageId, 0)
val contentResolver: ContentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(sourceUri)
val originalBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (originalBitmap == null) {
return@withContext null
}
updateProgress(messageId, 30)
val scaledBitmap = scaleBitmap(originalBitmap, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT)
originalBitmap.recycle()
updateProgress(messageId, 60)
val compressedFile = File(context.cacheDir, "compressed_img_${messageId}.jpg")
FileOutputStream(compressedFile).use { outputStream ->
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, outputStream)
}
scaledBitmap.recycle()
var finalFile = compressedFile
if (finalFile.length() > MAX_IMAGE_SIZE_KB * 1024) {
val quality = ((MAX_IMAGE_SIZE_KB * 1024 * 100) / finalFile.length()).coerceIn(30, 90).toInt()
finalFile = compressFileFurther(finalFile, quality)
compressedFile.delete()
}
updateProgress(messageId, 100)
return@withContext finalFile
} catch (e: Exception) {
e.printStackTrace()
return@withContext null
}
}
suspend fun generateVideoThumbnail(videoUri: Uri, messageId: Long): File? = withContext(Dispatchers.IO) {
try {
val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(videoUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext null
val thumbnailFile = File(context.cacheDir, "thumbnail_${messageId}.jpg")
FileOutputStream(thumbnailFile).use { outputStream ->
val scaledBitmap = scaleBitmap(bitmap, 320, 320)
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
scaledBitmap.recycle()
}
bitmap.recycle()
return@withContext thumbnailFile
} catch (e: Exception) {
e.printStackTrace()
return@withContext null
}
}
private fun scaleBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
if (width <= maxWidth && height <= maxHeight) {
return bitmap
}
val ratio = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height)
val newWidth = (width * ratio).toInt()
val newHeight = (height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
private fun compressFileFurther(file: File, quality: Int): File {
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val compressedFile = File(context.cacheDir, "compressed_img_final_${System.currentTimeMillis()}.jpg")
FileOutputStream(compressedFile).use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
}
bitmap.recycle()
return compressedFile
}
private fun updateProgress(messageId: Long, progress: Int) {
_compressionProgress.value = _compressionProgress.value.toMutableMap().apply {
this[messageId] = progress
}
}
fun clearMediaCache() {
val cacheDir = context.cacheDir
cacheDir.listFiles()?.forEach { file ->
if (file.name.startsWith("compressed_") || file.name.startsWith("thumbnail_")) {
file.delete()
}
}
}
fun getMediaCacheSize(): Long {
val cacheDir = context.cacheDir
return cacheDir.listFiles()?.filter { file ->
file.name.startsWith("compressed_") || file.name.startsWith("thumbnail_")
}?.sumOf { it.length() } ?: 0L
}
}
@@ -0,0 +1,63 @@
package com.example.myserver
import androidx.room.*
@Entity(
tableName = "media",
indices = [
Index(value = ["message_id"], name = "idx_media_message"),
Index(value = ["local_path"], name = "idx_media_local")
]
)
data class MediaEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "message_id")
val messageId: Long,
@ColumnInfo(name = "media_type")
val mediaType: String,
@ColumnInfo(name = "local_path")
val localPath: String,
@ColumnInfo(name = "remote_url")
val remoteUrl: String? = null,
@ColumnInfo(name = "thumbnail_path")
val thumbnailPath: String? = null,
@ColumnInfo(name = "file_size")
val fileSize: Long,
@ColumnInfo(name = "width")
val width: Int = 0,
@ColumnInfo(name = "height")
val height: Int = 0,
@ColumnInfo(name = "duration")
val duration: Long = 0,
@ColumnInfo(name = "mime_type")
val mimeType: String,
@ColumnInfo(name = "is_uploaded")
val isUploaded: Boolean = false
)
@Dao
interface MediaDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMedia(media: MediaEntity)
@Query("SELECT * FROM media WHERE message_id = :messageId")
suspend fun getMediaForMessage(messageId: Long): MediaEntity?
@Query("DELETE FROM media WHERE message_id = :messageId")
suspend fun deleteMediaForMessage(messageId: Long)
@Query("DELETE FROM media WHERE is_uploaded = 1 AND file_size > 0")
suspend fun deleteUploadedMedia()
}
@@ -0,0 +1,23 @@
package com.example.myserver
data class MediaMessage(
val id: Long,
val messageId: Long,
val mediaType: MediaType,
val localPath: String, // Локальный путь к файлу
val remoteUrl: String?, // URL на сервере
val thumbnailPath: String?, // Путь к миниатюре
val fileSize: Long, // Размер в байтах
val width: Int = 0,
val height: Int = 0,
val duration: Long = 0, // Для видео/аудио (в секундах)
val mimeType: String,
val isUploaded: Boolean = false
)
data class MediaUploadProgress(
val messageId: Long,
val progress: Int, // 0-100
val isComplete: Boolean
)
@@ -0,0 +1,8 @@
package com.example.myserver
enum class MediaType {
IMAGE,
VIDEO,
AUDIO,
DOCUMENT
}
@@ -0,0 +1,74 @@
package com.example.myserver
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
suspend fun getMessagesForChatPaginated(chatId: String, limit: Int, offset: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp DESC LIMIT :limit")
suspend fun getLastMessages(chatId: String, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chat_id = :chatId AND timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit")
suspend fun getMessagesBeforeTimestamp(chatId: String, beforeTimestamp: Long, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chat_id = :chatId AND timestamp > :afterTimestamp ORDER BY timestamp ASC")
suspend fun getMessagesAfterTimestamp(chatId: String, afterTimestamp: Long): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC")
suspend fun getAllMessagesForChat(chatId: String): List<MessageEntity>
@Query("SELECT * FROM messages WHERE id = :messageId")
suspend fun getMessageById(messageId: Long): MessageEntity?
@Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId")
suspend fun getMessageCount(chatId: String): Int
@Query("SELECT * FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0 ORDER BY timestamp ASC")
suspend fun getUnreadMessages(userId: Long): List<MessageEntity>
@Query("SELECT COUNT(*) FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0")
suspend fun getUnreadCount(userId: Long): Int
@Query("SELECT * FROM messages WHERE recipient_id = :userId AND status = 'SENT' AND is_deleted = 0")
suspend fun getUndeliveredMessages(userId: Long): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Query("UPDATE messages SET status = :status WHERE id = :messageId")
suspend fun updateMessageStatus(messageId: Long, status: String)
@Query("UPDATE messages SET is_read = 1, read_at = :readAt WHERE id = :messageId")
suspend fun markAsRead(messageId: Long, readAt: Long)
@Query("UPDATE messages SET status = 'DELIVERED', delivered_at = :deliveredAt WHERE id = :messageId")
suspend fun markAsDelivered(messageId: Long, deliveredAt: Long)
@Query("UPDATE messages SET content = :newContent, is_edited = 1, edited_content = :oldContent WHERE id = :messageId")
suspend fun updateMessageContent(messageId: Long, newContent: String, oldContent: String)
@Query("UPDATE messages SET is_deleted = 1, content = 'Сообщение удалено' WHERE id = :messageId")
suspend fun markAsDeleted(messageId: Long)
@Query("DELETE FROM messages WHERE timestamp < :beforeTimestamp")
suspend fun deleteOldMessages(beforeTimestamp: Long)
@Query("DELETE FROM messages WHERE chat_id = :chatId")
suspend fun deleteChatMessages(chatId: String)
@Query("SELECT * FROM messages WHERE status = :status AND is_deleted = 0 ORDER BY timestamp ASC")
suspend fun getMessagesByStatus(status: String): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chat_id = :chatId ORDER BY timestamp ASC")
fun observeMessages(chatId: String): Flow<List<MessageEntity>>
@Query("SELECT COUNT(*) FROM messages WHERE recipient_id = :userId AND is_read = 0 AND is_deleted = 0")
fun observeUnreadCount(userId: Long): Flow<Int>
}
@@ -0,0 +1,100 @@
package com.example.myserver
import androidx.room.*
@Entity(
tableName = "messages",
indices = [
Index(value = ["chat_id", "timestamp"], name = "idx_chat_timestamp"),
Index(value = ["sender_id"], name = "idx_sender"),
Index(value = ["recipient_id"], name = "idx_recipient"),
Index(value = ["timestamp"], name = "idx_timestamp"),
Index(value = ["status"], name = "idx_status")
]
)
data class MessageEntity(
@PrimaryKey
val id: Long,
@ColumnInfo(name = "sender_id")
val senderId: Long,
@ColumnInfo(name = "sender_name")
val senderName: String,
@ColumnInfo(name = "recipient_id")
val recipientId: Long,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "timestamp")
val timestamp: Long,
@ColumnInfo(name = "is_read")
val isRead: Boolean,
@ColumnInfo(name = "chat_id")
val chatId: String,
@ColumnInfo(name = "status")
val status: String = "PENDING",
@ColumnInfo(name = "delivered_at")
val deliveredAt: Long? = null,
@ColumnInfo(name = "read_at")
val readAt: Long? = null,
@ColumnInfo(name = "is_edited")
val isEdited: Boolean = false,
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
@ColumnInfo(name = "edited_content")
val editedContent: String? = null
) {
fun toChatMessage(): ChatMessage {
return ChatMessage(
id = id,
senderId = senderId,
senderName = senderName,
recipientId = recipientId,
content = if (isDeleted) "Сообщение удалено" else content,
timestamp = emptyList(),
status = try {
MessageStatus.valueOf(status)
} catch (e: Exception) {
MessageStatus.SENT
},
read = isRead,
deliveredAt = deliveredAt,
readAt = readAt,
isEdited = isEdited,
isDeleted = isDeleted,
originalContent = editedContent
)
}
companion object {
fun fromChatMessage(message: ChatMessage, chatId: String): MessageEntity {
return MessageEntity(
id = message.id,
senderId = message.senderId,
senderName = message.senderName,
recipientId = message.recipientId,
content = message.content,
timestamp = System.currentTimeMillis(),
isRead = message.read,
chatId = chatId,
status = message.status.name,
deliveredAt = message.deliveredAt,
readAt = message.readAt,
isEdited = message.isEdited,
isDeleted = message.isDeleted,
editedContent = message.originalContent
)
}
}
}
@@ -1,22 +1,81 @@
package com.example.myserver 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 android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp @HiltAndroidApp
class MyApplication : Application() class MyApplication : Application() {
companion object {
lateinit var instance: MyApplication
private set
}
override fun onCreate() {
super.onCreate()
instance = this
FirebaseApp.initializeApp(this)
Log.d("FCM", "Firebase инициализирован")
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
Log.d("FCM", "🔥 FCM Токен: $token")
// TODO: Отправить токен на сервер после логина
} else {
Log.e("FCM", "❌ Ошибка получения токена", task.exception)
}
}
}
}
@@ -0,0 +1,157 @@
package com.example.myserver
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MyFirebaseMessagingService : FirebaseMessagingService() {
companion object {
private const val CHANNEL_ID = "chat_channel"
private const val TAG = "FCM"
var tokenSentToServer = false
}
@Inject
lateinit var sessionManager: SessionManager
@Inject
lateinit var apiService: MyApiService
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "🔥 New FCM token: $token")
// Отправляем токен на сервер
sendTokenToServer(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d(TAG, "📨 Received message: ${remoteMessage.data}")
val data = remoteMessage.data
val type = data["type"] ?: "new_message"
when (type) {
"new_message" -> handleNewMessage(data)
"message_edited" -> handleMessageEdited(data)
"message_deleted" -> handleMessageDeleted(data)
else -> handleNewMessage(data)
}
}
private fun handleNewMessage(data: Map<String, String>) {
val senderName = data["senderName"] ?: "Игрок"
val content = data["content"] ?: "Новое сообщение"
val senderId = data["senderId"]?.toLongOrNull() ?: 0
val messageId = data["messageId"]?.toLongOrNull() ?: 0
// Показываем уведомление
showNotification(senderName, content, senderId, messageId)
}
private fun handleMessageEdited(data: Map<String, String>) {
val messageId = data["messageId"]?.toLongOrNull() ?: 0
val newContent = data["content"] ?: ""
Log.d(TAG, "Message $messageId edited: $newContent")
// Можно показать уведомление о редактировании
showNotification("Редактирование", "Сообщение изменено: $newContent", 0, messageId)
}
private fun handleMessageDeleted(data: Map<String, String>) {
val messageId = data["messageId"]?.toLongOrNull() ?: 0
Log.d(TAG, "Message $messageId deleted")
}
private fun showNotification(senderName: String, message: String, senderId: Long, messageId: Long) {
createNotificationChannel()
val intent = Intent(this, ChatActivity::class.java).apply {
putExtra("RECIPIENT_ID", senderId)
putExtra("RECIPIENT_NAME", senderName)
putExtra("MESSAGE_ID", messageId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, senderId.toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("✉️ $senderName")
.setContentText(message)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.build()
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(senderId.toInt(), notification)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Чат",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Новые сообщения в чате"
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun sendTokenToServer(token: String) {
val sessionKey = sessionManager.getSessionKey()
if (sessionKey == null) {
Log.d(TAG, "No session key, will send token after login")
return
}
// Отправляем токен на сервер
val request = FcmTokenRequest(
token = token,
deviceId = Settings.Secure.getString(
contentResolver,
Settings.Secure.ANDROID_ID
)
)
// Используем coroutine для отправки
kotlinx.coroutines.GlobalScope.launch {
try {
val response = apiService.registerFcmToken(sessionKey, request)
if (response.isSuccessful) {
tokenSentToServer = true
Log.d(TAG, "✅ FCM token sent to server successfully")
} else {
Log.e(TAG, "❌ Failed to send token: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "❌ Error sending token: ${e.message}")
}
}
}
}
@@ -16,7 +16,7 @@ object NetworkModule {
@Singleton @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)
}
}
+10
View File
@@ -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>
+5
View File
@@ -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
View File
@@ -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
} }
+7 -4
View File
@@ -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
View File
@@ -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") }
} }
} }