diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 80aac07f..39128ba6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,7 @@
-import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+@file:Suppress("UnstableApiUsage")
+
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage", "\"\"")
val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint", "\"\"")
@@ -17,13 +19,14 @@ plugins {
id("kotlin-android")
id("kotlin-kapt")
id("kotlin-parcelize")
- id("dagger.hilt.android.plugin")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp")
}
android {
namespace = "com.meloda.fast"
- compileSdk = 32
+ compileSdk = 34
applicationVariants.all {
outputs.all {
@@ -34,14 +37,14 @@ android {
defaultConfig {
applicationId = "com.meloda.fast"
- minSdk = 23
- targetSdk = 32
+ minSdk = 24
+ targetSdk = 34
versionCode = 1
versionName = "alpha"
javaCompileOptions {
annotationProcessorOptions {
- arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
+// arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
}
}
}
@@ -73,18 +76,51 @@ android {
}
}
+ val flavorDimension = "version"
+
+ flavorDimensions += flavorDimension
+
+ productFlavors {
+ create("dev") {
+ resourceConfigurations += listOf("en", "xxhdpi")
+
+ dimension = flavorDimension
+ applicationIdSuffix = ".dev"
+ versionNameSuffix = "-dev"
+ }
+ create("full") {
+ dimension = flavorDimension
+ }
+ }
+
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn")
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
viewBinding = true
+ compose = true
}
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.4.5"
+ useLiveLiterals = true
+ }
+ packagingOptions {
+ jniLibs {
+ useLegacyPackaging = false
+ }
+ }
+}
+
+kapt {
+ correctErrorTypes = true
}
fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
@@ -92,74 +128,99 @@ fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
val currentTime get() = (System.currentTimeMillis() / 1000).toInt()
dependencies {
- implementation(kotlin("reflect", "1.6.10"))
- implementation(libs.androidx.core)
- implementation(libs.androidx.lifecycle.viewmodel)
- implementation(libs.androidx.lifecycle.livedata)
- implementation(libs.androidx.lifecycle.runtime)
- implementation(libs.androidx.lifecycle.viewmodel.savedstate)
- implementation(libs.androidx.lifecycle.common.java8)
+ // DI zone
+ implementation("io.insert-koin:koin-android:3.4.0")
+ // end of DI zone
- implementation(libs.androidx.splashScreen)
+ implementation("com.github.skydoves:cloudy:0.1.2")
- implementation(libs.androidx.dataStore)
+ implementation("io.coil-kt:coil-compose:2.3.0")
+ implementation("io.coil-kt:coil:2.3.0")
- implementation(libs.androidx.appCompat)
+ implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2")
+ implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2")
- implementation(libs.androidx.activity)
+ implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.21")
- implementation(libs.androidx.fragment)
+ implementation("androidx.core:core-ktx:1.10.1")
- implementation(libs.androidx.preference)
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
- implementation(libs.androidx.swipeRefreshLayout)
+ implementation("androidx.core:core-splashscreen:1.0.1")
- implementation(libs.androidx.recyclerView)
+ implementation("androidx.appcompat:appcompat:1.6.1")
- implementation(libs.androidx.cardView)
+ implementation("androidx.activity:activity-ktx:1.7.2")
- implementation(libs.androidx.constraintLayout)
+ implementation("androidx.fragment:fragment-ktx:1.6.1")
- implementation(libs.androidx.room)
- implementation(libs.androidx.room.runtime)
- kapt(libs.androidx.room.compiler)
+ implementation("androidx.preference:preference-ktx:1.2.0")
- implementation(libs.cicerone)
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
- implementation(libs.waveformSeekBar)
+ implementation("androidx.recyclerview:recyclerview:1.3.1")
- implementation(libs.glide)
- kapt(libs.glide.compiler)
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation(libs.kPermissions)
- implementation(libs.kPermissions.coroutines)
+ implementation("com.google.accompanist:accompanist-systemuicontroller:0.27.0")
- implementation(libs.appCenter.analytics)
- implementation(libs.appCenter.crashes)
+ implementation("androidx.room:room-ktx:2.5.2")
+ implementation("androidx.room:room-runtime:2.5.2")
+ ksp("androidx.room:room-compiler:2.5.2")
- implementation(libs.hilt)
- kapt(libs.hilt.compiler)
+ implementation("com.github.terrakok:cicerone:7.1")
- implementation(libs.retrofit)
- implementation(libs.retrofit.gson.converter)
+ implementation("com.github.massoudss:waveformSeekBar:5.0.0")
- implementation(libs.okhttp3)
- implementation(libs.okhttp3.interceptor)
+ implementation("com.github.bumptech.glide:glide:4.15.1")
+ ksp("com.github.bumptech.glide:compiler:4.15.1")
- implementation(libs.coroutines.core)
- implementation(libs.coroutines.android)
+ implementation("com.github.fondesa:kpermissions:3.4.0")
+ implementation("com.github.fondesa:kpermissions-coroutines:3.4.0")
- implementation(libs.viewBindingDelegate)
+ implementation("com.microsoft.appcenter:appcenter-analytics:5.0.1")
+ implementation("com.microsoft.appcenter:appcenter-crashes:5.0.1")
- implementation(libs.google.gson)
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
- implementation(libs.google.guava)
+ implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11")
- implementation(libs.google.material)
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.7.1")
- implementation(libs.jsoup)
+ implementation("com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.9")
- implementation(libs.chucker)
-}
\ No newline at end of file
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ implementation("com.google.guava:guava:31.1-jre")
+
+ implementation("com.google.android.material:material:1.9.0")
+
+ implementation("com.github.chuckerteam.chucker:library:3.5.2")
+
+ implementation("dev.chrisbanes.insetter:insetter:0.6.1")
+
+ // Compose zone
+ implementation(platform("androidx.compose:compose-bom:2023.04.01"))
+
+ implementation("androidx.compose.material3:material3:1.1.1")
+// implementation("androidx.compose.material:material:1.4.3")
+ implementation("androidx.compose.ui:ui:1.4.3")
+
+ implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
+ debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
+
+ implementation("androidx.compose.material3:material3-window-size-class:1.1.1")
+
+ implementation("androidx.activity:activity-compose:1.7.2")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
+
+ implementation("androidx.compose.runtime:runtime-saveable:1.6.0-alpha02")
+ // end of Compose zone
+}
diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/33.json b/app/schemas/com.meloda.fast.database.AppDatabase/33.json
deleted file mode 100644
index 135b8b4d..00000000
--- a/app/schemas/com.meloda.fast.database.AppDatabase/33.json
+++ /dev/null
@@ -1,582 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 33,
- "identityHash": "ab075cc511743c47de441d484159b088",
- "entities": [
- {
- "tableName": "accounts",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "accessToken",
- "columnName": "accessToken",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "fastToken",
- "columnName": "fastToken",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "userId"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "conversations",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "ownerId",
- "columnName": "ownerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "callInProgress",
- "columnName": "callInProgress",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isPhantom",
- "columnName": "isPhantom",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "lastConversationMessageId",
- "columnName": "lastConversationMessageId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "inRead",
- "columnName": "inRead",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "outRead",
- "columnName": "outRead",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isMarkedUnread",
- "columnName": "isMarkedUnread",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "lastMessageId",
- "columnName": "lastMessageId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "unreadCount",
- "columnName": "unreadCount",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "membersCount",
- "columnName": "membersCount",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "canChangePin",
- "columnName": "canChangePin",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "majorId",
- "columnName": "majorId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "minorId",
- "columnName": "minorId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "pinnedMessage.id",
- "columnName": "pinnedMessage_id",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.text",
- "columnName": "pinnedMessage_text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.isOut",
- "columnName": "pinnedMessage_isOut",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.peerId",
- "columnName": "pinnedMessage_peerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.fromId",
- "columnName": "pinnedMessage_fromId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.date",
- "columnName": "pinnedMessage_date",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.randomId",
- "columnName": "pinnedMessage_randomId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.action",
- "columnName": "pinnedMessage_action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionMemberId",
- "columnName": "pinnedMessage_actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionText",
- "columnName": "pinnedMessage_actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionConversationMessageId",
- "columnName": "pinnedMessage_actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionMessage",
- "columnName": "pinnedMessage_actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.important",
- "columnName": "pinnedMessage_important",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.forwards",
- "columnName": "pinnedMessage_forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.attachments",
- "columnName": "pinnedMessage_attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.replyMessage",
- "columnName": "pinnedMessage_replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.geo",
- "columnName": "pinnedMessage_geo",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.id",
- "columnName": "lastMessage_id",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.text",
- "columnName": "lastMessage_text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.isOut",
- "columnName": "lastMessage_isOut",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.peerId",
- "columnName": "lastMessage_peerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.fromId",
- "columnName": "lastMessage_fromId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.date",
- "columnName": "lastMessage_date",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.randomId",
- "columnName": "lastMessage_randomId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.action",
- "columnName": "lastMessage_action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionMemberId",
- "columnName": "lastMessage_actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionText",
- "columnName": "lastMessage_actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionConversationMessageId",
- "columnName": "lastMessage_actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionMessage",
- "columnName": "lastMessage_actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.important",
- "columnName": "lastMessage_important",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.forwards",
- "columnName": "lastMessage_forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.attachments",
- "columnName": "lastMessage_attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.replyMessage",
- "columnName": "lastMessage_replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.geo",
- "columnName": "lastMessage_geo",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "messages",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "text",
- "columnName": "text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "isOut",
- "columnName": "isOut",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "peerId",
- "columnName": "peerId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fromId",
- "columnName": "fromId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "date",
- "columnName": "date",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "randomId",
- "columnName": "randomId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "action",
- "columnName": "action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "actionMemberId",
- "columnName": "actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "actionText",
- "columnName": "actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "actionConversationMessageId",
- "columnName": "actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "actionMessage",
- "columnName": "actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "important",
- "columnName": "important",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "forwards",
- "columnName": "forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "attachments",
- "columnName": "attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "replyMessage",
- "columnName": "replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "geo",
- "columnName": "geo",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "firstName",
- "columnName": "firstName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "lastName",
- "columnName": "lastName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "online",
- "columnName": "online",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastSeen",
- "columnName": "lastSeen",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastSeenStatus",
- "columnName": "lastSeenStatus",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "groups",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "screenName",
- "columnName": "screenName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "membersCount",
- "columnName": "membersCount",
- "affinity": "INTEGER",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- }
- ],
- "views": [],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab075cc511743c47de441d484159b088')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/34.json b/app/schemas/com.meloda.fast.database.AppDatabase/34.json
deleted file mode 100644
index 52e135b7..00000000
--- a/app/schemas/com.meloda.fast.database.AppDatabase/34.json
+++ /dev/null
@@ -1,600 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 34,
- "identityHash": "2c202b1fce1b5f6c6ab0da756e0590a6",
- "entities": [
- {
- "tableName": "accounts",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "accessToken",
- "columnName": "accessToken",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "fastToken",
- "columnName": "fastToken",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "userId"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "conversations",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_updateTime` INTEGER, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_updateTime` INTEGER, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "ownerId",
- "columnName": "ownerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "callInProgress",
- "columnName": "callInProgress",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isPhantom",
- "columnName": "isPhantom",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "lastConversationMessageId",
- "columnName": "lastConversationMessageId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "inRead",
- "columnName": "inRead",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "outRead",
- "columnName": "outRead",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isMarkedUnread",
- "columnName": "isMarkedUnread",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "lastMessageId",
- "columnName": "lastMessageId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "unreadCount",
- "columnName": "unreadCount",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "membersCount",
- "columnName": "membersCount",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "canChangePin",
- "columnName": "canChangePin",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "majorId",
- "columnName": "majorId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "minorId",
- "columnName": "minorId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "pinnedMessage.id",
- "columnName": "pinnedMessage_id",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.text",
- "columnName": "pinnedMessage_text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.isOut",
- "columnName": "pinnedMessage_isOut",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.peerId",
- "columnName": "pinnedMessage_peerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.fromId",
- "columnName": "pinnedMessage_fromId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.date",
- "columnName": "pinnedMessage_date",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.randomId",
- "columnName": "pinnedMessage_randomId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.action",
- "columnName": "pinnedMessage_action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionMemberId",
- "columnName": "pinnedMessage_actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionText",
- "columnName": "pinnedMessage_actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionConversationMessageId",
- "columnName": "pinnedMessage_actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.actionMessage",
- "columnName": "pinnedMessage_actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.updateTime",
- "columnName": "pinnedMessage_updateTime",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.important",
- "columnName": "pinnedMessage_important",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.forwards",
- "columnName": "pinnedMessage_forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.attachments",
- "columnName": "pinnedMessage_attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.replyMessage",
- "columnName": "pinnedMessage_replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "pinnedMessage.geo",
- "columnName": "pinnedMessage_geo",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.id",
- "columnName": "lastMessage_id",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.text",
- "columnName": "lastMessage_text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.isOut",
- "columnName": "lastMessage_isOut",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.peerId",
- "columnName": "lastMessage_peerId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.fromId",
- "columnName": "lastMessage_fromId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.date",
- "columnName": "lastMessage_date",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.randomId",
- "columnName": "lastMessage_randomId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.action",
- "columnName": "lastMessage_action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionMemberId",
- "columnName": "lastMessage_actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionText",
- "columnName": "lastMessage_actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionConversationMessageId",
- "columnName": "lastMessage_actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.actionMessage",
- "columnName": "lastMessage_actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.updateTime",
- "columnName": "lastMessage_updateTime",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.important",
- "columnName": "lastMessage_important",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.forwards",
- "columnName": "lastMessage_forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.attachments",
- "columnName": "lastMessage_attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.replyMessage",
- "columnName": "lastMessage_replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastMessage.geo",
- "columnName": "lastMessage_geo",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "messages",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "text",
- "columnName": "text",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "isOut",
- "columnName": "isOut",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "peerId",
- "columnName": "peerId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fromId",
- "columnName": "fromId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "date",
- "columnName": "date",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "randomId",
- "columnName": "randomId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "action",
- "columnName": "action",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "actionMemberId",
- "columnName": "actionMemberId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "actionText",
- "columnName": "actionText",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "actionConversationMessageId",
- "columnName": "actionConversationMessageId",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "actionMessage",
- "columnName": "actionMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "updateTime",
- "columnName": "updateTime",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "important",
- "columnName": "important",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "forwards",
- "columnName": "forwards",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "attachments",
- "columnName": "attachments",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "replyMessage",
- "columnName": "replyMessage",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "geo",
- "columnName": "geo",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "firstName",
- "columnName": "firstName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "lastName",
- "columnName": "lastName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "online",
- "columnName": "online",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "lastSeen",
- "columnName": "lastSeen",
- "affinity": "INTEGER",
- "notNull": false
- },
- {
- "fieldPath": "lastSeenStatus",
- "columnName": "lastSeenStatus",
- "affinity": "TEXT",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- },
- {
- "tableName": "groups",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "screenName",
- "columnName": "screenName",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "photo200",
- "columnName": "photo200",
- "affinity": "TEXT",
- "notNull": false
- },
- {
- "fieldPath": "membersCount",
- "columnName": "membersCount",
- "affinity": "INTEGER",
- "notNull": false
- }
- ],
- "primaryKey": {
- "columnNames": [
- "id"
- ],
- "autoGenerate": false
- },
- "indices": [],
- "foreignKeys": []
- }
- ],
- "views": [],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2c202b1fce1b5f6c6ab0da756e0590a6')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/app/src/dev/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..037e657b
Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/app/src/dev/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..e0ee3795
Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..a14bfbf5
Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..8b839a55
Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..c3b67b16
Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/values/ic_launcher_background.xml b/app/src/dev/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..30bd17f3
--- /dev/null
+++ b/app/src/dev/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #5B37DD
+
diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml
new file mode 100644
index 00000000..ede9730a
--- /dev/null
+++ b/app/src/dev/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+
+ Fast Dev
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a388ef3c..1d0023bd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,36 +3,35 @@
xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
-
+
+
+ tools:targetApi="tiramisu">
+
+
+
@@ -40,15 +39,64 @@
+
+
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt
index cee1e1b5..bbdad63b 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt
@@ -7,8 +7,6 @@ enum class ApiEvent(val value: Int) {
MessageEdit(5),
MessageReadIncoming(6),
MessageReadOutgoing(7),
- FriendOnline(8),
- FriendOffline(9),
MessagesDeleted(13),
PinUnpinConversation(20),
PrivateTyping(61),
@@ -25,4 +23,4 @@ enum class ApiEvent(val value: Int) {
fun parse(value: Int) = values().firstOrNull { it.value == value }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt
index d693636a..e0d69f7c 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt
@@ -1,10 +1,10 @@
package com.meloda.fast.api
import androidx.core.content.edit
-import androidx.lifecycle.MutableLiveData
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.model.AppAccount
+import kotlinx.coroutines.flow.MutableStateFlow
object UserConfig {
@@ -42,6 +42,6 @@ object UserConfig {
return currentUserId > 0 && userId > 0 && accessToken.isNotBlank()
}
- val vkUser = MutableLiveData(null)
+ val vkUser: MutableStateFlow = MutableStateFlow(null)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt
index 3f8dd237..e675fe2b 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt
@@ -8,11 +8,11 @@ object VKConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified"
const val USER_FIELDS =
- "photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info"
+ "photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate"
const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS"
- const val API_VERSION = "5.189"
+ const val API_VERSION = "5.173"
const val LP_VERSION = 10
const val VK_APP_ID = "2274003"
@@ -53,4 +53,4 @@ object VKConstants {
VkVoiceMessage::class.java,
VkWidget::class.java
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt
index 0beda05a..d55a408c 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt
@@ -4,20 +4,27 @@ import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.SpannableString
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.TextPaint
+import android.text.style.ClickableSpan
import android.text.style.StyleSpan
+import android.view.View
import androidx.core.content.ContextCompat
import com.google.gson.Gson
import com.meloda.fast.R
import com.meloda.fast.api.base.ApiError
-import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.attachments.*
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem
+import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.api.network.*
-import com.meloda.fast.extensions.orDots
+import com.meloda.fast.ext.orDots
+import com.meloda.fast.model.base.UiImage
+import com.meloda.fast.model.base.UiText
@Suppress("MemberVisibilityCanBePrivate")
object VkUtils {
@@ -27,7 +34,7 @@ object VkUtils {
id: Int,
ownerId: Int,
withAccessKey: Boolean,
- accessKey: String?
+ accessKey: String?,
): String {
val type = when (attachmentClass) {
VkAudio::class.java -> "audio"
@@ -52,15 +59,25 @@ object VkUtils {
else profiles[message.fromId]).also { message.user = it }
}
+ fun getMessageActionUser(message: VkMessage, profiles: Map): VkUser? {
+ return if (message.actionMemberId == null || message.actionMemberId <= 0) null
+ else profiles[message.actionMemberId]
+ }
+
fun getMessageGroup(message: VkMessage, groups: Map): VkGroup? {
return (if (!message.isGroup()) null
else groups[message.fromId]).also { message.group = it }
}
+ fun getMessageActionGroup(message: VkMessage, groups: Map): VkGroup? {
+ return if (message.actionMemberId == null || message.actionMemberId >= 0) null
+ else groups[message.actionMemberId]
+ }
+
fun getMessageAvatar(
message: VkMessage,
messageUser: VkUser?,
- messageGroup: VkGroup?
+ messageGroup: VkGroup?,
): String? {
return when {
message.isUser() -> messageUser?.photo200
@@ -74,7 +91,7 @@ object VkUtils {
defMessageUser: VkUser? = null,
defMessageGroup: VkGroup? = null,
profiles: Map? = null,
- groups: Map? = null
+ groups: Map? = null,
): String? {
val messageUser: VkUser? =
defMessageUser ?: if (profiles == null) null
@@ -91,37 +108,43 @@ object VkUtils {
}
}
- fun getConversationUser(conversation: VkConversation, profiles: Map): VkUser? {
- return (if (!conversation.isUser()) null
- else profiles[conversation.id]).also { conversation.user.postValue(it) }
+ fun getConversationUser(
+ conversation: VkConversationDomain,
+ profiles: Map
+ ): VkUser? {
+ return if (!conversation.isUser()) null
+ else profiles[conversation.id]
}
- fun getConversationGroup(conversation: VkConversation, groups: Map): VkGroup? {
- return (if (!conversation.isGroup()) null
- else groups[conversation.id]).also { conversation.group.postValue(it) }
+ fun getConversationGroup(
+ conversation: VkConversationDomain,
+ groups: Map
+ ): VkGroup? {
+ return if (!conversation.isGroup()) null
+ else groups[conversation.id]
}
fun getConversationAvatar(
- conversation: VkConversation,
+ conversation: VkConversationDomain,
conversationUser: VkUser?,
- conversationGroup: VkGroup?
+ conversationGroup: VkGroup?,
): String? {
return when {
conversation.isAccount() -> null
conversation.isUser() -> conversationUser?.photo200
conversation.isGroup() -> conversationGroup?.photo200
- conversation.isChat() -> conversation.photo200
+ conversation.isChat() -> conversation.conversationPhoto
else -> null
}
}
fun getConversationTitle(
context: Context,
- conversation: VkConversation,
+ conversation: VkConversationDomain,
defConversationUser: VkUser? = null,
defConversationGroup: VkGroup? = null,
profiles: Map? = null,
- groups: Map? = null
+ groups: Map? = null,
): String? {
val conversationUser: VkUser? =
defConversationUser ?: if (profiles == null) null
@@ -133,7 +156,7 @@ object VkUtils {
return when {
conversation.isAccount() -> context.getString(R.string.favorites)
- conversation.isChat() -> conversation.title
+ conversation.isChat() -> conversation.conversationTitle
conversation.isUser() -> conversationUser?.fullName
conversation.isGroup() -> conversationGroup?.name
else -> null
@@ -141,9 +164,9 @@ object VkUtils {
}
fun getConversationUserGroup(
- conversation: VkConversation,
+ conversation: VkConversationDomain,
profiles: Map,
- groups: Map
+ groups: Map,
): Pair {
val user: VkUser? = getConversationUser(conversation, profiles)
val group: VkGroup? = getConversationGroup(conversation, groups)
@@ -152,21 +175,45 @@ object VkUtils {
}
fun getMessageUserGroup(
- message: VkMessage,
+ message: VkMessage?,
profiles: Map,
- groups: Map
+ groups: Map,
): Pair {
+ if (message == null) return null to null
+
val user: VkUser? = getMessageUser(message, profiles)
val group: VkGroup? = getMessageGroup(message, groups)
return user to group
}
- fun prepareMessageText(text: String, forConversations: Boolean? = null): String {
- return text.apply {
- if (forConversations == true) replace("\n", "")
+ fun getMessageActionUserGroup(
+ message: VkMessage?,
+ profiles: Map,
+ groups: Map,
+ ): Pair {
+ if (message == null) return null to null
- replace("&", "&")
+ val user: VkUser? = getMessageActionUser(message, profiles)
+ val group: VkGroup? = getMessageActionGroup(message, groups)
+
+ return user to group
+ }
+
+ fun prepareMessageText(text: String, forConversations: Boolean = false): String {
+ return text.apply {
+ if (forConversations) {
+ replace("\n", "")
+ }
+
+ replace("&", "&")
+ replace(""", "\"")
+ replace("
", "\n")
+ replace(">", ">")
+ replace("<", "<")
+ replace("
", "\n")
+ replace("–", "-")
+ trim()
}
}
@@ -205,78 +252,97 @@ object VkUtils {
val photo = baseAttachment.photo ?: continue
attachments += photo.asVkPhoto()
}
+
BaseVkAttachmentItem.AttachmentType.Video -> {
val video = baseAttachment.video ?: continue
attachments += video.asVkVideo()
}
+
BaseVkAttachmentItem.AttachmentType.Audio -> {
val audio = baseAttachment.audio ?: continue
attachments += audio.asVkAudio()
}
+
BaseVkAttachmentItem.AttachmentType.File -> {
val file = baseAttachment.file ?: continue
attachments += file.asVkFile()
}
+
BaseVkAttachmentItem.AttachmentType.Link -> {
val link = baseAttachment.link ?: continue
attachments += link.asVkLink()
}
+
BaseVkAttachmentItem.AttachmentType.MiniApp -> {
val miniApp = baseAttachment.miniApp ?: continue
attachments += miniApp.asVkMiniApp()
}
+
BaseVkAttachmentItem.AttachmentType.Voice -> {
val voiceMessage = baseAttachment.voiceMessage ?: continue
attachments += voiceMessage.asVkVoiceMessage()
}
+
BaseVkAttachmentItem.AttachmentType.Sticker -> {
val sticker = baseAttachment.sticker ?: continue
attachments += sticker.asVkSticker()
}
+
BaseVkAttachmentItem.AttachmentType.Gift -> {
val gift = baseAttachment.gift ?: continue
attachments += gift.asVkGift()
}
+
BaseVkAttachmentItem.AttachmentType.Wall -> {
val wall = baseAttachment.wall ?: continue
attachments += wall.asVkWall()
}
+
BaseVkAttachmentItem.AttachmentType.Graffiti -> {
val graffiti = baseAttachment.graffiti ?: continue
attachments += graffiti.asVkGraffiti()
}
+
BaseVkAttachmentItem.AttachmentType.Poll -> {
val poll = baseAttachment.poll ?: continue
attachments += poll.asVkPoll()
}
+
BaseVkAttachmentItem.AttachmentType.WallReply -> {
val wallReply = baseAttachment.wallReply ?: continue
attachments += wallReply.asVkWallReply()
}
+
BaseVkAttachmentItem.AttachmentType.Call -> {
val call = baseAttachment.call ?: continue
attachments += call.asVkCall()
}
+
BaseVkAttachmentItem.AttachmentType.GroupCallInProgress -> {
val groupCall = baseAttachment.groupCall ?: continue
attachments += groupCall.asVkGroupCall()
}
+
BaseVkAttachmentItem.AttachmentType.Curator -> {
val curator = baseAttachment.curator ?: continue
attachments += curator.asVkCurator()
}
+
BaseVkAttachmentItem.AttachmentType.Event -> {
val event = baseAttachment.event ?: continue
attachments += event.asVkEvent()
}
+
BaseVkAttachmentItem.AttachmentType.Story -> {
val story = baseAttachment.story ?: continue
attachments += story.asVkStory()
}
+
BaseVkAttachmentItem.AttachmentType.Widget -> {
val widget = baseAttachment.widget ?: continue
attachments += widget.asVkWidget()
}
+
else -> continue
}
}
@@ -285,16 +351,229 @@ object VkUtils {
}
fun getActionMessageText(
- context: Context,
- message: VkMessage,
+ message: VkMessage?,
youPrefix: String,
- profiles: Map? = null,
- groups: Map? = null,
- messageUser: VkUser? = null,
- messageGroup: VkGroup? = null
+ messageUser: VkUser?,
+ messageGroup: VkGroup?,
+ action: VkMessage.Action?,
+ actionUser: VkUser?,
+ actionGroup: VkGroup?,
+ ): UiText? {
+ if (message == null) return null
+
+ return when (action) {
+ VkMessage.Action.CHAT_CREATE -> {
+ val text = message.actionText ?: return null
+
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_created,
+ listOf(prefix, text)
+ )
+ }
+
+ VkMessage.Action.CHAT_TITLE_UPDATE -> {
+ val text = message.actionText ?: return null
+
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_renamed,
+ listOf(prefix, text)
+ )
+ }
+
+ VkMessage.Action.CHAT_PHOTO_UPDATE -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_photo_update, listOf(prefix))
+ }
+
+ VkMessage.Action.CHAT_PHOTO_REMOVE -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_photo_remove, listOf(prefix))
+ }
+
+ VkMessage.Action.CHAT_KICK_USER -> {
+ val memberId = message.actionMemberId ?: return null
+ val isUser = memberId > 0
+ val isGroup = memberId < 0
+
+ if (isUser && actionUser == null) return null
+ if (isGroup && actionGroup == null) return null
+
+ if (memberId == message.fromId) {
+ val prefix = if (memberId == UserConfig.userId) youPrefix
+ else actionUser.toString()
+
+ UiText.ResourceParams(R.string.message_action_chat_user_left, listOf(prefix))
+ } else {
+ val prefix =
+ if (message.fromId == UserConfig.userId) youPrefix
+ else messageUser?.toString() ?: messageGroup?.toString().orDots()
+
+ val postfix =
+ if (memberId == UserConfig.userId) youPrefix.lowercase()
+ else actionUser.toString()
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_kicked, listOf(prefix, postfix)
+ )
+ }
+ }
+
+ VkMessage.Action.CHAT_INVITE_USER -> {
+ val memberId = message.actionMemberId ?: 0
+ val isUser = memberId > 0
+ val isGroup = memberId < 0
+
+ if (isUser && actionUser == null) return null
+ if (isGroup && actionGroup == null) return null
+
+ if (memberId == message.fromId) {
+ val prefix = if (memberId == UserConfig.userId) youPrefix
+ else actionUser.toString()
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_returned,
+ listOf(prefix)
+ )
+ } else {
+ val prefix = if (message.fromId == UserConfig.userId) youPrefix
+ else messageUser?.toString() ?: messageGroup?.toString().orDots()
+
+ val postfix =
+ if (memberId == UserConfig.userId) youPrefix.lowercase()
+ else actionUser.toString()
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_invited,
+ listOf(prefix, postfix)
+ )
+ }
+ }
+
+ VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_joined_by_link,
+ listOf(prefix)
+ )
+ }
+
+ VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_joined_by_call,
+ listOf(prefix)
+ )
+ }
+
+ VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(
+ R.string.message_action_chat_user_joined_by_call_link,
+ listOf(prefix)
+ )
+ }
+
+ VkMessage.Action.CHAT_PIN_MESSAGE -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_pin_message, listOf(prefix))
+ }
+
+ VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_unpin_message, listOf(prefix))
+ }
+
+ VkMessage.Action.CHAT_SCREENSHOT -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isGroup() -> messageGroup?.name
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_screenshot, listOf(prefix))
+ }
+
+ VkMessage.Action.CHAT_STYLE_UPDATE -> {
+ val prefix = when {
+ message.fromId == UserConfig.userId -> youPrefix
+ message.isUser() -> messageUser?.toString()
+ else -> return null
+ } ?: return null
+
+ UiText.ResourceParams(R.string.message_action_chat_style_update, listOf(prefix))
+ }
+
+ null -> null
+ }
+ }
+
+ fun getActionMessageText(
+ context: Context,
+ message: VkMessage?,
+ youPrefix: String,
+ messageUser: VkUser?,
+ messageGroup: VkGroup?,
+ action: VkMessage.Action?,
+ actionUser: VkUser?,
+ actionGroup: VkGroup?,
): SpannableString? {
- @Suppress("REDUNDANT_ELSE_IN_WHEN")
- return when (message.getPreparedAction()) {
+ if (message == null) return null
+
+ return when (action) {
VkMessage.Action.CHAT_CREATE -> {
val text = message.actionText ?: return null
@@ -319,6 +598,7 @@ object VkUtils {
)
}
}
+
VkMessage.Action.CHAT_TITLE_UPDATE -> {
val text = message.actionText ?: return null
@@ -340,6 +620,7 @@ object VkUtils {
)
}
}
+
VkMessage.Action.CHAT_PHOTO_UPDATE -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -355,6 +636,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_PHOTO_REMOVE -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -370,14 +652,12 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_KICK_USER -> {
val memberId = message.actionMemberId ?: return null
val isUser = memberId > 0
val isGroup = memberId < 0
- val actionUser = profiles?.get(memberId)
- val actionGroup = groups?.get(memberId)
-
if (isUser && actionUser == null) return null
if (isGroup && actionGroup == null) return null
@@ -416,14 +696,12 @@ object VkUtils {
}
}
}
+
VkMessage.Action.CHAT_INVITE_USER -> {
val memberId = message.actionMemberId ?: 0
val isUser = memberId > 0
val isGroup = memberId < 0
- val actionUser = profiles?.get(memberId)
- val actionGroup = groups?.get(memberId)
-
if (isUser && actionUser == null) return null
if (isGroup && actionGroup == null) return null
@@ -461,6 +739,7 @@ object VkUtils {
}
}
}
+
VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -475,6 +754,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -489,6 +769,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -503,6 +784,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_PIN_MESSAGE -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -518,6 +800,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -533,6 +816,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_SCREENSHOT -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -548,6 +832,7 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
VkMessage.Action.CHAT_STYLE_UPDATE -> {
val prefix = when {
message.fromId == UserConfig.userId -> youPrefix
@@ -562,74 +847,112 @@ object VkUtils {
it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0)
}
}
+
null -> null
- else -> SpannableString("[${message.action}]")
}
}
fun getActionConversationText(
- context: Context,
- message: VkMessage,
+ message: VkMessage?,
youPrefix: String,
- profiles: HashMap? = null,
- groups: HashMap? = null,
messageUser: VkUser? = null,
- messageGroup: VkGroup? = null
+ messageGroup: VkGroup? = null,
+ action: VkMessage.Action?,
+ actionUser: VkUser?,
+ actionGroup: VkGroup?,
+ ): UiText? {
+ return getActionMessageText(
+ message = message,
+ youPrefix = youPrefix,
+ messageUser = messageUser,
+ messageGroup = messageGroup,
+ action = action,
+ actionUser = actionUser,
+ actionGroup = actionGroup,
+ )
+ }
+
+ fun getActionConversationText(
+ context: Context,
+ message: VkMessage?,
+ youPrefix: String,
+ messageUser: VkUser? = null,
+ messageGroup: VkGroup? = null,
+ action: VkMessage.Action?,
+ actionUser: VkUser?,
+ actionGroup: VkGroup?,
): String? {
return getActionMessageText(
context = context,
message = message,
youPrefix = youPrefix,
- profiles = profiles,
- groups = groups,
messageUser = messageUser,
- messageGroup = messageGroup
+ messageGroup = messageGroup,
+ action = action,
+ actionUser = actionUser,
+ actionGroup = actionGroup,
)?.toString()
}
- fun getForwardsText(context: Context, message: VkMessage): String? {
- if (message.forwards.isNullOrEmpty()) return null
+ fun getForwardsText(message: VkMessage?): UiText? {
+ if (message?.forwards.isNullOrEmpty()) return null
- return message.forwards?.let { forwards ->
- context.getString(
+ return message?.forwards?.let { forwards ->
+ UiText.Resource(
if (forwards.size == 1) R.string.forwarded_message
else R.string.forwarded_messages
)
}
}
- fun getAttachmentText(context: Context, message: VkMessage): String? {
- message.geo?.let {
+ fun getAttachmentText(message: VkMessage?): UiText? {
+ message?.geo?.let {
return when (it.type) {
- "point" -> context.getString(R.string.message_geo_point)
- else -> context.getString(R.string.message_geo)
+ "point" -> UiText.Resource(R.string.message_geo_point)
+ else -> UiText.Resource(R.string.message_geo)
}
}
- if (message.attachments.isNullOrEmpty()) return null
+ if (message?.attachments.isNullOrEmpty()) return null
- return message.attachments?.let { attachments ->
+ return message?.attachments?.let { attachments ->
if (attachments.size == 1) {
getAttachmentTypeByClass(attachments[0])?.let {
- getAttachmentTextByType(
- context,
- it
- )
+ getAttachmentTextByType(it)
}
} else {
if (isAttachmentsHaveOneType(attachments)) {
getAttachmentTypeByClass(attachments[0])?.let {
- getAttachmentTextByType(
- context, it, attachments.size
- )
+ getAttachmentTextByType(it, attachments.size)
}
} else {
- context.getString(R.string.message_attachments_many)
+ UiText.Resource(R.string.message_attachments_many)
}
}
}
}
- fun getAttachmentConversationIcon(context: Context, message: VkMessage): Drawable? {
+ fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
+ return message?.attachments?.let { attachments ->
+ if (attachments.isEmpty()) return null
+ if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
+ message.geo?.let {
+ return UiImage.Resource(R.drawable.ic_map_marker)
+ }
+
+ getAttachmentTypeByClass(attachments[0])?.let {
+ getAttachmentIconByType(it)
+ }
+ } else {
+ UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
+ }
+ }
+ }
+
+ fun getAttachmentConversationIcon(
+ context: Context,
+ message: VkMessage?,
+ ): Drawable? {
+ if (message == null) return null
return message.attachments?.let { attachments ->
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
message.geo?.let {
@@ -650,9 +973,32 @@ object VkUtils {
}
}
+ fun getAttachmentIconByType(attachmentType: BaseVkAttachmentItem.AttachmentType): UiImage? {
+ return when (attachmentType) {
+ BaseVkAttachmentItem.AttachmentType.Photo -> R.drawable.ic_attachment_photo
+ BaseVkAttachmentItem.AttachmentType.Video -> R.drawable.ic_attachment_video
+ BaseVkAttachmentItem.AttachmentType.Audio -> R.drawable.ic_attachment_audio
+ BaseVkAttachmentItem.AttachmentType.File -> R.drawable.ic_attachment_file
+ BaseVkAttachmentItem.AttachmentType.Link -> R.drawable.ic_attachment_link
+ BaseVkAttachmentItem.AttachmentType.Voice -> R.drawable.ic_attachment_voice
+ BaseVkAttachmentItem.AttachmentType.MiniApp -> R.drawable.ic_attachment_mini_app
+ BaseVkAttachmentItem.AttachmentType.Sticker -> R.drawable.ic_attachment_sticker
+ BaseVkAttachmentItem.AttachmentType.Gift -> R.drawable.ic_attachment_gift
+ BaseVkAttachmentItem.AttachmentType.Wall -> R.drawable.ic_attachment_wall
+ BaseVkAttachmentItem.AttachmentType.Graffiti -> R.drawable.ic_attachment_graffiti
+ BaseVkAttachmentItem.AttachmentType.Poll -> R.drawable.ic_attachment_poll
+ BaseVkAttachmentItem.AttachmentType.WallReply -> R.drawable.ic_attachment_wall_reply
+ BaseVkAttachmentItem.AttachmentType.Call -> R.drawable.ic_attachment_call
+ BaseVkAttachmentItem.AttachmentType.GroupCallInProgress -> R.drawable.ic_attachment_group_call
+ BaseVkAttachmentItem.AttachmentType.Story -> R.drawable.ic_attachment_story
+ else -> null
+ }?.let(UiImage::Resource)
+ }
+
+ @Deprecated("Use new with UiImage")
fun getAttachmentIconByType(
context: Context,
- attachmentType: BaseVkAttachmentItem.AttachmentType
+ attachmentType: BaseVkAttachmentItem.AttachmentType,
): Drawable? {
val resId = when (attachmentType) {
BaseVkAttachmentItem.AttachmentType.Photo -> R.drawable.ic_attachment_photo
@@ -716,50 +1062,68 @@ object VkUtils {
}
fun getAttachmentTextByType(
- context: Context,
attachmentType: BaseVkAttachmentItem.AttachmentType,
- size: Int = 1
- ): String {
+ size: Int = 1,
+ ): UiText {
return when (attachmentType) {
BaseVkAttachmentItem.AttachmentType.Photo ->
- context.resources.getQuantityString(R.plurals.attachment_photos, size, size)
+ UiText.QuantityResource(R.plurals.attachment_photos, size)
+
BaseVkAttachmentItem.AttachmentType.Video ->
- context.resources.getQuantityString(R.plurals.attachment_videos, size, size)
+ UiText.QuantityResource(R.plurals.attachment_videos, size)
+
BaseVkAttachmentItem.AttachmentType.Audio ->
- context.resources.getQuantityString(R.plurals.attachment_audios, size, size)
+ UiText.QuantityResource(R.plurals.attachment_audios, size)
+
BaseVkAttachmentItem.AttachmentType.File ->
- context.resources.getQuantityString(R.plurals.attachment_files, size, size)
+ UiText.QuantityResource(R.plurals.attachment_files, size)
+
BaseVkAttachmentItem.AttachmentType.Link ->
- context.resources.getString(R.string.message_attachments_link)
+ UiText.Resource(R.string.message_attachments_link)
+
BaseVkAttachmentItem.AttachmentType.Voice ->
- context.resources.getString(R.string.message_attachments_voice)
+ UiText.Resource(R.string.message_attachments_voice)
+
BaseVkAttachmentItem.AttachmentType.MiniApp ->
- context.resources.getString(R.string.message_attachments_mini_app)
+ UiText.Resource(R.string.message_attachments_mini_app)
+
BaseVkAttachmentItem.AttachmentType.Sticker ->
- context.resources.getString(R.string.message_attachments_sticker)
+ UiText.Resource(R.string.message_attachments_sticker)
+
BaseVkAttachmentItem.AttachmentType.Gift ->
- context.resources.getString(R.string.message_attachments_gift)
+ UiText.Resource(R.string.message_attachments_gift)
+
BaseVkAttachmentItem.AttachmentType.Wall ->
- context.resources.getString(R.string.message_attachments_wall)
+ UiText.Resource(R.string.message_attachments_wall)
+
BaseVkAttachmentItem.AttachmentType.Graffiti ->
- context.resources.getString(R.string.message_attachments_graffiti)
+ UiText.Resource(R.string.message_attachments_graffiti)
+
BaseVkAttachmentItem.AttachmentType.Poll ->
- context.resources.getString(R.string.message_attachments_poll)
+ UiText.Resource(R.string.message_attachments_poll)
+
BaseVkAttachmentItem.AttachmentType.WallReply ->
- context.resources.getString(R.string.message_attachments_wall_reply)
+ UiText.Resource(R.string.message_attachments_wall_reply)
+
BaseVkAttachmentItem.AttachmentType.Call ->
- context.resources.getString(R.string.message_attachments_call)
+ UiText.Resource(R.string.message_attachments_call)
+
BaseVkAttachmentItem.AttachmentType.GroupCallInProgress ->
- context.resources.getString(R.string.message_attachments_call_in_progress)
+ UiText.Resource(R.string.message_attachments_call_in_progress)
+
BaseVkAttachmentItem.AttachmentType.Event ->
- context.resources.getString(R.string.message_attachments_event)
+ UiText.Resource(R.string.message_attachments_event)
+
BaseVkAttachmentItem.AttachmentType.Curator ->
- context.resources.getString(R.string.message_attachments_curator)
+ UiText.Resource(R.string.message_attachments_curator)
+
BaseVkAttachmentItem.AttachmentType.Story ->
- context.resources.getString(R.string.message_attachments_story)
+ UiText.Resource(R.string.message_attachments_story)
+
BaseVkAttachmentItem.AttachmentType.Widget ->
- context.resources.getString(R.string.message_attachments_widget)
- else -> attachmentType.value
+ UiText.Resource(R.string.message_attachments_widget)
+
+ else -> UiText.Simple(attachmentType.value)
}
}
@@ -775,18 +1139,43 @@ object VkUtils {
authorizationError
}
+
+ VkErrorCodes.AccessTokenExpired.toString() -> {
+ val tokenExpiredError =
+ gson.fromJson(errorString, TokenExpiredError::class.java)
+
+ tokenExpiredError
+ }
+
VkErrors.NeedValidation -> {
val validationError =
- gson.fromJson(errorString, ValidationRequiredError::class.java)
+ gson.fromJson(
+ errorString,
+ if (defaultError.errorMessage == VkErrorMessages.UserBanned) {
+ UserBannedError::class.java
+ } else {
+ ValidationRequiredError::class.java
+ }
+ )
validationError
}
+
VkErrors.NeedCaptcha -> {
val captchaRequiredError =
gson.fromJson(errorString, CaptchaRequiredError::class.java)
captchaRequiredError
}
+
+ VkErrors.InvalidRequest -> {
+ when (defaultError.errorType) {
+ VkErrorTypes.OtpFormatIncorrect -> WrongTwoFaCodeFormatError
+ VkErrorTypes.WrongOtp -> WrongTwoFaCodeError
+ else -> defaultError
+ }
+ }
+
else -> defaultError
}
@@ -795,4 +1184,102 @@ object VkUtils {
return ApiAnswer.Error(ApiError(throwable = e))
}
}
-}
\ No newline at end of file
+
+ fun visualizeMentions(
+ messageText: String,
+ mentionColor: Int,
+ onMentionClick: ((id: Int) -> Unit)? = null,
+ ): SpannableStringBuilder {
+ if (messageText.isEmpty()) {
+ return SpannableStringBuilder("")
+ }
+
+ var newMessageText = messageText
+
+ val idsIndexes = mutableListOf>()
+ val mentions = mutableListOf>()
+
+ var startFrom = 0
+
+ while (true) {
+ val leftBracketIndex = newMessageText.indexOf('[', startFrom)
+ val verticalLineIndex = newMessageText.indexOf('|', startFrom)
+ val rightBracketIndex = newMessageText.indexOf(']', startFrom)
+
+ if (leftBracketIndex == -1 ||
+ verticalLineIndex == -1 ||
+ rightBracketIndex == -1
+ ) break
+
+ val idPart = newMessageText.substring(leftBracketIndex + 1, verticalLineIndex)
+
+ val actualId = idPart.substring(2, idPart.length).toIntOrNull() ?: -1
+
+ if (!idPart.matches(Regex("^id(\\d+)\$")) || rightBracketIndex - verticalLineIndex < 2) {
+ break
+ }
+
+ val text = newMessageText.substring(verticalLineIndex + 1, rightBracketIndex)
+
+ val str = "[$idPart|$text]"
+
+ mentions += str to text
+
+ idsIndexes += Triple(actualId, leftBracketIndex, leftBracketIndex + text.length)
+
+ startFrom = rightBracketIndex + 1
+ }
+
+ idsIndexes.reverse()
+
+ mentions.forEachIndexed { index, pair ->
+ val old = pair.first
+ val new = pair.second
+
+ val oldIndexStart = newMessageText.indexOf(old)
+
+ idsIndexes[index].copy(
+ second = oldIndexStart,
+ third = oldIndexStart + new.length
+ ).let { idsIndexes[index] = it }
+
+ newMessageText = newMessageText.replace(old, new)
+ }
+
+ val spanBuilder = SpannableStringBuilder(newMessageText)
+
+ idsIndexes.forEach { triple ->
+ val id = triple.first
+ val start = triple.second
+ val end = triple.third
+
+ spanBuilder.setSpan(
+ createClickableSpan(id, mentionColor, onMentionClick),
+ start,
+ end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+
+ return spanBuilder
+ }
+
+ private fun createClickableSpan(
+ id: Int,
+ mentionColor: Int,
+ onMentionClick: ((id: Int) -> Unit)? = null,
+ ): ClickableSpan {
+ return object : ClickableSpan() {
+ override fun onClick(widget: View) {
+ widget.cancelPendingInputEvents()
+
+ onMentionClick?.invoke(id)
+ }
+
+ override fun updateDrawState(ds: TextPaint) {
+ ds.color = mentionColor
+// ds.typeface = Typeface.defaultFromStyle(Typeface.BOLD)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt
index 677a22bc..ae38560f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt
@@ -9,6 +9,8 @@ open class ApiError(
val error: String? = null,
@SerializedName("error_msg", alternate = ["error_description"])
open val errorMessage: String? = null,
+ @SerializedName("error_type")
+ val errorType: String? = null,
val throwable: Throwable? = null
) : IOException() {
diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt b/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt
new file mode 100644
index 00000000..2aad4520
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt
@@ -0,0 +1,9 @@
+package com.meloda.fast.api.base
+
+import com.meloda.fast.api.model.attachments.VkAttachment
+import okio.IOException
+
+class AttachmentClassNameIsEmptyException(attachment: VkAttachment) :
+ IOException(
+ "attachment ${attachment.javaClass.name} does not have declared field \"className\""
+ )
diff --git a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt
index 2d117e92..3637bd5f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt
@@ -9,12 +9,26 @@ sealed class LongPollEvent {
data class VkMessageNewEvent(
val message: VkMessage,
val profiles: HashMap,
- val groups: HashMap
+ val groups: HashMap,
) : LongPollEvent()
data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent()
- data class VkMessageReadIncomingEvent(val peerId: Int, val messageId: Int) : LongPollEvent()
- data class VkMessageReadOutgoingEvent(val peerId: Int, val messageId: Int) : LongPollEvent()
+ data class VkMessageReadIncomingEvent(
+ val peerId: Int,
+ val messageId: Int,
+ val unreadCount: Int,
+ ) : LongPollEvent()
-}
\ No newline at end of file
+ data class VkMessageReadOutgoingEvent(
+ val peerId: Int,
+ val messageId: Int,
+ val unreadCount: Int,
+ ) : LongPollEvent()
+
+ data class VkConversationPinStateChangedEvent(
+ val peerId: Int,
+ val majorId: Int,
+ ) : LongPollEvent()
+
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt
index f419b3de..324d446f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt
@@ -10,7 +10,12 @@ import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.messages.MessagesGetByIdRequest
import com.meloda.fast.base.viewmodel.VkEventCallback
import com.meloda.fast.data.messages.MessagesRepository
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -47,10 +52,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
ApiEvent.MessageEdit -> parseMessageEdit(eventType, event)
ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event)
ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event)
- ApiEvent.FriendOnline -> parseFriendOnline(eventType, event)
- ApiEvent.FriendOffline -> parseFriendOffline(eventType, event)
ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event)
- ApiEvent.PinUnpinConversation -> onNewEvent(eventType, event)
+ ApiEvent.PinUnpinConversation -> parseConversationPinStateChanged(eventType, event)
ApiEvent.PrivateTyping -> onNewEvent(eventType, event)
ApiEvent.ChatTyping -> onNewEvent(eventType, event)
ApiEvent.OneMoreTyping -> onNewEvent(eventType, event)
@@ -67,6 +70,27 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event")
}
+ private fun parseConversationPinStateChanged(eventType: ApiEvent, event: JsonArray) {
+ Log.d("LongPollUpdatesParser", "$eventType: $event")
+
+ val peerId = event[1].asInt
+ val majorId = event[2].asInt
+
+ launch {
+ listenersMap[ApiEvent.PinUnpinConversation]?.let { listeners ->
+ listeners.forEach { vkEventCallback ->
+ (vkEventCallback as VkEventCallback)
+ .onEvent(
+ LongPollEvent.VkConversationPinStateChangedEvent(
+ peerId = peerId,
+ majorId = majorId
+ )
+ )
+ }
+ }
+ }
+ }
+
private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
@@ -119,6 +143,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt
val messageId = event[2].asInt
+ val unreadCount = event[3].asInt
launch {
listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners ->
@@ -127,7 +152,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent(
LongPollEvent.VkMessageReadIncomingEvent(
peerId = peerId,
- messageId = messageId
+ messageId = messageId,
+ unreadCount = unreadCount
)
)
}
@@ -139,6 +165,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt
val messageId = event[2].asInt
+ val unreadCount = event[3].asInt
launch {
listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners ->
@@ -147,7 +174,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent(
LongPollEvent.VkMessageReadOutgoingEvent(
peerId = peerId,
- messageId = messageId
+ messageId = messageId,
+ unreadCount = unreadCount
)
)
}
@@ -155,21 +183,13 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
}
}
- private fun parseFriendOnline(eventType: ApiEvent, event: JsonArray) {
- Log.d("LongPollUpdatesParser", "$eventType: $event")
- }
-
- private fun parseFriendOffline(eventType: ApiEvent, event: JsonArray) {
- Log.d("LongPollUpdatesParser", "$eventType: $event")
- }
-
private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private suspend fun loadNormalMessage(eventType: ApiEvent, messageId: Int) =
coroutineScope {
- suspendCoroutine {
+ suspendCoroutine {
launch {
val normalMessageResponse = messagesRepository.getById(
MessagesGetByIdRequest(
@@ -179,7 +199,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
)
)
- if (!normalMessageResponse.isSuccessful()) {
+ if (normalMessageResponse.isError()) {
normalMessageResponse.error.throwable?.run { throw this }
}
@@ -195,12 +215,12 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
val profiles = hashMapOf()
messagesResponse.profiles?.forEach { baseUser ->
- baseUser.asVkUser().let { user -> profiles[user.id] = user }
+ baseUser.mapToDomain().let { user -> profiles[user.id] = user }
}
val groups = hashMapOf()
messagesResponse.groups?.forEach { baseGroup ->
- baseGroup.asVkGroup().let { group -> groups[group.id] = group }
+ baseGroup.mapToDomain().let { group -> groups[group.id] = group }
}
val resumeValue: LongPollEvent? = when (eventType) {
@@ -228,6 +248,14 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
}
}
+ fun onConversationPinStateChanged(listener: VkEventCallback) {
+ registerListener(ApiEvent.PinUnpinConversation, listener)
+ }
+
+ fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) {
+ onConversationPinStateChanged(assembleEventCallback(block))
+ }
+
fun onMessageIncomingRead(listener: VkEventCallback) {
registerListener(ApiEvent.MessageReadIncoming, listener)
}
@@ -265,8 +293,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
}
}
-internal inline fun assembleEventCallback(crossinline block: (R) -> Unit): VkEventCallback {
- return object : VkEventCallback {
- override fun onEvent(event: R) = block.invoke(event)
- }
-}
\ No newline at end of file
+internal inline fun assembleEventCallback(
+ crossinline block: (R) -> Unit,
+): VkEventCallback {
+ return VkEventCallback { event -> block.invoke(event) }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt b/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt
new file mode 100644
index 00000000..ece31811
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt
@@ -0,0 +1,17 @@
+package com.meloda.fast.api.model
+
+sealed class ActionState {
+ object Phantom : ActionState()
+ object CallInProgress : ActionState()
+ object None : ActionState()
+
+ companion object {
+ fun parse(isPhantom: Boolean, isCallInProgress: Boolean): ActionState {
+ return when {
+ isPhantom -> Phantom
+ isCallInProgress -> CallInProgress
+ else -> None
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt b/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt
new file mode 100644
index 00000000..25c3bb53
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt
@@ -0,0 +1,25 @@
+package com.meloda.fast.api.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+sealed class ConversationPeerType : Parcelable {
+ object User : ConversationPeerType()
+ object Group : ConversationPeerType()
+ object Chat : ConversationPeerType()
+
+ fun isUser() = this == User
+ fun isGroup() = this == Group
+ fun isChat() = this == Chat
+
+ companion object {
+ fun parse(type: String): ConversationPeerType {
+ return when (type) {
+ "user" -> User
+ "group" -> Group
+ else -> Chat
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt
new file mode 100644
index 00000000..c75b45be
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt
@@ -0,0 +1,53 @@
+package com.meloda.fast.api.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class VkChat(
+ val type: String,
+ val title: String,
+ val adminId: Int,
+ val membersCount: Int,
+ val id: Int,
+ val members: List = emptyList(),
+ val photo50: String,
+ val photo100: String,
+ val photo200: String,
+ val isDefaultPhoto: Boolean
+) : Parcelable {
+
+
+ @Parcelize
+ data class ChatMember(
+ val id: Int,
+ val type: ChatMemberType,
+ val isOnline: Boolean?,
+ val lastSeen: Int?,
+ val name: String?,
+ val firstName: String?,
+ val lastName: String?,
+ val invitedBy: Int,
+ val photo50: String?,
+ val photo100: String?,
+ val photo200: String?,
+ val isOwner: Boolean,
+ val isAdmin: Boolean,
+ val canKick: Boolean
+ ) : Parcelable {
+
+ fun isProfile(): Boolean = type == ChatMemberType.Profile
+
+ fun isGroup(): Boolean = type == ChatMemberType.Group
+
+ enum class ChatMemberType(val value: String) {
+ Profile("profile"), Group("group");
+
+ companion object {
+ fun parse(value: String) = values().first { it.value == value }
+ }
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt
new file mode 100644
index 00000000..1a513309
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt
@@ -0,0 +1,14 @@
+package com.meloda.fast.api.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class VkChatMember(
+ val memberId: Int,
+ val invitedBy: Int,
+ val joinDate: Int,
+ val isAdmin: Boolean,
+ val isOwner: Boolean,
+ val canKick: Boolean
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt
deleted file mode 100644
index a5c7118a..00000000
--- a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.meloda.fast.api.model
-
-import androidx.lifecycle.MutableLiveData
-import androidx.room.Embedded
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.PrimaryKey
-import com.meloda.fast.api.UserConfig
-import com.meloda.fast.model.SelectableItem
-import kotlinx.parcelize.IgnoredOnParcel
-import kotlinx.parcelize.Parcelize
-
-@Entity(tableName = "conversations")
-@Parcelize
-data class VkConversation(
- @PrimaryKey(autoGenerate = false)
- var id: Int,
- var ownerId: Int?,
- var title: String?,
- var photo200: String?,
- var type: String,
- var callInProgress: Boolean,
- var isPhantom: Boolean,
- var lastConversationMessageId: Int,
- var inRead: Int,
- var outRead: Int,
- var isMarkedUnread: Boolean,
- var lastMessageId: Int,
- var unreadCount: Int,
- var membersCount: Int?,
- var canChangePin: Boolean,
- var majorId: Int,
- var minorId: Int,
-
- @Embedded(prefix = "pinnedMessage_")
- var pinnedMessage: VkMessage? = null,
-
- @Embedded(prefix = "lastMessage_")
- var lastMessage: VkMessage? = null,
-) : SelectableItem(id) {
-
- @Ignore
- @IgnoredOnParcel
- val user = MutableLiveData()
-
- @Ignore
- @IgnoredOnParcel
- val group = MutableLiveData()
-
- fun isChat() = type == "chat"
- fun isUser() = type == "user"
- fun isGroup() = type == "group"
-
- fun isInUnread() = inRead - lastMessageId < 0
- fun isOutUnread() = outRead - lastMessageId < 0
-
- fun isUnread() = isInUnread() || isOutUnread()
-
- fun isAccount() = id == UserConfig.userId
-
- fun isPinned() = majorId > 0
-
-}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt
index e4b8e8fc..55373d72 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt
@@ -7,11 +7,13 @@ import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage
+import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.model.SelectableItem
import com.meloda.fast.util.TimeUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
+// TODO: 05.08.2023, Danil Nikolaev: create other class for storing in database
@Entity(tableName = "messages")
@Parcelize
data class VkMessage constructor(
@@ -38,7 +40,7 @@ data class VkMessage constructor(
var replyMessage: VkMessage? = null,
val geo: BaseVkMessage.Geo? = null,
-) : SelectableItem(id) {
+) : SelectableItem() {
@Ignore
@IgnoredOnParcel
@@ -48,6 +50,14 @@ data class VkMessage constructor(
@IgnoredOnParcel
var group: VkGroup? = null
+ @Ignore
+ @IgnoredOnParcel
+ var actionUser: VkUser? = null
+
+ @Ignore
+ @IgnoredOnParcel
+ var actionGroup: VkGroup? = null
+
@Ignore
@IgnoredOnParcel
var state: State = State.Sent
@@ -58,7 +68,7 @@ data class VkMessage constructor(
fun isGroup() = fromId < 0
- fun isRead(conversation: VkConversation) =
+ fun isRead(conversation: VkConversationDomain) =
if (isOut) {
conversation.outRead - id >= 0
} else {
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt
index 93d59131..fdd2f9e2 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt
@@ -15,11 +15,12 @@ data class VkUser(
val online: Boolean,
val photo200: String?,
val lastSeen: Int?,
- val lastSeenStatus: String?
+ val lastSeenStatus: String?,
+ val birthday: String?
) : Parcelable {
override fun toString() = fullName
val fullName get() = "$firstName $lastName".trim()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt
index c8fee250..71702bfb 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt
@@ -1,16 +1,12 @@
package com.meloda.fast.api.model.attachments
import android.os.Parcelable
-import com.meloda.fast.model.DataItem
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
-open class VkAttachment : DataItem(), Parcelable {
-
- @IgnoredOnParcel
- override val dataItemId: Int = -1
+open class VkAttachment : Parcelable {
open fun asString(withAccessKey: Boolean = true) = ""
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt
index f2283066..1427cf2f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt
@@ -25,4 +25,4 @@ data class VkAudio(
withAccessKey = withAccessKey,
accessKey = accessKey
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt
index c55143da..7a6c4002 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt
@@ -16,4 +16,4 @@ data class VkCall(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt
index b0a2089e..dd656e4d 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments
+import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkCurator(
- val id: Int
-) : VkAttachment()
\ No newline at end of file
+ val id: Int,
+) : VkAttachment() {
+
+ @IgnoredOnParcel
+ val className: String = this::class.java.name
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt
index 8a1f9801..debda085 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt
@@ -5,4 +5,4 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkEvent(
val id: Int
-) : VkAttachment()
\ No newline at end of file
+) : VkAttachment()
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt
index e1ccb61b..2766e61b 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt
@@ -27,4 +27,4 @@ data class VkFile(
withAccessKey = withAccessKey,
accessKey = accessKey
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt
index 9ce65371..2f4ddae9 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt
@@ -15,4 +15,4 @@ data class VkGraffiti(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt
index 562cf467..2a0aa581 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt
@@ -10,4 +10,4 @@ data class VkGroupCall(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt
index 8e6e2860..a65fb1f1 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt
@@ -10,4 +10,4 @@ data class VkMiniApp(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt
index ef95a6f0..8d64b274 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt
@@ -94,4 +94,4 @@ data class VkPhoto(
return null
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt
index 25c17860..4bee6a71 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt
@@ -10,4 +10,4 @@ data class VkPoll(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt
index 55e968c6..c6862706 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt
@@ -1,5 +1,6 @@
package com.meloda.fast.api.model.attachments
+import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -14,4 +15,7 @@ data class VkStory(
fun isFromGroup() = ownerId < 0
-}
\ No newline at end of file
+ @IgnoredOnParcel
+ val className: String = this::class.java.name
+
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt
index f3061a55..b73fab8c 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt
@@ -13,7 +13,7 @@ data class VkVideo(
val images: List,
val firstFrames: List?,
val accessKey: String?,
- val title: String
+ val title: String,
) : VkAttachment() {
@IgnoredOnParcel
@@ -47,11 +47,11 @@ data class VkVideo(
val width: Int,
val height: Int,
val url: String,
- val withPadding: Boolean
+ val withPadding: Boolean,
) : Parcelable {
@IgnoredOnParcel
- var shapeKind: ShapeKind
+ var shapeKind: ShapeKind? = null
init {
val ratio = width.toFloat() / height.toFloat()
@@ -64,10 +64,21 @@ data class VkVideo(
}
}
- sealed class ShapeKind {
- object Vertical : ShapeKind()
- object Horizontal : ShapeKind()
- object Square : ShapeKind()
+ open class ShapeKind(val value: Int) {
+ object Square : ShapeKind(0)
+ object Vertical : ShapeKind(1)
+ object Horizontal : ShapeKind(2)
+
+ companion object {
+
+
+ fun parse(value: Int) = when (value) {
+ 0 -> Square
+ 1 -> Vertical
+ 2 -> Horizontal
+ else -> throw IllegalArgumentException("Unknown value: $value")
+ }
+ }
}
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
@@ -78,4 +89,4 @@ data class VkVideo(
accessKey = accessKey
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt
index 4c72603b..557f4c77 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt
@@ -19,4 +19,4 @@ data class VkVoiceMessage(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt
index 68ae06be..bd24073a 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt
@@ -22,4 +22,4 @@ data class VkWall(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt
index dff2966b..110145eb 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt
@@ -10,4 +10,4 @@ data class VkWallReply(
@IgnoredOnParcel
val className: String = this::class.java.name
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt
index 51949fd0..7edb329f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments
+import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkWidget(
val id: Int
-) : VkAttachment()
+) : VkAttachment() {
+
+ @IgnoredOnParcel
+ val className: String = this::class.java.name
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt
new file mode 100644
index 00000000..1f10f4c5
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt
@@ -0,0 +1,38 @@
+package com.meloda.fast.api.model.base
+
+import android.os.Parcelable
+import com.meloda.fast.api.model.VkChat
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class BaseVkChat(
+ val type: String,
+ val title: String,
+ val admin_id: Int,
+ val members_count: Int,
+ val id: Int,
+ val photo_50: String,
+ val photo_100: String,
+ val photo_200: String,
+ val is_default_photo: Boolean,
+ val push_settings: PushSettings
+) : Parcelable {
+
+ fun asVkChat() = VkChat(
+ type = type,
+ title = title,
+ adminId = admin_id,
+ membersCount = members_count,
+ id = id,
+ photo50 = photo_50,
+ photo100 = photo_100,
+ photo200 = photo_200,
+ isDefaultPhoto = is_default_photo
+ )
+
+ @Parcelize
+ data class PushSettings(
+ val sound: Int,
+ val disabled_until: Int
+ ) : Parcelable
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt
new file mode 100644
index 00000000..139664d9
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt
@@ -0,0 +1,26 @@
+package com.meloda.fast.api.model.base
+
+import android.os.Parcelable
+import com.meloda.fast.api.model.VkChatMember
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class BaseVkChatMember(
+ val member_id: Int,
+ val invited_by: Int,
+ val join_date: Int,
+ val is_admin: Boolean?,
+ val is_owner: Boolean?,
+ val can_kick: Boolean?
+) : Parcelable {
+
+ fun asVkChatMember() = VkChatMember(
+ memberId = member_id,
+ invitedBy = invited_by,
+ joinDate = join_date,
+ isAdmin = is_admin == true,
+ isOwner = is_owner == true,
+ canKick = can_kick == true
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt
index eb2b0a6a..e76b3ea7 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt
@@ -20,7 +20,7 @@ data class BaseVkGroup(
val members_count: Int?
) : Parcelable {
- fun asVkGroup() = VkGroup(
+ fun mapToDomain() = VkGroup(
id = -id,
name = name,
screenName = screen_name,
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt
index e3cfb731..322ce4d2 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt
@@ -18,7 +18,8 @@ data class BaseVkUser(
val photo_200: String?,
val online: Int?,
val online_info: OnlineInfo?,
- val screen_name: String
+ val screen_name: String,
+ val bdate: String?
//...other fields
) : Parcelable {
@@ -32,14 +33,15 @@ data class BaseVkUser(
val app_id: Int?
) : Parcelable
- fun asVkUser() = VkUser(
+ fun mapToDomain() = VkUser(
id = id,
firstName = first_name,
lastName = last_name,
online = online == 1,
photo200 = photo_200,
lastSeen = online_info?.last_seen,
- lastSeenStatus = online_info?.status
+ lastSeenStatus = online_info?.status,
+ birthday = bdate
)
}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt
similarity index 67%
rename from app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt
rename to app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt
index 96e09aee..933f778b 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt
@@ -1,9 +1,12 @@
-package com.meloda.fast.api.model.base
+package com.meloda.fast.api.model.data
import android.os.Parcelable
-import com.meloda.fast.api.model.VkConversation
+import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
+import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.attachments.BaseVkGroupCall
+import com.meloda.fast.api.model.domain.VkConversationDomain
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -12,6 +15,8 @@ data class BaseVkConversation(
val last_message_id: Int,
val in_read: Int,
val out_read: Int,
+ val in_read_cmid: Int,
+ val out_read_cmid: Int,
val sort_id: SortId,
val last_conversation_message_id: Int,
val is_marked_unread: Boolean,
@@ -22,43 +27,20 @@ data class BaseVkConversation(
val can_receive_money: Boolean,
val chat_settings: ChatSettings?,
val call_in_progress: CallInProgress?,
- val unread_count: Int?
+ val unread_count: Int?,
) : Parcelable {
- fun asVkConversation(lastMessage: VkMessage? = null) = VkConversation(
- id = peer.id,
- title = chat_settings?.title,
- photo200 = chat_settings?.photo?.photo_200,
- type = peer.type,
- callInProgress = call_in_progress != null,
- isPhantom = chat_settings?.is_disappearing == true,
- lastConversationMessageId = last_conversation_message_id,
- inRead = in_read,
- outRead = out_read,
- isMarkedUnread = is_marked_unread,
- lastMessageId = last_message_id,
- unreadCount = unread_count ?: 0,
- membersCount = chat_settings?.members_count,
- ownerId = chat_settings?.owner_id,
- majorId = sort_id.major_id,
- minorId = sort_id.minor_id,
- canChangePin = chat_settings?.acl?.can_change_pin == true
- ).apply {
- this.lastMessage = lastMessage
- this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage()
- }
-
@Parcelize
data class Peer(
val id: Int,
val type: String,
- val local_id: Int
+ val local_id: Int,
) : Parcelable
@Parcelize
data class SortId(
val major_id: Int,
- val minor_id: Int
+ val minor_id: Int,
) : Parcelable
@Parcelize
@@ -66,12 +48,12 @@ data class BaseVkConversation(
val disabled_forever: Boolean,
val no_sound: Boolean,
val disabled_mentions: Boolean,
- val disabled_mass_mentions: Boolean
+ val disabled_mass_mentions: Boolean,
) : Parcelable
@Parcelize
data class CanWrite(
- val allowed: Boolean
+ val allowed: Boolean,
) : Parcelable
@Parcelize
@@ -89,7 +71,7 @@ data class BaseVkConversation(
val is_disappearing: Boolean,
val is_service: Boolean,
val theme: String?,
- val pinned_message: BaseVkMessage?
+ val pinned_message: BaseVkMessage?,
) : Parcelable {
@Parcelize
@@ -104,7 +86,7 @@ data class BaseVkConversation(
val can_copy_chat: Boolean,
val can_call: Boolean,
val can_use_mass_mentions: Boolean,
- val can_change_style: Boolean
+ val can_change_style: Boolean,
) : Parcelable
@Parcelize
@@ -112,21 +94,54 @@ data class BaseVkConversation(
val photo_50: String?,
val photo_100: String?,
val photo_200: String?,
- val is_default_photo: Boolean
+ val is_default_photo: Boolean,
) : Parcelable
}
@Parcelize
data class CallInProgress(
val participants: BaseVkGroupCall.Participants,
- val join_link: String
+ val join_link: String,
) : Parcelable {
@Parcelize
data class Participants(
val list: List,
- val count: Int
+ val count: Int,
) : Parcelable
}
-}
\ No newline at end of file
+
+ fun mapToDomain(
+ lastMessage: VkMessage? = null,
+ conversationUser: VkUser? = null,
+ conversationGroup: VkGroup? = null,
+ ) = VkConversationDomain(
+ id = peer.id,
+ localId = peer.local_id,
+ conversationTitle = chat_settings?.title,
+ conversationPhoto = chat_settings?.photo?.photo_200,
+ type = peer.type,
+ isCallInProgress = call_in_progress != null,
+ isPhantom = chat_settings?.is_disappearing == true,
+ lastConversationMessageId = last_conversation_message_id,
+ inRead = in_read,
+ outRead = out_read,
+ lastMessageId = last_message_id,
+ unreadCount = unread_count ?: 0,
+ membersCount = chat_settings?.members_count,
+ ownerId = chat_settings?.owner_id,
+ majorId = sort_id.major_id,
+ minorId = sort_id.minor_id,
+ canChangePin = chat_settings?.acl?.can_change_pin == true,
+ canChangeInfo = chat_settings?.acl?.can_change_info == true,
+ pinnedMessageId = chat_settings?.pinned_message?.id,
+ inReadCmId = in_read_cmid,
+ outReadCmId = out_read_cmid,
+ ).also {
+ it.lastMessage = lastMessage
+ it.pinnedMessage = chat_settings?.pinned_message?.asVkMessage()
+ it.conversationUser = conversationUser
+ it.conversationGroup = conversationGroup
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt b/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt
new file mode 100644
index 00000000..89f4d731
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt
@@ -0,0 +1,245 @@
+package com.meloda.fast.api.model.domain
+
+import android.os.Parcelable
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.PrimaryKey
+import com.meloda.fast.R
+import com.meloda.fast.api.UserConfig
+import com.meloda.fast.api.VkUtils
+import com.meloda.fast.api.model.ActionState
+import com.meloda.fast.api.model.ConversationPeerType
+import com.meloda.fast.api.model.VkGroup
+import com.meloda.fast.api.model.VkMessage
+import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.api.model.presentation.VkConversationUi
+import com.meloda.fast.common.AppGlobal
+import com.meloda.fast.ext.isFalse
+import com.meloda.fast.ext.isTrue
+import com.meloda.fast.ext.orDots
+import com.meloda.fast.model.base.UiImage
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.model.base.parseString
+import com.meloda.fast.util.TimeUtils
+import kotlinx.parcelize.IgnoredOnParcel
+import kotlinx.parcelize.Parcelize
+import java.util.Calendar
+
+@Suppress("MemberVisibilityCanBePrivate")
+@Entity(tableName = "conversations")
+@Parcelize
+data class VkConversationDomain(
+ @PrimaryKey(autoGenerate = false)
+ val id: Int,
+ val localId: Int,
+ val ownerId: Int?,
+ val conversationTitle: String?,
+ val conversationPhoto: String?,
+ val isCallInProgress: Boolean,
+ val isPhantom: Boolean,
+ val lastConversationMessageId: Int,
+ val inReadCmId: Int,
+ val outReadCmId: Int,
+ val inRead: Int,
+ val outRead: Int,
+ val lastMessageId: Int,
+ val unreadCount: Int,
+ val membersCount: Int?,
+ val canChangePin: Boolean,
+ val canChangeInfo: Boolean,
+ val majorId: Int,
+ val minorId: Int,
+ val pinnedMessageId: Int?,
+ val type: String,
+) : Parcelable {
+
+ @Ignore
+ @IgnoredOnParcel
+ var peerType: ConversationPeerType = ConversationPeerType.parse(type)
+
+ @Ignore
+ @IgnoredOnParcel
+ var lastMessage: VkMessage? = null
+
+ @Ignore
+ @IgnoredOnParcel
+ var pinnedMessage: VkMessage? = null
+
+ @Ignore
+ @IgnoredOnParcel
+ var conversationUser: VkUser? = null
+
+ @Ignore
+ @IgnoredOnParcel
+ var conversationGroup: VkGroup? = null
+
+ fun isChat() = peerType.isChat()
+ fun isUser() = peerType.isUser()
+ fun isGroup() = peerType.isGroup()
+
+ fun isInUnread() = inRead - lastMessageId < 0
+ fun isOutUnread() = outRead - lastMessageId < 0
+
+ fun isUnread() = isInUnread() || isOutUnread()
+
+ fun isAccount() = id == UserConfig.userId
+
+ fun isPinned() = majorId > 0
+
+ fun extractAvatar(): UiImage {
+ val placeholderImage = UiImage.Resource(R.drawable.ic_account_circle_cut)
+
+ val avatarLink = when {
+ peerType.isUser() -> {
+ if (id == UserConfig.userId) {
+ null
+ } else {
+ conversationUser?.photo200
+ }
+ }
+
+ peerType.isGroup() -> conversationGroup?.photo200
+ peerType.isChat() -> conversationPhoto
+ else -> null
+ }
+
+ return avatarLink?.let(UiImage::Url) ?: placeholderImage
+ }
+
+ fun extractTitle(): UiText {
+ return when {
+ isAccount() -> UiText.Resource(R.string.favorites)
+ peerType.isChat() -> UiText.Simple(conversationTitle ?: "...")
+ peerType.isUser() -> UiText.Simple(conversationUser?.fullName ?: "...")
+ peerType.isGroup() -> UiText.Simple(conversationGroup?.name ?: "...")
+ else -> UiText.Simple("...")
+ }
+ }
+
+ fun extractUnreadCounterText(): String? {
+ if (lastMessage?.isOut.isFalse && !isInUnread()) return null
+
+ return when (unreadCount) {
+ in 1..999 -> unreadCount.toString()
+ 0 -> null
+ else -> "%dK".format(unreadCount / 1000)
+ }
+ }
+
+ // TODO: 07.01.2023, Danil Nikolaev: rewrite
+ fun extractMessage(): String {
+ val actionMessage = VkUtils.getActionConversationText(
+ message = lastMessage,
+ youPrefix = "You",
+ messageUser = lastMessage?.user,
+ messageGroup = lastMessage?.group,
+ action = lastMessage?.getPreparedAction(),
+ actionUser = lastMessage?.actionUser,
+ actionGroup = lastMessage?.actionGroup
+ )
+
+ val attachmentIcon: UiImage? = when {
+ lastMessage?.text == null -> null
+ !lastMessage?.forwards.isNullOrEmpty() -> {
+ if (lastMessage?.forwards?.size == 1) {
+ UiImage.Resource(R.drawable.ic_attachment_forwarded_message)
+ } else {
+ UiImage.Resource(R.drawable.ic_attachment_forwarded_messages)
+ }
+ }
+
+ else -> VkUtils.getAttachmentConversationIcon(lastMessage)
+ }
+
+ val attachmentText = (if (attachmentIcon == null) VkUtils.getAttachmentText(
+ message = lastMessage
+ ) else null)
+
+ val forwardsMessage = (if (lastMessage?.text == null) VkUtils.getForwardsText(
+ message = lastMessage
+ ) else null)
+
+ val messageText = lastMessage?.text?.let(UiText::Simple)
+
+ var prefix = when {
+ actionMessage != null -> ""
+ lastMessage?.isOut.isTrue -> "You: "
+ else ->
+ when {
+ lastMessage?.user != null && lastMessage?.user?.firstName?.isNotBlank().isTrue -> {
+ "${lastMessage?.user?.firstName}: "
+ }
+
+ lastMessage?.group != null && lastMessage?.group?.name?.isNotBlank().isTrue -> {
+ "${lastMessage?.group?.name}: "
+ }
+
+ else -> ""
+ }
+ }
+
+ if ((!peerType.isChat() && lastMessage?.isOut.isFalse) || id == UserConfig.userId)
+ prefix = ""
+
+ val finalText =
+ (actionMessage ?: forwardsMessage ?: attachmentText ?: messageText)
+ ?.parseString(AppGlobal.Instance)
+ ?.let(VkUtils::prepareMessageText)
+ ?.let { text -> "$prefix$text" }
+
+
+ return finalText.orDots()
+ }
+
+ fun extractAttachmentImage(): UiImage? {
+ if (lastMessage?.text == null) return null
+ return VkUtils.getAttachmentConversationIcon(lastMessage)
+ }
+
+ fun extractReadCondition(): Boolean {
+ return (lastMessage?.isOut.isTrue && isOutUnread()) ||
+ (lastMessage?.isOut.isFalse && isInUnread())
+ }
+
+ fun extractDate(): String {
+ return TimeUtils.getLocalizedTime(AppGlobal.Instance, (lastMessage?.date ?: -1) * 1000L)
+ }
+
+ // TODO: 05.08.2023, Danil Nikolaev: rewrite
+ fun extractBirthday(): Boolean {
+ val birthday = conversationUser?.birthday ?: return false
+ val splitBirthday = birthday.split(".")
+
+ return if (splitBirthday.size > 1) {
+ val birthdayCalendar = Calendar.getInstance().apply {
+ this[Calendar.DAY_OF_MONTH] = splitBirthday.first().toIntOrNull() ?: -1
+ this[Calendar.MONTH] = (splitBirthday[1].toIntOrNull() ?: 0) - 1
+ }
+ val nowCalendar = Calendar.getInstance()
+
+ (nowCalendar[Calendar.DAY_OF_MONTH] == birthdayCalendar[Calendar.DAY_OF_MONTH]
+ && nowCalendar[Calendar.MONTH] == birthdayCalendar[Calendar.MONTH])
+ } else false
+ }
+
+ fun mapToPresentation() = VkConversationUi(
+ conversationId = id,
+ lastMessageId = lastMessageId,
+ avatar = extractAvatar(),
+ title = extractTitle(),
+ unreadCount = extractUnreadCounterText(),
+ date = extractDate(),
+ message = extractMessage(),
+ attachmentImage = extractAttachmentImage(),
+ isPinned = majorId > 0,
+ actionState = ActionState.parse(isPhantom, isCallInProgress),
+ isBirthday = extractBirthday(),
+ isUnread = extractReadCondition(),
+ isAccount = isAccount(),
+ isOnline = !isAccount() && conversationUser?.online == true,
+ lastMessage = lastMessage,
+ conversationUser = conversationUser,
+ conversationGroup = conversationGroup,
+ peerType = peerType
+ )
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt b/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt
new file mode 100644
index 00000000..2f731153
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt
@@ -0,0 +1,33 @@
+package com.meloda.fast.api.model.presentation
+
+import com.meloda.fast.api.model.ActionState
+import com.meloda.fast.api.model.ConversationPeerType
+import com.meloda.fast.api.model.VkGroup
+import com.meloda.fast.api.model.VkMessage
+import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.model.base.AdapterDiffItem
+import com.meloda.fast.model.base.UiImage
+import com.meloda.fast.model.base.UiText
+
+data class VkConversationUi(
+ val conversationId: Int,
+ val lastMessageId: Int,
+ val avatar: UiImage,
+ val title: UiText,
+ val unreadCount: String?,
+ val date: String,
+ val message: String,
+ val attachmentImage: UiImage?,
+ val isPinned: Boolean,
+ val actionState: ActionState,
+ val isBirthday: Boolean,
+ val isUnread: Boolean,
+ val isAccount: Boolean,
+ val isOnline: Boolean,
+ val lastMessage: VkMessage?,
+ val conversationUser: VkUser?,
+ val conversationGroup: VkGroup?,
+ val peerType: ConversationPeerType,
+) : AdapterDiffItem {
+ override val id = conversationId
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt
index 9b760e70..057ebb6c 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt
@@ -43,9 +43,10 @@ object VkErrorCodes {
const val InvalidDocId = 1150
const val InvalidDocTitle = 1152
const val AccessToDocDenied = 1153
+
+ const val AccessTokenExpired = 1117
}
-@Suppress("unused")
object VkErrors {
const val Unknown = "unknown_error"
@@ -55,7 +56,18 @@ object VkErrors {
}
-class AuthorizationError : ApiError()
+object VkErrorTypes {
+ const val OtpFormatIncorrect = "otp_format_is_incorrect"
+ const val WrongOtp = "wrong_otp"
+}
+
+object VkErrorMessages {
+ const val UserBanned = "user has been banned"
+}
+
+open class AuthorizationError : ApiError()
+
+class TokenExpiredError : AuthorizationError()
data class ValidationRequiredError(
@SerializedName("validation_type")
@@ -75,4 +87,24 @@ data class CaptchaRequiredError(
val captchaSid: String,
@SerializedName("captcha_img")
val captchaImg: String
-) : ApiError()
\ No newline at end of file
+) : ApiError()
+
+object WrongTwoFaCodeFormatError : ApiError()
+
+object WrongTwoFaCodeError : ApiError()
+
+data class UserBannedError(
+ @SerializedName("ban_info")
+ val banInfo: BanInfo
+) : ApiError() {
+
+ data class BanInfo(
+ @SerializedName("member_name")
+ val memberName: String,
+ val message: String,
+ @SerializedName("access_token")
+ val accessToken: String,
+ @SerializedName("restore_url")
+ val restoreUrl: String
+ )
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt
index 1c740012..32c4ca2f 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt
@@ -3,6 +3,7 @@ package com.meloda.fast.api.network
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.network.account.AccountUrls
+import com.meloda.fast.api.network.ota.OtaUrls
import okhttp3.Interceptor
import okhttp3.Response
import java.net.URLEncoder
@@ -14,7 +15,7 @@ class AuthInterceptor : Interceptor {
val url = builder.build().toUrl().toString()
- if (!url.contains("upload.php")) {
+ if (!url.contains("upload.php") && !url.contains(OtaUrls.GetActualUrl)) {
builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8"))
}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt
index db06da8d..2cde43e2 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt
@@ -14,11 +14,11 @@ import java.lang.reflect.Type
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
-class ResultCallFactory : CallAdapter.Factory() {
+class ResultCallFactory(private val gson: Gson) : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array,
- retrofit: Retrofit
+ retrofit: Retrofit,
): CallAdapter<*, *>? {
val rawReturnType: Class<*> = getRawType(returnType)
if (rawReturnType == Call::class.java) {
@@ -27,9 +27,9 @@ class ResultCallFactory : CallAdapter.Factory() {
if (getRawType(callInnerType) == ApiAnswer::class.java) {
if (callInnerType is ParameterizedType) {
val resultInnerType = getParameterUpperBound(0, callInnerType)
- return ResultCallAdapter(resultInnerType)
+ return ResultCallAdapter(resultInnerType, gson)
}
- return ResultCallAdapter(Nothing::class.java)
+ return ResultCallAdapter(Nothing::class.java, gson)
}
}
}
@@ -58,30 +58,29 @@ internal abstract class CallDelegate(protected val proxy: Call) : C
abstract fun cloneImpl(): Call
}
-private class ResultCallAdapter(private val type: Type) : CallAdapter>> {
+private class ResultCallAdapter(private val type: Type, private val gson: Gson) : CallAdapter>> {
override fun responseType() = type
- override fun adapt(call: Call): Call> = ResultCall(call)
+ override fun adapt(call: Call): Call> = ResultCall(call, gson)
}
-internal class ResultCall(proxy: Call) : CallDelegate>(proxy) {
+internal class ResultCall(proxy: Call, private val gson: Gson) : CallDelegate>(proxy) {
override fun enqueueImpl(callback: Callback>) {
- proxy.enqueue(ResultCallback(this, callback))
+ proxy.enqueue(ResultCallback(this, callback, gson))
}
override fun cloneImpl(): ResultCall {
- return ResultCall(proxy.clone())
+ return ResultCall(proxy.clone(), gson)
}
private class ResultCallback(
private val proxy: ResultCall,
- private val callback: Callback>
+ private val callback: Callback>,
+ private val gson: Gson
) : Callback {
- val gson = Gson()
-
override fun onResponse(call: Call, response: Response) {
val result: ApiAnswer =
if (response.isSuccessful) {
@@ -117,13 +116,11 @@ internal class ResultCall(proxy: Call) : CallDelegate>(pro
}
private fun checkErrors(call: Call, result: ApiAnswer<*>): Boolean {
- if (!result.isSuccessful()) {
+ if (result.isError()) {
result.error.throwable?.run {
onFailure(call, this)
return true
}
- } else {
- return false
}
return false
@@ -143,8 +140,16 @@ sealed class ApiAnswer {
@OptIn(ExperimentalContracts::class)
fun isSuccessful(): Boolean {
contract {
- returns(false) implies (this@ApiAnswer is Error)
+ returns(true) implies (this@ApiAnswer is Success)
}
return this is Success
}
-}
\ No newline at end of file
+
+ @OptIn(ExperimentalContracts::class)
+ fun isError(): Boolean {
+ contract {
+ returns(true) implies (this@ApiAnswer is Error)
+ }
+ return this is Error
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt
index dcbcb39c..a38e540e 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt
@@ -4,37 +4,6 @@ object VkUrls {
const val OAUTH = "https://oauth.vk.com"
const val API = "https://api.vk.com/method"
-
- object Auth {
- const val DirectAuth = "$OAUTH/token"
- const val SendSms = "$API/auth.validatePhone"
- }
-
- object Conversations {
- const val Get = "$API/messages.getConversations"
- const val Delete = "$API/messages.deleteConversation"
- const val Pin = "$API/messages.pinConversation"
- const val Unpin = "$API/messages.unpinConversation"
- const val ReorderPinned = "$API/messages.reorderPinnedConversations"
- }
-
- object Users {
- const val GetById = "$API/users.get"
- }
-
- object Messages {
- const val GetHistory = "$API/messages.getHistory"
- const val Send = "$API/messages.send"
- const val MarkAsImportant = "$API/messages.markAsImportant"
- const val GetLongPollServer = "$API/messages.getLongPollServer"
- const val GetLongPollHistory = "$API/messages.getLongPollHistory"
- const val Pin = "$API/messages.pin"
- const val Unpin = "$API/messages.unpin"
- const val Delete = "$API/messages.delete"
- const val Edit = "$API/messages.edit"
- }
-
-
}
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt
index 6e66db70..f206f415 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt
@@ -6,10 +6,15 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class AuthDirectResponse(
- @SerializedName("access_token") val accessToken: String? = null,
- @SerializedName("user_id") val userId: Int? = null,
- @SerializedName("trusted_hash") val twoFaHash: String? = null,
- @SerializedName("validation_sid") val validationSid: String? = null
+ @SerializedName("access_token") val accessToken: String?,
+ @SerializedName("user_id") val userId: Int?,
+ @SerializedName("trusted_hash") val twoFaHash: String?,
+ @SerializedName("validation_sid") val validationSid: String?,
+ @SerializedName("validation_type") val validationType: String?,
+ @SerializedName("phone_mask") val phoneMask: String?,
+ @SerializedName("redirect_uri") val redirectUrl: String?,
+ @SerializedName("validation_resend") val validationResend: String?,
+ @SerializedName("cant_get_code_open_restore") val isCanNotGetCodeNeedToOpenRestore: Boolean
) : Parcelable
@Parcelize
@@ -18,4 +23,4 @@ data class SendSmsResponse(
@SerializedName("delay") val delay: Int?,
@SerializedName("validation_type") val validationType: String?,
@SerializedName("validation_resend") val validationResend: String?
-) : Parcelable
\ No newline at end of file
+) : Parcelable
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt
index c49d24e1..c8f461a4 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt
@@ -2,7 +2,7 @@ package com.meloda.fast.api.network.conversations
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
-import com.meloda.fast.api.model.base.BaseVkConversation
+import com.meloda.fast.api.model.data.BaseVkConversation
import com.meloda.fast.api.model.base.BaseVkGroup
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.BaseVkUser
@@ -23,4 +23,4 @@ data class ConversationsResponseItems(
val conversation: BaseVkConversation,
@SerializedName("last_message")
val lastMessage: BaseVkMessage?
-) : Parcelable
\ No newline at end of file
+) : Parcelable
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt
index 5e619b27..2c71ae35 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt
@@ -189,5 +189,51 @@ data class MessagesGetByIdRequest(
extended?.let { this["extended"] = it.intString }
fields?.let { this["fields"] = it }
}
+}
+@Parcelize
+data class MessagesGetChatRequest(
+ val chatId: Int,
+ val fields: String? = null
+) : Parcelable {
+
+ val map
+ get() = mutableMapOf(
+ "chat_id" to chatId.toString()
+ ).apply {
+ fields?.let { this["fields"] = it }
+ }
+}
+
+@Parcelize
+data class MessagesGetConversationMembersRequest(
+ val peerId: Int,
+ val offset: Int? = null,
+ val count: Int? = null,
+ val extended: Boolean? = null,
+ val fields: String? = null
+) : Parcelable {
+
+ val map
+ get() = mutableMapOf(
+ "peer_id" to peerId.toString()
+ ).apply {
+ offset?.let { this["offset"] = it.toString() }
+ count?.let { this["count"] = it.toString() }
+ extended?.let { this["extended"] = it.toString() }
+ fields?.let { this["fields"] = it }
+ }
+
+}
+
+@Parcelize
+data class MessagesRemoveChatUserRequest(
+ val chatId: Int,
+ val memberId: Int
+) : Parcelable {
+ val map
+ get() = mutableMapOf(
+ "chat_id" to chatId.toString(),
+ "member_id" to memberId.toString()
+ )
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt
index b881d145..86dd9a6a 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt
@@ -1,10 +1,8 @@
package com.meloda.fast.api.network.messages
import android.os.Parcelable
-import com.meloda.fast.api.model.base.BaseVkConversation
-import com.meloda.fast.api.model.base.BaseVkGroup
-import com.meloda.fast.api.model.base.BaseVkMessage
-import com.meloda.fast.api.model.base.BaseVkUser
+import com.meloda.fast.api.model.base.*
+import com.meloda.fast.api.model.data.BaseVkConversation
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -22,4 +20,12 @@ data class MessagesGetByIdResponse(
val items: List = emptyList(),
val profiles: List?,
val groups: List?
-) : Parcelable
\ No newline at end of file
+) : Parcelable
+
+@Parcelize
+data class MessagesGetConversationMembersResponse(
+ val count: Int,
+ val items: List = emptyList(),
+ val profiles: List?,
+ val groups: List?
+) : Parcelable
diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt
index 66c3acd2..829c76c6 100644
--- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt
+++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt
@@ -15,5 +15,8 @@ object MessagesUrls {
const val Edit = "${VkUrls.API}/messages.edit"
const val GetById = "${VkUrls.API}/messages.getById"
const val MarkAsRead = "${VkUrls.API}/messages.markAsRead"
+ const val GetChat = "${VkUrls.API}/messages.getChat"
+ const val GetConversationMembers = "${VkUrls.API}/messages.getConversationMembers"
+ const val RemoveChatUser = "${VkUrls.API}/messages.removeChatUser"
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt
index dba3f764..3113c485 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt
@@ -8,5 +8,4 @@ abstract class BaseActivity : AppCompatActivity {
constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId)
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt
index 867e7485..20d217db 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt
@@ -1,47 +1,11 @@
package com.meloda.fast.base
-import android.os.Bundle
-import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
-import com.meloda.fast.screens.main.MainActivity
abstract class BaseFragment : Fragment {
constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId)
-
- protected var shouldNavBarShown: Boolean = true
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- if (arguments == null) arguments = Bundle()
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- (requireActivity() as? MainActivity)?.run {
- toggleNavBarVisibility(shouldNavBarShown)
- }
- }
-
- val activityRouter
- get() = run {
- if (requireActivity() is MainActivity) {
- (requireActivity() as MainActivity).router
- } else {
- null
- }
- }
-
- fun requireActivityRouter() = requireNotNull(activityRouter)
-
- fun startProgress() = toggleProgress(true)
- fun stopProgress() = toggleProgress(false)
-
- protected open fun toggleProgress(isProgressing: Boolean) {}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt
new file mode 100644
index 00000000..9167b253
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt
@@ -0,0 +1,52 @@
+package com.meloda.fast.base.adapter
+
+import androidx.recyclerview.widget.DiffUtil
+import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import com.meloda.fast.model.base.AdapterDiffItem
+
+class AsyncDiffItemAdapter(
+ customDiffCallback: DiffUtil.ItemCallback? = null,
+ vararg delegates: AdapterDelegate>,
+) : AsyncListDifferDelegationAdapter(customDiffCallback ?: DIFF_CALLBACK) {
+
+ constructor(
+ vararg delegates: AdapterDelegate>,
+ ) : this(customDiffCallback = null) {
+ delegates.forEach(::addDelegate)
+ }
+
+ init {
+ delegates.forEach(::addDelegate)
+ }
+
+ fun addDelegates(vararg delegates: AdapterDelegate>) {
+ delegates.forEach(::addDelegate)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun addDelegate(delegate: AdapterDelegate>) {
+ (delegate as? AdapterDelegate>)?.let(delegatesManager::addDelegate)
+ }
+
+ fun isEmpty() = itemCount == 0
+ fun isNotEmpty() = itemCount > 0
+
+ companion object {
+ val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: AdapterDiffItem,
+ newItem: AdapterDiffItem,
+ ): Boolean {
+ return oldItem.areItemsTheSame(newItem)
+ }
+
+ override fun areContentsTheSame(
+ oldItem: AdapterDiffItem,
+ newItem: AdapterDiffItem,
+ ): Boolean {
+ return oldItem.areContentsTheSame(newItem)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt
index 0aea22ec..22ac45b8 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt
@@ -9,12 +9,11 @@ import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
-import com.meloda.fast.model.DataItem
import kotlinx.coroutines.*
import kotlin.properties.Delegates
@Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST")
-abstract class BaseAdapter, VH : BaseHolder> constructor(
+abstract class BaseAdapter constructor(
var context: Context,
diffUtil: DiffUtil.ItemCallback,
preAddedValues: List = emptyList(),
@@ -59,27 +58,18 @@ abstract class BaseAdapter, VH : BaseHolder> constructor(
fun add(
item: T,
position: Int? = null,
- beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
- ) = addAll(listOf(item), position, beforeFooter, commitCallback)
+ ) = addAll(listOf(item), position, commitCallback)
fun addAll(
items: List,
position: Int? = null,
- beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) {
adapterScope.launch {
val newList = cloneCurrentList()
if (position == null) {
val mutableItems = items.toMutableList()
- if (beforeFooter && newList.lastOrNull() is DataItem.Footer) {
- newList.removeLastOrNull()
- }
-
- if (beforeFooter) {
- mutableItems += DataItem.Footer as T
- }
newList.addAll(mutableItems)
cleanList.addAll(mutableItems)
@@ -100,40 +90,34 @@ abstract class BaseAdapter, VH : BaseHolder> constructor(
fun removeAll(items: List, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList.removeAll(items)
- submitList(newList, commitCallback)
-
cleanList.removeAll(items)
+
+ submitList(newList, commitCallback)
}
fun removeAt(index: Int, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList.removeAt(index)
- submitList(newList, commitCallback)
-
cleanList.removeAt(index)
+
+ submitList(newList, commitCallback)
}
fun clear(commitCallback: (() -> Unit)? = null) = removeAll(currentList, commitCallback)
fun setItem(
item: T,
- withHeader: Boolean = false,
- withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
- ) = setItems(listOf(item), withHeader, withFooter, commitCallback)
+ ) = setItems(listOf(item), commitCallback)
@Suppress("UNCHECKED_CAST")
fun setItems(
list: List?,
- withHeader: Boolean = false,
- withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) {
adapterScope.launch {
val items = mutableListOf()
- if (withHeader) items.add(DataItem.Header as T)
if (!list.isNullOrEmpty()) items.addAll(list)
- if (withFooter) items.add(DataItem.Footer as T)
withContext(Dispatchers.Main) {
if (items == currentList) {
@@ -165,9 +149,9 @@ abstract class BaseAdapter, VH : BaseHolder> constructor(
fun setItem(position: Int, item: T, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList[position] = item
- submitList(newList, commitCallback)
-
cleanList[position] = item
+
+ submitList(newList, commitCallback)
}
fun isEmpty() = currentList.isEmpty()
diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt
deleted file mode 100644
index 7fa4b4ac..00000000
--- a/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.meloda.fast.base.adapter
-
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.view.isInvisible
-import androidx.recyclerview.widget.RecyclerView
-import com.meloda.fast.extensions.dpToPx
-import com.meloda.fast.util.AndroidUtils
-import kotlin.math.roundToInt
-
-class EmptyHeaderAdapter(
- var context: Context
-) : RecyclerView.Adapter() {
-
- inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView)
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(generateHeaderView())
-
- override fun onBindViewHolder(holder: Holder, position: Int) {
- }
-
- override fun getItemCount() = 1
-
- private fun generateHeaderView() = View(context).apply {
- layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- 56.dpToPx()
- )
- isClickable = false
- isEnabled = false
- isFocusable = false
- isInvisible = true
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt
index 5fdbccb7..204ec669 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt
@@ -1,9 +1,9 @@
package com.meloda.fast.base.adapter
-interface OnItemClickListener {
- fun onItemClick(position: Int)
+fun interface OnItemClickListener {
+ fun onItemClick(item: T)
}
-interface OnItemLongClickListener {
- fun onItemLongClick(position: Int)
-}
\ No newline at end of file
+fun interface OnItemLongClickListener {
+ fun onLongItemClick(item: T): Boolean
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt b/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt
new file mode 100644
index 00000000..2fd269af
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt
@@ -0,0 +1,23 @@
+package com.meloda.fast.base.screen
+
+import com.github.terrakok.cicerone.Router
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+interface AppScreen {
+ val resultFlow: MutableSharedFlow
+
+ var args: ArgType
+
+ fun show(router: Router, args: ArgType)
+
+ fun getArguments(): ArgType = args
+}
+
+@Suppress("unused")
+fun AppScreen.createResultFlow(): MutableSharedFlow {
+ return MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt
index 7388207c..9509b8c4 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt
@@ -1,110 +1,97 @@
package com.meloda.fast.base.viewmodel
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.base.ApiError
-import com.meloda.fast.api.network.ApiAnswer
-import com.meloda.fast.api.network.AuthorizationError
-import com.meloda.fast.api.network.CaptchaRequiredError
-import com.meloda.fast.api.network.ValidationRequiredError
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.coroutines.launch
+import com.meloda.fast.api.network.*
+import com.meloda.fast.ext.isTrue
+import com.meloda.fast.ext.notNull
-@Suppress("MemberVisibilityCanBePrivate")
abstract class BaseViewModel : ViewModel() {
- var unknownErrorDefaultText: String = ""
+ open suspend fun sendSingleEvent(event: VkEvent) {}
- protected val tasksEventChannel = Channel()
- val tasksEvent = tasksEventChannel.receiveAsFlow()
+ suspend fun sendRequestNotNull(
+ onError: ErrorHandler? = null,
+ request: suspend () -> ApiAnswer
+ ): T = sendRequest(onError, request).notNull()
- protected val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
- viewModelScope.launch { onException(throwable) }
- }
-
- fun launch(block: suspend CoroutineScope.() -> Unit): Job {
- return viewModelScope.launch(exceptionHandler, block = block)
- }
-
- protected suspend fun makeSuspendJob(
- job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {},
- onStart: (suspend () -> Unit)? = null,
- onEnd: (suspend () -> Unit)? = null,
- onError: (suspend (Throwable) -> Unit)? = null
- ): ApiAnswer {
- onStart?.invoke() ?: onStart()
- val response = job()
-
- when (response) {
- is ApiAnswer.Success -> onAnswer(response.data)
+ suspend fun sendRequest(
+ onError: ErrorHandler? = null,
+ request: suspend () -> ApiAnswer,
+ ): T? {
+ return when (val response = request()) {
+ is ApiAnswer.Success -> response.data
is ApiAnswer.Error -> {
- onError?.invoke(response.error) ?: checkErrors(response.error)
- }
- }
+ val error = response.error
- onEnd?.invoke()
+ if (!onError?.handleError(error).isTrue) {
+ checkErrors(error)
+ }
- return response
- }
-
- protected fun makeJob(
- job: suspend () -> ApiAnswer,
- onAnswer: suspend (T) -> Unit = {},
- onStart: (suspend () -> Unit)? = null,
- onEnd: (suspend () -> Unit)? = null,
- onError: (suspend (Throwable) -> Unit)? = null
- ): Job = viewModelScope.launch {
- onStart?.invoke() ?: onStart()
- when (val response = job()) {
- is ApiAnswer.Success -> onAnswer(response.data)
- is ApiAnswer.Error -> {
- onError?.invoke(response.error) ?: checkErrors(response.error)
- }
- }
- }.also {
- it.invokeOnCompletion {
- viewModelScope.launch {
- onEnd?.invoke() ?: onStop()
+ null
}
}
}
- protected open suspend fun onException(throwable: Throwable) {
- checkErrors(throwable)
- }
-
- protected suspend fun onStart() {
- sendEvent(StartProgressEvent)
- }
-
- protected suspend fun onStop() {
- sendEvent(StopProgressEvent)
- }
-
- protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event)
-
protected suspend fun checkErrors(throwable: Throwable) {
when (throwable) {
+ is TokenExpiredError -> {
+ sendSingleEvent(TokenExpiredErrorEvent)
+ }
is AuthorizationError -> {
- sendEvent(AuthorizationErrorEvent)
+ sendSingleEvent(AuthorizationErrorEvent)
+ }
+ is UserBannedError -> {
+ throwable.banInfo.let { banInfo ->
+ sendSingleEvent(
+ UserBannedEvent(
+ memberName = banInfo.memberName,
+ message = banInfo.message,
+ restoreUrl = banInfo.restoreUrl,
+ accessToken = banInfo.accessToken
+ )
+ )
+ }
}
is ValidationRequiredError -> {
- sendEvent(ValidationRequiredEvent(throwable.validationSid))
+ sendSingleEvent(
+ ValidationRequiredEvent(
+ sid = throwable.validationSid,
+ redirectUri = throwable.redirectUri,
+ phoneMask = throwable.phoneMask,
+ validationType = throwable.validationType,
+ canResendSms = throwable.validationResend == "sms",
+ codeError = null
+ )
+ )
}
is CaptchaRequiredError -> {
- sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg))
+ sendSingleEvent(
+ CaptchaRequiredEvent(
+ sid = throwable.captchaSid,
+ image = throwable.captchaImg
+ )
+ )
}
+
is ApiError -> {
- sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText))
+ sendSingleEvent(
+ if (throwable.errorMessage == null) {
+ UnknownErrorEvent
+ } else {
+ ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage))
+ }
+ )
}
else -> {
- sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText))
+ sendSingleEvent(
+ if (throwable.message == null) {
+ UnknownErrorEvent
+ } else {
+ ErrorTextEvent(requireNotNull(throwable.message))
+ }
+ )
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt
index de979b7b..cb9f8b38 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt
@@ -5,10 +5,10 @@ import android.view.View
import androidx.annotation.LayoutRes
import androidx.lifecycle.lifecycleScope
import com.meloda.fast.base.BaseFragment
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
-abstract class BaseViewModelFragment : BaseFragment {
+@Deprecated("", ReplaceWith("BaseFragment"))
+abstract class BaseViewModelFragment : BaseFragment {
constructor() : super()
@@ -25,10 +25,10 @@ abstract class BaseViewModelFragment : BaseFragment {
ViewModelUtils.parseEvent(this, event)
}
- protected fun subscribeToViewModel(viewModel: T) {
+ protected fun subscribeToViewModel(viewModel: T) {
lifecycleScope.launch {
viewModel.tasksEvent.collect { onEvent(it) }
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt
new file mode 100644
index 00000000..b88206ac
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt
@@ -0,0 +1,139 @@
+package com.meloda.fast.base.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.meloda.fast.api.base.ApiError
+import com.meloda.fast.api.network.*
+import com.meloda.fast.ext.isTrue
+import com.meloda.fast.ext.notNull
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+@Deprecated("rewrite")
+abstract class DeprecatedBaseViewModel : ViewModel() {
+
+ private val tasksEventChannel = Channel()
+ val tasksEvent = tasksEventChannel.receiveAsFlow()
+
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ viewModelScope.launch { onException(throwable) }
+ }
+
+ fun launch(block: suspend CoroutineScope.() -> Unit): Job {
+ return viewModelScope.launch(exceptionHandler, block = block)
+ }
+
+ suspend fun sendRequestNotNull(
+ onError: ErrorHandler? = null,
+ request: suspend () -> ApiAnswer
+ ): T = sendRequest(onError, request).notNull()
+
+ suspend fun sendRequest(
+ onError: ErrorHandler? = null,
+ request: suspend () -> ApiAnswer,
+ ): T? {
+ return when (val response = request()) {
+ is ApiAnswer.Success -> response.data
+ is ApiAnswer.Error -> {
+ val error = response.error
+
+ if (!onError?.handleError(error).isTrue) {
+ checkErrors(error)
+ }
+
+ null
+ }
+ }
+ }
+
+ // TODO: 05.04.2023, Danil Nikolaev: переписать makeJob на sendRequest (oh boy, писать дохуя)
+ // TODO: 05.04.2023, Danil Nikolaev: переписать Conversations Screen на новую архитектуру, пока что оставить View
+
+ protected fun makeJob(
+ job: suspend () -> ApiAnswer,
+ onAnswer: suspend (T) -> Unit = {},
+ onStart: (suspend () -> Unit)? = null,
+ onEnd: (suspend () -> Unit)? = null,
+ onError: (suspend (Throwable) -> Unit)? = null,
+ onAnyResult: (suspend () -> Unit)? = null,
+ ): Job = viewModelScope.launch {
+ onStart?.invoke()
+ when (val response = job()) {
+ is ApiAnswer.Success -> {
+ onAnswer(response.data)
+ onAnyResult?.invoke()
+ }
+ is ApiAnswer.Error -> {
+ onError?.invoke(response.error) ?: checkErrors(response.error)
+ onAnyResult?.invoke()
+ }
+ }
+ }.also {
+ it.invokeOnCompletion {
+ viewModelScope.launch {
+ onEnd?.invoke()
+ }
+ }
+ }
+
+ protected open suspend fun onException(throwable: Throwable) {
+ checkErrors(throwable)
+ }
+
+ protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event)
+
+ protected suspend fun checkErrors(throwable: Throwable) {
+ when (throwable) {
+ is TokenExpiredError -> sendEvent(TokenExpiredErrorEvent)
+ is AuthorizationError -> sendEvent(AuthorizationErrorEvent)
+ is UserBannedError -> {
+ val banInfo = throwable.banInfo
+ sendEvent(
+ UserBannedEvent(
+ memberName = banInfo.memberName,
+ message = banInfo.message,
+ restoreUrl = banInfo.restoreUrl,
+ accessToken = banInfo.accessToken
+ )
+ )
+ }
+ is ValidationRequiredError -> {
+ sendEvent(
+ ValidationRequiredEvent(
+ sid = throwable.validationSid,
+ redirectUri = throwable.redirectUri,
+ phoneMask = throwable.phoneMask,
+ validationType = throwable.validationType,
+ canResendSms = throwable.validationResend == "sms",
+ codeError = null
+ )
+ )
+ }
+ is CaptchaRequiredError -> sendEvent(
+ CaptchaRequiredEvent(
+ sid = throwable.captchaSid,
+ image = throwable.captchaImg
+ )
+ )
+
+ is ApiError -> sendEvent(
+ if (throwable.errorMessage == null) {
+ UnknownErrorEvent
+ } else {
+ ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage))
+ }
+ )
+ else -> sendEvent(
+ if (throwable.message == null) {
+ UnknownErrorEvent
+ } else {
+ ErrorTextEvent(requireNotNull(throwable.message))
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt
new file mode 100644
index 00000000..aa702965
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt
@@ -0,0 +1,9 @@
+package com.meloda.fast.base.viewmodel
+
+fun interface ErrorHandler {
+
+ /**
+ * @return true if error has been handled manually
+ */
+ suspend fun handleError(error: Throwable): Boolean
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt
index b958c069..02acae53 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt
@@ -1,18 +1,30 @@
package com.meloda.fast.base.viewmodel
-abstract class VkEvent
-abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
-abstract class VkProgressEvent : VkEvent()
+import com.meloda.fast.model.base.UiText
+abstract class VkEvent
+
+abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
+
+object UnknownErrorEvent : VkErrorEvent()
open class ErrorTextEvent(override val errorText: String) : VkErrorEvent()
object AuthorizationErrorEvent : VkErrorEvent()
+object TokenExpiredErrorEvent : VkErrorEvent()
data class CaptchaRequiredEvent(val sid: String, val image: String) : VkErrorEvent()
-data class ValidationRequiredEvent(val sid: String) : VkErrorEvent()
+data class ValidationRequiredEvent(
+ val sid: String,
+ val redirectUri: String,
+ val phoneMask: String,
+ val validationType: String,
+ val canResendSms: Boolean,
+ val codeError: UiText?
+) : VkErrorEvent()
-object StartProgressEvent : VkProgressEvent()
-object StopProgressEvent : VkProgressEvent()
+data class UserBannedEvent(
+ val memberName: String, val message: String, val restoreUrl: String, val accessToken: String,
+) : VkErrorEvent()
-interface VkEventCallback {
+fun interface VkEventCallback {
fun onEvent(event: T)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt
index 3bf4c2cc..716c143f 100644
--- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt
+++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt
@@ -6,12 +6,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.meloda.fast.R
import com.meloda.fast.api.UserConfig
-import com.meloda.fast.base.BaseFragment
-import com.meloda.fast.screens.main.MainActivity
-import com.meloda.fast.util.ViewUtils.showErrorDialog
+import com.meloda.fast.ext.showDialog
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.screens.main.activity.MainActivity
object ViewModelUtils {
+ @Deprecated("rewrite")
@Suppress("MemberVisibilityCanBePrivate")
fun parseEvent(activity: FragmentActivity, event: VkEvent) {
when (event) {
@@ -24,26 +25,47 @@ object ViewModelUtils {
activity.finishAffinity()
activity.startActivity(Intent(activity, MainActivity::class.java))
}
+ is TokenExpiredErrorEvent -> {
+ Toast.makeText(
+ activity, R.string.token_expired, Toast.LENGTH_LONG
+ ).show()
+ UserConfig.clear()
+ activity.finishAffinity()
+ activity.startActivity(Intent(activity, MainActivity::class.java))
+ }
+ is UserBannedEvent -> {
+ // TODO: 17.04.2023, Danil Nikolaev: handle banned event
+// (activity as? MainActivity)?.accessRouter()?.newRootScreen(
+// Screens.UserBanned(
+// memberName = event.memberName,
+// message = event.message,
+// restoreUrl = event.restoreUrl,
+// accessToken = event.accessToken
+// )
+// )
+ }
+ is UnknownErrorEvent -> {
+ activity.showDialog(
+ title = UiText.Resource(R.string.title_error),
+ message = UiText.Resource(R.string.unknown_error_occurred),
+ positiveText = UiText.Resource(R.string.ok)
+ )
+ }
is VkErrorEvent -> {
event.errorText?.run {
- activity.showErrorDialog(this)
+ activity.showDialog(
+ title = UiText.Resource(R.string.title_error),
+ message = UiText.Simple(this),
+ positiveText = UiText.Resource(R.string.ok)
+ )
}
}
}
}
+ @Deprecated("rewrite")
fun parseEvent(fragment: Fragment, event: VkEvent) {
- if (event is VkProgressEvent) {
- if (fragment is BaseFragment) {
- if (event is StartProgressEvent) {
- fragment.startProgress()
- } else if (event is StopProgressEvent) {
- fragment.stopProgress()
- }
- }
- } else {
- parseEvent(fragment.requireActivity(), event)
- }
+ parseEvent(fragment.requireActivity(), event)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt
index 5b821eb1..e8c3b4f8 100644
--- a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt
+++ b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt
@@ -1,94 +1,83 @@
package com.meloda.fast.common
import android.app.Application
-import android.app.DownloadManager
-import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.res.Resources
-import android.net.ConnectivityManager
-import android.util.Log
-import android.view.inputmethod.InputMethodManager
+import android.media.AudioManager
+import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.pm.PackageInfoCompat
import androidx.preference.PreferenceManager
-import androidx.room.Room
-import com.meloda.fast.database.AppDatabase
-import dagger.hilt.android.HiltAndroidApp
+import com.google.android.material.color.DynamicColors
+import com.meloda.fast.common.di.applicationModule
+import com.meloda.fast.screens.settings.SettingsFragment
+import com.meloda.fast.util.AndroidUtils
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.context.GlobalContext.startKoin
import kotlin.math.roundToInt
-import kotlin.math.sqrt
+import kotlin.properties.Delegates
-@HiltAndroidApp
class AppGlobal : Application() {
- companion object {
-
- lateinit var inputMethodManager: InputMethodManager
- lateinit var connectivityManager: ConnectivityManager
- lateinit var clipboardManager: ClipboardManager
- lateinit var downloadManager: DownloadManager
-
- lateinit var preferences: SharedPreferences
- lateinit var resources: Resources
- lateinit var packageName: String
- private lateinit var instance: AppGlobal
-
- lateinit var appDatabase: AppDatabase
-
- lateinit var packageManager: PackageManager
-
- var versionName = ""
- var versionCode = 0
-
- var screenWidth = 0
- var screenHeight = 0
-
- var screenWidth80 = 0
-
- val Instance get() = instance
- }
-
override fun onCreate() {
super.onCreate()
+
instance = this
- appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache")
-// .fallbackToDestructiveMigration()
- .build()
-
- preferences = PreferenceManager.getDefaultSharedPreferences(this)
+ if (preferences.getBoolean(
+ SettingsFragment.KEY_USE_DYNAMIC_COLORS,
+ SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS
+ )
+ ) {
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ }
val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES)
versionName = info.versionName
versionCode = PackageInfoCompat.getLongVersionCode(info).toInt()
- Companion.resources = resources
- Companion.packageName = packageName
- Companion.packageManager = packageManager
+ screenWidth80 = (AndroidUtils.getDisplayWidth() * 0.8).roundToInt()
- screenWidth = resources.displayMetrics.widthPixels
- screenHeight = resources.displayMetrics.heightPixels
+ audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
- screenWidth80 = (screenWidth * 0.8).roundToInt()
+ applyDarkTheme()
- val density = resources.displayMetrics.density
- val densityDpi = resources.displayMetrics.densityDpi
- val densityScaled = resources.displayMetrics.scaledDensity
- val xDpi = resources.displayMetrics.xdpi
- val yDpi = resources.displayMetrics.ydpi
-
- val diagonal = sqrt(
- (screenWidth * screenWidth - screenHeight * screenHeight).toFloat()
- )
-
- Log.i(
- "Fast::DeviceInfo",
- "width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi"
- )
-
- inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ initKoin()
}
-}
\ No newline at end of file
+
+ private fun applyDarkTheme() {
+ val nightMode = preferences.getInt(
+ SettingsFragment.KEY_APPEARANCE_DARK_THEME,
+ SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME
+ )
+ AppCompatDelegate.setDefaultNightMode(nightMode)
+ }
+
+ private fun initKoin() {
+ startKoin {
+ androidLogger()
+ androidContext(this@AppGlobal)
+ modules(applicationModule)
+ }
+ }
+
+ companion object {
+ private lateinit var instance: AppGlobal
+
+ val preferences: SharedPreferences by lazy {
+ PreferenceManager.getDefaultSharedPreferences(instance)
+ }
+
+ var versionName = ""
+ var versionCode = 0
+ var screenWidth80 = 0
+
+ val Instance: AppGlobal get() = instance
+ val resources: Resources get() = Instance.resources
+ val packageManager: PackageManager get() = Instance.packageManager
+
+ var audioManager: AudioManager by Delegates.notNull()
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt
deleted file mode 100644
index 4f0bcc26..00000000
--- a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.meloda.fast.common
-
-import android.content.Context
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.preferencesDataStore
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-
-
-object AppSettings {
-
- val keyUseNavigationDrawer = booleanPreferencesKey("use_nav_drawer")
-
-}
-
-val Context.dataStore: DataStore by preferencesDataStore(
- name = "settings",
- corruptionHandler = null,
- scope = CoroutineScope(Dispatchers.IO + Job())
-)
-
diff --git a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt
index f2411903..b0719a89 100644
--- a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt
+++ b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt
@@ -1,39 +1,37 @@
package com.meloda.fast.common
import com.github.terrakok.cicerone.androidx.FragmentScreen
-import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.model.UpdateItem
+import com.meloda.fast.screens.chatinfo.ChatInfoFragment
import com.meloda.fast.screens.conversations.ConversationsFragment
import com.meloda.fast.screens.login.LoginFragment
import com.meloda.fast.screens.main.MainFragment
import com.meloda.fast.screens.messages.ForwardedMessagesFragment
import com.meloda.fast.screens.messages.MessagesHistoryFragment
-import com.meloda.fast.screens.settings.SettingsRootFragment
+import com.meloda.fast.screens.settings.SettingsFragment
import com.meloda.fast.screens.updates.UpdatesFragment
+import com.meloda.fast.screens.userbanned.UserBannedFragment
@Suppress("FunctionName")
object Screens {
- fun Main() = FragmentScreen { MainFragment() }
+ fun Main() = FragmentScreen { MainFragment.newInstance() }
- fun Login(
- getFastToken: Boolean = false
- ) = FragmentScreen {
- LoginFragment.newInstance(getFastToken)
- }
+ fun Login() = FragmentScreen { LoginFragment.newInstance() }
fun Conversations() = FragmentScreen { ConversationsFragment() }
fun MessagesHistory(
- conversation: VkConversation,
+ conversation: VkConversationDomain,
user: VkUser?,
group: VkGroup?
) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) }
fun ForwardedMessages(
- conversation: VkConversation,
+ conversation: VkConversationDomain,
messages: List,
profiles: HashMap = hashMapOf(),
groups: HashMap = hashMapOf()
@@ -43,8 +41,25 @@ object Screens {
)
}
+ fun ChatInfo(
+ conversation: VkConversationDomain,
+ user: VkUser?,
+ group: VkGroup?
+ ) = FragmentScreen { ChatInfoFragment.newInstance(conversation, user, group) }
+
fun Updates(updateItem: UpdateItem? = null) =
FragmentScreen { UpdatesFragment.newInstance(updateItem) }
- fun Settings() = FragmentScreen { SettingsRootFragment() }
-}
\ No newline at end of file
+ fun Settings() = FragmentScreen { SettingsFragment.newInstance() }
+
+ fun UserBanned(
+ memberName: String,
+ message: String,
+ restoreUrl: String,
+ accessToken: String
+ ) = FragmentScreen {
+ UserBannedFragment.newInstance(
+ memberName, message, restoreUrl, accessToken
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt
index 930aab68..5ea0d868 100644
--- a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt
+++ b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt
@@ -1,37 +1,39 @@
package com.meloda.fast.common
-import androidx.lifecycle.MutableLiveData
import com.meloda.fast.BuildConfig
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse
import com.meloda.fast.data.ota.OtaApi
-import com.meloda.fast.extensions.setIfNotEquals
import com.meloda.fast.model.UpdateActualUrl
import com.meloda.fast.model.UpdateItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import java.net.URLEncoder
import kotlin.coroutines.CoroutineContext
-class UpdateManager(private val repo: OtaApi) : CoroutineScope {
+interface UpdateManager {
+ val stateFlow: Flow
- override val coroutineContext: CoroutineContext
- get() = Dispatchers.Default
+ fun checkUpdates(): Job
+}
- companion object {
- val newUpdate = MutableLiveData(null)
- val updateError = MutableLiveData(null)
+class UpdateManagerImpl(private val repo: OtaApi) : UpdateManager {
- var otaBaseUrl: String? = null
- private set
- }
+ private val coroutineContext: CoroutineContext
+ get() = Dispatchers.IO
- private var listener: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null
+ private val coroutineScope = CoroutineScope(coroutineContext)
- private fun getActualUrl() = launch {
+ private var otaBaseUrl: String? = null
+
+ override val stateFlow = MutableStateFlow(UpdateManagerState.EMPTY)
+
+ override fun checkUpdates() = coroutineScope.launch {
val job: suspend () -> ApiAnswer = { repo.getActualUrl() }
when (val jobResponse = job()) {
@@ -44,47 +46,55 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope {
is ApiAnswer.Error -> {
otaBaseUrl = null
val throwable = jobResponse.error.throwable
- listener?.invoke(null, throwable)
- withContext(Dispatchers.Main) {
- updateError.setIfNotEquals(throwable)
- }
+ val newForm = stateFlow.value.copy(
+ updateItem = null,
+ throwable = throwable
+ )
+ stateFlow.emit(newForm)
}
}
}
- private fun getLatestRelease() = launch {
+ private fun getLatestRelease() = coroutineScope.launch {
val url = "$otaBaseUrl/releases-latest"
val job: suspend () -> ApiAnswer> = {
repo.getLatestRelease(url = url, secretCode = getOtaSecret())
}
- withContext(Dispatchers.Main) {
- when (val jobResponse = job()) {
- is ApiAnswer.Success -> {
- val response = jobResponse.data.response ?: return@withContext
- val latestRelease = response.release
+ when (val jobResponse = job()) {
+ is ApiAnswer.Success -> {
+ val response = jobResponse.data.response ?: return@launch
+ val latestRelease = response.release
- if (latestRelease != null &&
- (AppGlobal.versionName
- .split("_")
- .getOrNull(1) != latestRelease.versionName ||
- AppGlobal.versionCode < latestRelease.versionCode)
- ) {
- newUpdate.setIfNotEquals(latestRelease)
- listener?.invoke(latestRelease, null)
- } else {
- newUpdate.setIfNotEquals(null)
- listener?.invoke(null, null)
- }
+ val updateItem = if (latestRelease != null &&
+ (AppGlobal.versionName
+ .split("_")
+ .getOrNull(1) != latestRelease.versionName ||
+ AppGlobal.versionCode < latestRelease.versionCode)
+ ) {
+ latestRelease
+ } else {
+ null
}
- is ApiAnswer.Error -> {
- val throwable = jobResponse.error.throwable
- updateError.setIfNotEquals(throwable)
- listener?.invoke(null, throwable)
- }
+ val newForm = stateFlow.value.copy(
+ updateItem = updateItem,
+ throwable = null
+ )
+
+ stateFlow.emit(newForm)
+ }
+
+ is ApiAnswer.Error -> {
+ val throwable = jobResponse.error.throwable
+
+ val newForm = stateFlow.value.copy(
+ updateItem = null,
+ throwable = throwable
+ )
+ stateFlow.emit(newForm)
}
}
}
@@ -92,9 +102,15 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope {
private fun getOtaSecret(): String {
return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8")
}
+}
- fun checkUpdates(block: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null) = launch {
- this@UpdateManager.listener = block
- getActualUrl()
+data class UpdateManagerState(
+ val updateItem: UpdateItem?,
+ val throwable: Throwable?,
+) {
+ companion object {
+ val EMPTY = UpdateManagerState(
+ updateItem = null, throwable = null
+ )
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt
new file mode 100644
index 00000000..58784bac
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt
@@ -0,0 +1,40 @@
+package com.meloda.fast.common.di
+
+import com.meloda.fast.di.apiModule
+import com.meloda.fast.di.dataModule
+import com.meloda.fast.di.databaseModule
+import com.meloda.fast.di.navigationModule
+import com.meloda.fast.di.networkModule
+import com.meloda.fast.di.otaModule
+import com.meloda.fast.screens.captcha.di.captchaModule
+import com.meloda.fast.screens.chatinfo.di.chatInfoModule
+import com.meloda.fast.screens.conversations.di.conversationsModule
+import com.meloda.fast.screens.login.di.loginModule
+import com.meloda.fast.screens.main.di.mainModule
+import com.meloda.fast.screens.messages.di.messagesHistoryModule
+import com.meloda.fast.screens.photos.di.photoViewModule
+import com.meloda.fast.screens.settings.di.settingsModule
+import com.meloda.fast.screens.twofa.di.twoFaModule
+import com.meloda.fast.screens.updates.di.updatesModule
+import org.koin.dsl.module
+
+val applicationModule = module {
+ includes(
+ navigationModule,
+ databaseModule,
+ dataModule,
+ otaModule,
+ networkModule,
+ apiModule,
+ loginModule,
+ twoFaModule,
+ captchaModule,
+ mainModule,
+ conversationsModule,
+ chatInfoModule,
+ settingsModule,
+ updatesModule,
+ messagesHistoryModule,
+ photoViewModule,
+ )
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt b/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt
new file mode 100644
index 00000000..4f03a816
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt
@@ -0,0 +1,163 @@
+package com.meloda.fast.compose
+
+import androidx.compose.animation.*
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.meloda.fast.ext.getString
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.ui.AppTheme
+
+@Composable
+fun MaterialDialog(
+ onDismissAction: (() -> Unit),
+ title: UiText? = null,
+ message: UiText? = null,
+ positiveText: UiText? = null,
+ positiveAction: (() -> Unit)? = null,
+ negativeText: UiText? = null,
+ negativeAction: (() -> Unit)? = null,
+ neutralText: UiText? = null,
+ neutralAction: (() -> Unit)? = null,
+ content: (@Composable () -> Unit)? = null
+) {
+ var isVisible by remember {
+ mutableStateOf(true)
+ }
+ val onDismissRequest = {
+ onDismissAction.invoke()
+ isVisible = false
+ }
+
+ AppTheme {
+ // TODO: 08.04.2023, Danil Nikolaev: implement animation
+ AlertAnimation(visible = isVisible) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ val scrollState = rememberScrollState()
+ val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
+ val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = AlertDialogDefaults.containerColor,
+ shape = AlertDialogDefaults.shape,
+ tonalElevation = AlertDialogDefaults.TonalElevation
+ ) {
+ Column(
+ modifier = Modifier.padding(
+ start = 20.dp,
+ top = 20.dp,
+ end = 20.dp,
+ bottom = 10.dp
+ )
+ ) {
+ Row {
+ title?.getString()?.let { title ->
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ }
+ }
+
+ if (canScrollBackward) {
+ Divider(modifier = Modifier.fillMaxWidth())
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f, fill = false)
+ .verticalScroll(scrollState)
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row {
+ message?.getString()?.let { message ->
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ content?.let { content ->
+ Spacer(modifier = Modifier.height(4.dp))
+ content.invoke()
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+ }
+
+ if (canScrollForward) {
+ Divider(modifier = Modifier.fillMaxWidth())
+ }
+
+ Row {
+ neutralText?.getString()?.let { text ->
+ TextButton(
+ onClick = {
+ onDismissRequest.invoke()
+ neutralAction?.invoke()
+ }
+ ) {
+ Text(text = text)
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ negativeText?.getString()?.let { text ->
+ TextButton(
+ onClick = {
+ onDismissRequest.invoke()
+ negativeAction?.invoke()
+ }
+ ) {
+ Text(text = text)
+ }
+ }
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ positiveText?.getString()?.let { text ->
+ TextButton(
+ onClick = {
+ onDismissRequest.invoke()
+ positiveAction?.invoke()
+ }
+ ) {
+ Text(text = text)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun AlertAnimation(
+ visible: Boolean,
+ content: @Composable AnimatedVisibilityScope.() -> Unit
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(animationSpec = tween(400)) +
+ scaleIn(animationSpec = tween(400)),
+ exit = fadeOut(animationSpec = tween(150)),
+ content = content
+ )
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt
index ed138f4e..bbd3f79f 100644
--- a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt
+++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt
@@ -15,4 +15,7 @@ interface AccountsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(values: List)
-}
\ No newline at end of file
+ @Query("DELETE FROM accounts WHERE userId = :userId")
+ suspend fun deleteById(userId: Int)
+
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt
index 57a1aa5e..0804a222 100644
--- a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt
+++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt
@@ -4,15 +4,15 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
-import com.meloda.fast.api.model.VkConversation
+import com.meloda.fast.api.model.domain.VkConversationDomain
@Dao
interface ConversationsDao {
@Query("SELECT * FROM conversations")
- suspend fun getAll(): List
+ suspend fun getAll(): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(values: List)
+ suspend fun insert(values: List)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt
index c9161d2e..9fd2049e 100644
--- a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt
+++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt
@@ -1,11 +1,10 @@
package com.meloda.fast.data.conversations
-import com.meloda.fast.api.model.VkConversation
+import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.api.network.conversations.ConversationsDeleteRequest
import com.meloda.fast.api.network.conversations.ConversationsGetRequest
import com.meloda.fast.api.network.conversations.ConversationsPinRequest
import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest
-import kotlinx.coroutines.sync.Mutex
class ConversationsRepository(
private val conversationsApi: ConversationsApi,
@@ -20,6 +19,6 @@ class ConversationsRepository(
suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map)
- suspend fun store(conversations: List) = conversationsDao.insert(conversations)
+ suspend fun store(conversations: List) = conversationsDao.insert(conversations)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt
index e8faab88..c12d1fd0 100644
--- a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt
+++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt
@@ -1,10 +1,12 @@
package com.meloda.fast.data.messages
import com.meloda.fast.api.base.ApiResponse
+import com.meloda.fast.api.model.base.BaseVkChat
import com.meloda.fast.api.model.base.BaseVkLongPoll
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.messages.MessagesGetByIdResponse
+import com.meloda.fast.api.network.messages.MessagesGetConversationMembersResponse
import com.meloda.fast.api.network.messages.MessagesGetHistoryResponse
import com.meloda.fast.api.network.messages.MessagesUrls
import retrofit2.http.FieldMap
@@ -53,4 +55,16 @@ interface MessagesApi {
@POST(MessagesUrls.MarkAsRead)
suspend fun markAsRead(@FieldMap params: Map): ApiAnswer>
+ @FormUrlEncoded
+ @POST(MessagesUrls.GetChat)
+ suspend fun getChat(@FieldMap params: Map): ApiAnswer>
+
+ @FormUrlEncoded
+ @POST(MessagesUrls.GetConversationMembers)
+ suspend fun getConversationMembers(@FieldMap params: Map): ApiAnswer>
+
+ @FormUrlEncoded
+ @POST(MessagesUrls.RemoveChatUser)
+ suspend fun removeChatUser(@FieldMap params: Map): ApiAnswer>
+
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt
index f3d9c653..f1c70b60 100644
--- a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt
+++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt
@@ -2,15 +2,28 @@ package com.meloda.fast.data.messages
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest
+import com.meloda.fast.api.network.messages.MessagesDeleteRequest
+import com.meloda.fast.api.network.messages.MessagesEditRequest
+import com.meloda.fast.api.network.messages.MessagesGetByIdRequest
+import com.meloda.fast.api.network.messages.MessagesGetChatRequest
+import com.meloda.fast.api.network.messages.MessagesGetConversationMembersRequest
+import com.meloda.fast.api.network.messages.MessagesGetHistoryRequest
+import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest
+import com.meloda.fast.api.network.messages.MessagesMarkAsImportantRequest
+import com.meloda.fast.api.network.messages.MessagesPinMessageRequest
+import com.meloda.fast.api.network.messages.MessagesRemoveChatUserRequest
+import com.meloda.fast.api.network.messages.MessagesSendRequest
+import com.meloda.fast.api.network.messages.MessagesUnPinMessageRequest
import com.meloda.fast.data.longpoll.LongPollApi
-import com.meloda.fast.api.network.messages.*
class MessagesRepository(
private val messagesApi: MessagesApi,
private val messagesDao: MessagesDao,
- private val longPollApi: LongPollApi
+ private val longPollApi: LongPollApi,
) {
+ suspend fun store(message: VkMessage) = store(listOf(message))
+
suspend fun store(messages: List) = messagesDao.insert(messages)
suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId)
@@ -41,7 +54,7 @@ class MessagesRepository(
suspend fun getLongPollUpdates(
serverUrl: String,
- params: LongPollGetUpdatesRequest
+ params: LongPollGetUpdatesRequest,
) = longPollApi.getResponse(serverUrl, params.map)
suspend fun getById(params: MessagesGetByIdRequest) =
@@ -50,7 +63,7 @@ class MessagesRepository(
suspend fun markAsRead(
peerId: Int,
messagesIds: List? = null,
- startMessageId: Int? = null
+ startMessageId: Int? = null,
) = messagesApi.markAsRead(
mutableMapOf("peer_id" to peerId.toString()).apply {
messagesIds?.let {
@@ -62,4 +75,30 @@ class MessagesRepository(
}
)
-}
\ No newline at end of file
+ suspend fun getChat(
+ chatId: Int,
+ fields: String? = null,
+ ) = messagesApi.getChat(MessagesGetChatRequest(chatId, fields).map)
+
+ suspend fun getConversationMembers(
+ peerId: Int,
+ offset: Int? = null,
+ count: Int? = null,
+ extended: Boolean? = null,
+ fields: String? = null,
+ ) = messagesApi.getConversationMembers(
+ MessagesGetConversationMembersRequest(
+ peerId,
+ offset,
+ count,
+ extended,
+ fields
+ ).map
+ )
+
+ suspend fun removeChatUser(
+ chatId: Int,
+ memberId: Int,
+ ) = messagesApi.removeChatUser(MessagesRemoveChatUserRequest(chatId, memberId).map)
+
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt
new file mode 100644
index 00000000..a5ec4ef4
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt
@@ -0,0 +1,15 @@
+package com.meloda.fast.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.meloda.fast.data.account.AccountsDao
+import com.meloda.fast.model.AppAccount
+
+@Database(
+ entities = [AppAccount::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class AccountsDatabase : RoomDatabase() {
+ abstract val accountsDao: AccountsDao
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt
similarity index 64%
rename from app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt
rename to app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt
index a2640d66..fa8df9ea 100644
--- a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt
+++ b/app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt
@@ -1,41 +1,33 @@
package com.meloda.fast.database
-import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
-import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
-import com.meloda.fast.data.account.AccountsDao
+import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.data.conversations.ConversationsDao
import com.meloda.fast.data.groups.GroupsDao
import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.users.UsersDao
-import com.meloda.fast.model.AppAccount
@Database(
entities = [
- AppAccount::class,
- VkConversation::class,
+ VkConversationDomain::class,
VkMessage::class,
VkUser::class,
VkGroup::class
],
- version = 34,
- exportSchema = true,
- autoMigrations = [
- AutoMigration(from = 33, to = 34)
- ]
+ version = 42,
+ exportSchema = false
)
@TypeConverters(Converters::class)
-abstract class AppDatabase : RoomDatabase() {
+abstract class CacheDatabase : RoomDatabase() {
- abstract val accountsDao: AccountsDao
abstract val conversationsDao: ConversationsDao
abstract val messagesDao: MessagesDao
abstract val usersDao: UsersDao
abstract val groupsDao: GroupsDao
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt
index 73472b37..5b852294 100644
--- a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt
+++ b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt
@@ -2,6 +2,7 @@ package com.meloda.fast.database
import androidx.room.TypeConverter
import com.google.gson.Gson
+import com.meloda.fast.api.base.AttachmentClassNameIsEmptyException
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage
@@ -18,25 +19,37 @@ class Converters {
fun fromGeoToString(geo: BaseVkMessage.Geo?): String? {
if (geo == null) return null
- val string = Gson().toJson(geo)
+ return try {
+ val string = Gson().toJson(geo)
- return string
+ return string
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
@TypeConverter
fun fromStringToGeo(string: String?): BaseVkMessage.Geo? {
if (string == null) return null
- val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java)
+ return try {
+ val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java)
- return geo
+ return geo
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
@TypeConverter
fun fromListVkMessageToString(messages: List?): String? {
if (messages == null) return null
- val string = messages.map { fromVkMessageToString(it)!! }.joinToString { CACHE_SEPARATOR }
+ val string = messages
+ .mapNotNull(::fromVkMessageToString)
+ .joinToString(separator = CACHE_SEPARATOR)
return string
}
@@ -46,40 +59,52 @@ class Converters {
if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) {
- val messages =
- string.split(CACHE_SEPARATOR).map { fromStringToVkMessage(it)!! }
+ val messages = string
+ .split(CACHE_SEPARATOR)
+ .mapNotNull(::fromStringToVkMessage)
return messages
}
- val message = fromStringToVkMessage(string)!!
-
- return listOf(message)
+ val message = fromStringToVkMessage(string)
+ return message?.let { listOf(it) }
}
@TypeConverter
fun fromVkMessageToString(message: VkMessage?): String? {
if (message == null) return null
- return Gson().toJson(message)
+ return try {
+ val string = Gson().toJson(message)
+
+ return string
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
@TypeConverter
fun fromStringToVkMessage(string: String?): VkMessage? {
if (string == null) return null
- val message = Gson().fromJson(string, VkMessage::class.java)
+ return try {
+ val message = Gson().fromJson(string, VkMessage::class.java)
- return message
+ return message
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
@TypeConverter
fun fromListVkAttachmentToString(attachments: List?): String? {
if (attachments == null) return null
- val string =
- attachments.map { fromVkAttachmentToString(it)!! }.joinToString { CACHE_SEPARATOR }
-
+ val string = attachments
+ .mapNotNull(::fromVkAttachmentToString)
+ .joinToString(separator = CACHE_SEPARATOR)
return string
}
@@ -88,34 +113,48 @@ class Converters {
if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) {
- val attachments =
- string.split(CACHE_SEPARATOR).map { fromStringToVkAttachment(it)!! }
+ val attachments = string
+ .split(CACHE_SEPARATOR)
+ .mapNotNull(::fromStringToVkAttachment)
return attachments
}
+ val attachment = fromStringToVkAttachment(string)
- val attachment = fromStringToVkAttachment(string)!!
-
- return listOf(attachment)
+ return attachment?.let { listOf(it) }
}
@TypeConverter
fun fromVkAttachmentToString(attachment: VkAttachment?): String? {
if (attachment == null) return null
- val string = Gson().toJson(attachment)
-
- return string
+ try {
+ attachment.javaClass.getDeclaredField("className")
+ } catch (e: NoSuchFieldException) {
+ throw AttachmentClassNameIsEmptyException(attachment)
+ }
+ return try {
+ val string = Gson().toJson(attachment)
+ string
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
@TypeConverter
fun fromStringToVkAttachment(string: String?): VkAttachment? {
- if (string == null) return null
+ if (string.isNullOrBlank()) return null
- val className = JSONObject(string).optString("className")
+ return try {
+ val className = JSONObject(string).optString("className")
- val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment?
+ val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment?
- return attachment
+ return attachment
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
}
}
diff --git a/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt b/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt
new file mode 100644
index 00000000..275c38b5
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt
@@ -0,0 +1,35 @@
+package com.meloda.fast.di
+
+import com.meloda.fast.api.longpoll.LongPollUpdatesParser
+import com.meloda.fast.data.account.AccountApi
+import com.meloda.fast.data.audios.AudiosApi
+import com.meloda.fast.data.auth.AuthApi
+import com.meloda.fast.data.conversations.ConversationsApi
+import com.meloda.fast.data.files.FilesApi
+import com.meloda.fast.data.longpoll.LongPollApi
+import com.meloda.fast.data.messages.MessagesApi
+import com.meloda.fast.data.ota.OtaApi
+import com.meloda.fast.data.photos.PhotosApi
+import com.meloda.fast.data.users.UsersApi
+import com.meloda.fast.data.videos.VideosApi
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.scope.Scope
+import org.koin.dsl.module
+
+val apiModule = module {
+ single { api(AuthApi::class.java) }
+ single { api(ConversationsApi::class.java) }
+ single { api(UsersApi::class.java) }
+ single { api(MessagesApi::class.java) }
+ single { api(LongPollApi::class.java) }
+ single { api(AccountApi::class.java) }
+ single { api(OtaApi::class.java) }
+ single { api(PhotosApi::class.java) }
+ single { api(VideosApi::class.java) }
+ single { api(AudiosApi::class.java) }
+ single { api(FilesApi::class.java) }
+
+ singleOf(::LongPollUpdatesParser)
+}
+
+internal fun Scope.api(className: Class): T = retrofit().create(className)
diff --git a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt
index 890f763f..9397fd8a 100644
--- a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt
+++ b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt
@@ -1,103 +1,26 @@
package com.meloda.fast.di
-import com.meloda.fast.data.longpoll.LongPollApi
-import com.meloda.fast.data.account.AccountApi
-import com.meloda.fast.data.account.AccountsDao
import com.meloda.fast.data.account.AccountsRepository
-import com.meloda.fast.data.audios.AudiosApi
import com.meloda.fast.data.audios.AudiosRepository
-import com.meloda.fast.data.auth.AuthApi
import com.meloda.fast.data.auth.AuthRepository
-import com.meloda.fast.data.conversations.ConversationsApi
-import com.meloda.fast.data.conversations.ConversationsDao
import com.meloda.fast.data.conversations.ConversationsRepository
-import com.meloda.fast.data.files.FilesApi
import com.meloda.fast.data.files.FilesRepository
-import com.meloda.fast.data.groups.GroupsDao
-import com.meloda.fast.data.groups.GroupsRepository
-import com.meloda.fast.data.messages.MessagesApi
-import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.messages.MessagesRepository
-import com.meloda.fast.data.photos.PhotosApi
import com.meloda.fast.data.photos.PhotosRepository
-import com.meloda.fast.data.users.UsersApi
-import com.meloda.fast.data.users.UsersDao
import com.meloda.fast.data.users.UsersRepository
-import com.meloda.fast.data.videos.VideosApi
import com.meloda.fast.data.videos.VideosRepository
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
-@InstallIn(SingletonComponent::class)
-@Module
-object DataModule {
-
- @Singleton
- @Provides
- fun provideConversationsRepository(
- conversationsApi: ConversationsApi,
- conversationsDao: ConversationsDao
- ): ConversationsRepository = ConversationsRepository(conversationsApi, conversationsDao)
-
- @Singleton
- @Provides
- fun provideMessagesRepository(
- messagesApi: MessagesApi,
- messagesDao: MessagesDao,
- longPollApi: LongPollApi
- ): MessagesRepository = MessagesRepository(messagesApi, messagesDao, longPollApi)
-
- @Singleton
- @Provides
- fun provideUsersRepository(
- usersApi: UsersApi,
- usersDao: UsersDao
- ): UsersRepository = UsersRepository(usersApi, usersDao)
-
- @Singleton
- @Provides
- fun provideGroupsRepository(
- groupsDao: GroupsDao
- ): GroupsRepository = GroupsRepository(groupsDao)
-
- @Singleton
- @Provides
- fun provideAuthRepository(
- authApi: AuthApi
- ): AuthRepository = AuthRepository(authApi)
-
- @Singleton
- @Provides
- fun provideAccountsRepository(
- accountApi: AccountApi,
- accountsDao: AccountsDao
- ): AccountsRepository = AccountsRepository(accountApi, accountsDao)
-
- @Singleton
- @Provides
- fun providePhotosRepository(
- photosApi: PhotosApi
- ): PhotosRepository = PhotosRepository(photosApi)
-
- @Singleton
- @Provides
- fun provideVideosRepository(
- videosApi: VideosApi
- ): VideosRepository = VideosRepository(videosApi)
-
- @Singleton
- @Provides
- fun provideAudiosRepository(
- audiosApi: AudiosApi
- ): AudiosRepository = AudiosRepository(audiosApi)
-
- @Singleton
- @Provides
- fun provideFilesRepository(
- filesApi: FilesApi
- ): FilesRepository = FilesRepository(filesApi)
-
-}
\ No newline at end of file
+// TODO: 17.04.2023, Danil Nikolaev: use specific repositories in local DI modules
+val dataModule = module {
+ singleOf(::ConversationsRepository)
+ singleOf(::MessagesRepository)
+ singleOf(::UsersRepository)
+ singleOf(::AuthRepository)
+ singleOf(::AccountsRepository)
+ singleOf(::PhotosRepository)
+ singleOf(::VideosRepository)
+ singleOf(::AudiosRepository)
+ singleOf(::FilesRepository)
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt
index af8962e8..bd5615ce 100644
--- a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt
@@ -1,50 +1,28 @@
package com.meloda.fast.di
+import androidx.room.Room
import com.meloda.fast.common.AppGlobal
-import com.meloda.fast.data.account.AccountsDao
-import com.meloda.fast.data.conversations.ConversationsDao
-import com.meloda.fast.data.groups.GroupsDao
-import com.meloda.fast.data.messages.MessagesDao
-import com.meloda.fast.data.users.UsersDao
-import com.meloda.fast.database.AppDatabase
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
+import com.meloda.fast.database.AccountsDatabase
+import com.meloda.fast.database.CacheDatabase
+import org.koin.core.scope.Scope
+import org.koin.dsl.module
-@InstallIn(SingletonComponent::class)
-@Module
-object DatabaseModule {
+val databaseModule = module {
+ single {
+ Room.databaseBuilder(AppGlobal.Instance, CacheDatabase::class.java, "cache")
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+ single {
+ Room.databaseBuilder(AppGlobal.Instance, AccountsDatabase::class.java, "accounts")
+ .build()
+ }
+ single { cache().conversationsDao }
+ single { cache().messagesDao }
+ single { cache().usersDao }
+ single { cache().groupsDao }
+ single { accounts().accountsDao }
+}
- @Provides
- @Singleton
- fun provideAppDatabase(): AppDatabase =
- AppGlobal.appDatabase
-
- @Provides
- @Singleton
- fun provideAccountsDao(appDatabase: AppDatabase): AccountsDao =
- appDatabase.accountsDao
-
- @Provides
- @Singleton
- fun provideConversationsDao(appDatabase: AppDatabase): ConversationsDao =
- appDatabase.conversationsDao
-
- @Provides
- @Singleton
- fun provideMessagesDao(appDatabase: AppDatabase): MessagesDao =
- appDatabase.messagesDao
-
- @Provides
- @Singleton
- fun provideUsersDao(appDatabase: AppDatabase): UsersDao =
- appDatabase.usersDao
-
- @Provides
- @Singleton
- fun provideGroupsDao(appDatabase: AppDatabase): GroupsDao =
- appDatabase.groupsDao
-
-}
\ No newline at end of file
+private fun Scope.cache(): CacheDatabase = get()
+private fun Scope.accounts(): AccountsDatabase = get()
diff --git a/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt
index a996124d..3b130ede 100644
--- a/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt
+++ b/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt
@@ -2,24 +2,19 @@ package com.meloda.fast.di
import com.github.terrakok.cicerone.Cicerone
import com.github.terrakok.cicerone.Router
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
+import com.meloda.fast.screens.captcha.screen.CaptchaScreen
+import com.meloda.fast.screens.twofa.screen.TwoFaScreen
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.scope.Scope
+import org.koin.dsl.module
-@InstallIn(SingletonComponent::class)
-@Module
-object NavigationModule {
- @Provides
- @Singleton
- fun getCicerone(): Cicerone = Cicerone.create()
+val navigationModule = module {
+ single { Cicerone.create() }
+ single { cicerone().router }
+ single { cicerone().getNavigatorHolder() }
- @Provides
- @Singleton
- fun getRouter(cicerone: Cicerone) = cicerone.router
+ singleOf(::CaptchaScreen)
+ singleOf(::TwoFaScreen)
+}
- @Provides
- @Singleton
- fun getNavigationHolder(cicerone: Cicerone) = cicerone.getNavigatorHolder()
-}
\ No newline at end of file
+private fun Scope.cicerone(): Cicerone = get()
diff --git a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt
index ecb81be5..b7a546ab 100644
--- a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt
+++ b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt
@@ -2,96 +2,34 @@ package com.meloda.fast.di
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
-import com.google.gson.Gson
import com.google.gson.GsonBuilder
-import com.meloda.fast.api.longpoll.LongPollUpdatesParser
import com.meloda.fast.api.network.AuthInterceptor
import com.meloda.fast.api.network.ResultCallFactory
import com.meloda.fast.api.network.VkUrls
-import com.meloda.fast.common.AppGlobal
-import com.meloda.fast.common.UpdateManager
-import com.meloda.fast.data.account.AccountApi
-import com.meloda.fast.data.audios.AudiosApi
-import com.meloda.fast.data.auth.AuthApi
-import com.meloda.fast.data.conversations.ConversationsApi
-import com.meloda.fast.data.files.FilesApi
-import com.meloda.fast.data.longpoll.LongPollApi
-import com.meloda.fast.data.messages.MessagesApi
-import com.meloda.fast.data.messages.MessagesRepository
-import com.meloda.fast.data.ota.OtaApi
-import com.meloda.fast.data.photos.PhotosApi
-import com.meloda.fast.data.users.UsersApi
-import com.meloda.fast.data.videos.VideosApi
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.scope.Scope
+import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
-import javax.inject.Singleton
-@InstallIn(SingletonComponent::class)
-@Module
-object NetworkModule {
-
- /*
-
- val chuckerCollector = ChuckerCollector(
- context = this,
- // Toggles visibility of the notification
- showNotification = true,
- // Allows to customize the retention period of collected data
- retentionPeriod = RetentionManager.Period.ONE_HOUR
-)
-
-// Create the Interceptor
-val chuckerInterceptor = ChuckerInterceptor.Builder(context)
- // The previously created Collector
- .collector(chuckerCollector)
- // The max body content length in bytes, after this responses will be truncated.
- .maxContentLength(250_000L)
- // List of headers to replace with ** in the Chucker UI
- .redactHeaders("Auth-Token", "Bearer")
- // Read the whole response body even when the client does not consume the response completely.
- // This is useful in case of parsing errors or when the response body
- // is closed before being read like in Retrofit with Void and Unit types.
- .alwaysReadResponseBody(true)
- // Use decoder when processing request and response bodies. When multiple decoders are installed they
- // are applied in an order they were added.
- .addBodyDecoder(decoder)
- // Controls Android shortcut creation. Available in SNAPSHOTS versions only at the moment
- .createShortcut(true)
- .build()
- */
-
- @Singleton
- @Provides
- fun provideChuckerCollector(): ChuckerCollector =
- ChuckerCollector(AppGlobal.Instance)
-
- @Singleton
- @Provides
- fun provideChuckerInterceptor(
- chuckerCollector: ChuckerCollector
- ): ChuckerInterceptor =
- ChuckerInterceptor.Builder(AppGlobal.Instance)
- .collector(chuckerCollector)
- .build()
-
- @Singleton
- @Provides
- fun provideOkHttpClient(
- authInterceptor: AuthInterceptor,
- chuckerInterceptor: ChuckerInterceptor
- ): OkHttpClient =
+val networkModule = module {
+ single { ChuckerCollector(get()) }
+ single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
+ singleOf(::AuthInterceptor)
+ single { GsonBuilder().setLenient().create() }
+ single {
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
- .addInterceptor(authInterceptor)
- .addInterceptor(chuckerInterceptor)
+ .addInterceptor(authInterceptor())
+ .addInterceptor(
+ chuckerInterceptor().apply {
+ redactHeader("Secret-Code")
+ }
+ )
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(
@@ -99,92 +37,17 @@ val chuckerInterceptor = ChuckerInterceptor.Builder(context)
level = HttpLoggingInterceptor.Level.BODY
}
).build()
+ }
+ single {
+ Retrofit.Builder()
+ .baseUrl("${VkUrls.API}/")
+ .addConverterFactory(GsonConverterFactory.create(get()))
+ .addCallAdapterFactory(ResultCallFactory(get()))
+ .client(get())
+ .build()
+ }
+}
- @Singleton
- @Provides
- fun provideGson(): Gson = GsonBuilder()
- .setLenient()
- .create()
-
- @Singleton
- @Provides
- fun provideRetrofit(
- client: OkHttpClient,
- gson: Gson
- ): Retrofit = Retrofit.Builder()
- .baseUrl("${VkUrls.API}/")
- .addConverterFactory(GsonConverterFactory.create(gson))
- .addCallAdapterFactory(ResultCallFactory())
- .client(client)
- .build()
-
- @Provides
- @Singleton
- fun provideAuthInterceptor(): AuthInterceptor = AuthInterceptor()
-
- @Provides
- @Singleton
- fun provideAuthApi(retrofit: Retrofit): AuthApi =
- retrofit.create(AuthApi::class.java)
-
- @Provides
- @Singleton
- fun provideConversationsApi(retrofit: Retrofit): ConversationsApi =
- retrofit.create(ConversationsApi::class.java)
-
- @Provides
- @Singleton
- fun provideUsersApi(retrofit: Retrofit): UsersApi =
- retrofit.create(UsersApi::class.java)
-
- @Provides
- @Singleton
- fun provideMessagesApi(retrofit: Retrofit): MessagesApi =
- retrofit.create(MessagesApi::class.java)
-
- @Provides
- @Singleton
- fun provideLongPollApi(retrofit: Retrofit): LongPollApi =
- retrofit.create(LongPollApi::class.java)
-
- @Provides
- @Singleton
- fun provideLongPollUpdatesParser(messagesRepository: MessagesRepository): LongPollUpdatesParser =
- LongPollUpdatesParser(messagesRepository)
-
- @Provides
- @Singleton
- fun provideAccountApi(retrofit: Retrofit): AccountApi =
- retrofit.create(AccountApi::class.java)
-
- @Provides
- @Singleton
- fun provideOtaApi(retrofit: Retrofit): OtaApi =
- retrofit.create(OtaApi::class.java)
-
- @Provides
- @Singleton
- fun provideUpdateManager(otaApi: OtaApi): UpdateManager =
- UpdateManager(otaApi)
-
- @Provides
- @Singleton
- fun providePhotosApi(retrofit: Retrofit): PhotosApi =
- retrofit.create(PhotosApi::class.java)
-
- @Provides
- @Singleton
- fun provideVideosApi(retrofit: Retrofit): VideosApi =
- retrofit.create(VideosApi::class.java)
-
- @Provides
- @Singleton
- fun provideAudiosApi(retrofit: Retrofit): AudiosApi =
- retrofit.create(AudiosApi::class.java)
-
- @Provides
- @Singleton
- fun provideFilesApi(retrofit: Retrofit): FilesApi =
- retrofit.create(FilesApi::class.java)
-
-}
\ No newline at end of file
+internal fun Scope.retrofit(): Retrofit = get()
+private fun Scope.authInterceptor(): AuthInterceptor = get()
+private fun Scope.chuckerInterceptor(): ChuckerInterceptor = get()
diff --git a/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt b/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt
new file mode 100644
index 00000000..413a16b3
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt
@@ -0,0 +1,13 @@
+package com.meloda.fast.di
+
+import com.meloda.fast.common.UpdateManager
+import com.meloda.fast.common.UpdateManagerImpl
+import com.meloda.fast.data.ota.OtaApi
+import org.koin.core.module.dsl.bind
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val otaModule = module {
+ single { api(OtaApi::class.java) }
+ singleOf(::UpdateManagerImpl) { bind() }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt
new file mode 100644
index 00000000..f75afa5a
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt
@@ -0,0 +1,14 @@
+package com.meloda.fast.ext
+
+import android.app.Activity
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.Flow
+
+fun Activity.edgeToEdge() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+}
+
+context(AppCompatActivity)
+fun Flow.listenValue(action: suspend (T) -> Unit) = listenValue(lifecycleScope, action)
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt
new file mode 100644
index 00000000..c65f885c
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt
@@ -0,0 +1,54 @@
+package com.meloda.fast.ext
+
+import android.os.Build
+
+fun isSdkAtLeast(sdkInt: Int, action: (() -> Unit)? = null): Boolean {
+ return if (Build.VERSION.SDK_INT >= sdkInt) {
+ action?.invoke()
+ true
+ } else {
+ false
+ }
+}
+
+fun sdkAndUp(sdkInt: Int, action: () -> Unit): Boolean? {
+ return if (Build.VERSION.SDK_INT >= sdkInt) {
+ action.invoke()
+ true
+ } else null
+}
+
+fun isSdkAtLeastOr(
+ sdkInt: Int,
+ action: (() -> Unit)? = null,
+ orAction: (() -> Unit)? = null
+): Boolean {
+ return if (Build.VERSION.SDK_INT >= sdkInt) {
+ action?.invoke()
+ true
+ } else {
+ orAction?.invoke()
+ false
+ }
+}
+
+fun sdk26AndUp(action: () -> Unit): Boolean? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ action.invoke()
+ true
+ } else null
+}
+
+fun sdk30AndUp(action: () -> Unit): Boolean? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ action.invoke()
+ true
+ } else null
+}
+
+fun sdk33AndUp(action: () -> Unit): Boolean? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ action.invoke()
+ true
+ } else null
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt
new file mode 100644
index 00000000..a1007d57
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt
@@ -0,0 +1,5 @@
+package com.meloda.fast.ext
+
+val Boolean?.isTrue: Boolean get() = this == true
+
+val Boolean?.isFalse: Boolean get() = this == false
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt
new file mode 100644
index 00000000..bc31d952
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt
@@ -0,0 +1,36 @@
+package com.meloda.fast.ext
+
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+
+@Suppress("UNCHECKED_CAST", "DEPRECATION")
+fun Bundle.getParcelableArrayListCompat(
+ key: String?,
+ clazz: Class
+): java.util.ArrayList? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableArrayList(key, clazz)
+ } else {
+ getParcelableArrayList(key) as ArrayList
+ }
+}
+
+@Suppress("DEPRECATION")
+fun Bundle.getParcelableCompat(key: String?, clazz: Class): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelable(key, clazz)
+ } else {
+ getParcelable(key)
+ }
+}
+
+@Suppress("DEPRECATION", "UNCHECKED_CAST")
+fun Bundle.getSerializableCompat(key: String?, clazz: Class): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializable(key, clazz)
+ } else {
+ getSerializable(key) as? T
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt
new file mode 100644
index 00000000..8ab3fa82
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt
@@ -0,0 +1,146 @@
+package com.meloda.fast.ext
+
+import android.content.res.Configuration
+import android.media.AudioManager
+import android.view.KeyEvent
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Indication
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.semantics.Role
+import com.meloda.fast.common.AppGlobal
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.model.base.parseString
+import com.meloda.fast.screens.settings.SettingsFragment
+import com.meloda.fast.util.AndroidUtils
+
+@ExperimentalFoundationApi
+fun Modifier.clickableSound(
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ role: Role? = null,
+ onClick: (() -> Unit)? = null
+): Modifier = this.clickable(
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ role = role,
+ onClick = {
+ AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK)
+ onClick?.invoke()
+ }
+)
+
+@ExperimentalFoundationApi
+fun Modifier.combinedClickableSound(
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ role: Role? = null,
+ onLongClickLabel: String? = null,
+ onLongClick: (() -> Unit)? = null,
+ onDoubleClick: (() -> Unit)? = null,
+ onClick: (() -> Unit)? = null
+): Modifier = composed {
+ this.combinedClickableSound(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = LocalIndication.current,
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ role = role,
+ onLongClickLabel = onLongClickLabel,
+ onLongClick = onLongClick,
+ onDoubleClick = onDoubleClick,
+ onClick = {
+ AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK)
+ onClick?.invoke()
+ }
+ )
+}
+
+@ExperimentalFoundationApi
+fun Modifier.combinedClickableSound(
+ interactionSource: MutableInteractionSource,
+ indication: Indication?,
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ role: Role? = null,
+ onLongClickLabel: String? = null,
+ onLongClick: (() -> Unit)? = null,
+ onDoubleClick: (() -> Unit)? = null,
+ onClick: (() -> Unit)? = null
+): Modifier = this.combinedClickable(
+ interactionSource = interactionSource,
+ indication = indication,
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ role = role,
+ onLongClickLabel = onLongClickLabel,
+ onLongClick = onLongClick,
+ onDoubleClick = onDoubleClick,
+ onClick = {
+ AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK)
+ onClick?.invoke()
+ }
+)
+
+fun Modifier.handleTabKey(
+ action: () -> Boolean
+): Modifier = this.onKeyEvent { event ->
+ if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_TAB) {
+ action.invoke()
+ } else false
+}
+
+fun Modifier.handleEnterKey(
+ action: () -> Boolean
+): Modifier = this.onKeyEvent { event ->
+ if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
+ action.invoke()
+ } else false
+}
+
+@Composable
+fun UiText?.getString(): String? {
+ return this.parseString(LocalContext.current)
+}
+
+@Composable
+fun isUsingDarkTheme(): Boolean {
+ if (LocalView.current.isInEditMode) {
+ return false
+ }
+
+ val nightThemeMode = AppGlobal.preferences.getInt(
+ SettingsFragment.KEY_APPEARANCE_DARK_THEME,
+ SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME
+ )
+ val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES
+ val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
+
+ val systemUiNightMode = AppGlobal.resources.configuration.uiMode
+
+ val isSystemBatterySaver = AndroidUtils.isBatterySaverOn()
+ val isSystemUsingDarkTheme =
+ systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+
+ return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+}
+
+@Composable
+fun isUsingDynamicColors(): Boolean =
+ if (LocalView.current.isInEditMode) true
+ else {
+ AppGlobal.preferences.getBoolean(
+ SettingsFragment.KEY_USE_DYNAMIC_COLORS,
+ SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS
+ )
+ }
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt
new file mode 100644
index 00000000..a7fd3e66
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt
@@ -0,0 +1,90 @@
+package com.meloda.fast.ext
+
+import android.content.Context
+import android.view.View
+import androidx.appcompat.app.AlertDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.model.base.parseString
+
+fun Context.showDialog(
+ title: UiText? = null,
+ message: UiText? = null,
+ isCancelable: Boolean = true,
+ positiveText: UiText? = null,
+ positiveAction: (() -> Unit)? = null,
+ negativeText: UiText? = null,
+ negativeAction: (() -> Unit)? = null,
+ neutralText: UiText? = null,
+ neutralAction: (() -> Unit)? = null,
+ onDismissAction: (() -> Unit)? = null,
+ view: View? = null,
+ items: List? = null,
+ itemsChoiceType: ItemsChoiceType = ItemsChoiceType.None,
+ itemsClickAction: ((index: Int, value: String) -> Unit)? = null,
+ itemsMultiChoiceClickAction: ((index: Int, value: String, isChecked: Boolean) -> Unit)? = null,
+ checkedItems: List? = null
+): AlertDialog {
+ val builder = MaterialAlertDialogBuilder(this)
+ .setCancelable(isCancelable)
+ .setOnDismissListener { onDismissAction?.invoke() }
+
+ title?.asString()?.let(builder::setTitle)
+ message?.asString()?.let(builder::setMessage)
+
+ view?.let(builder::setView)
+
+ positiveText?.let { text ->
+ builder.setPositiveButton(text.asString()) { _, _ -> positiveAction?.invoke() }
+ }
+ negativeText?.let { text ->
+ builder.setNegativeButton(text.asString()) { _, _ -> negativeAction?.invoke() }
+ }
+ neutralText?.let { text ->
+ builder.setNeutralButton(text.asString()) { _, _ -> neutralAction?.invoke() }
+ }
+
+ items?.mapNotNull { it.asString() }?.let { stringItems ->
+ when (itemsChoiceType) {
+ ItemsChoiceType.None -> {
+ builder.setItems(
+ stringItems.toTypedArray()
+ ) { dialog, which ->
+ dialog.dismiss()
+ itemsClickAction?.invoke(which, stringItems[which])
+ }
+ }
+
+ ItemsChoiceType.SingleChoice -> {
+ builder.setSingleChoiceItems(
+ stringItems.toTypedArray(),
+ checkedItems?.first() ?: -1
+ ) { _, which ->
+ itemsClickAction?.invoke(which, stringItems[which])
+ }
+ }
+
+ ItemsChoiceType.MultiChoice -> {
+ builder.setMultiChoiceItems(
+ stringItems.toTypedArray(),
+ BooleanArray(stringItems.size) { index -> checkedItems?.contains(index).isTrue }
+ ) { _, which, isChecked ->
+ itemsMultiChoiceClickAction?.invoke(which, stringItems[which], isChecked)
+ }
+ }
+ }
+ }
+
+ return builder.show()
+}
+
+sealed class ItemsChoiceType {
+ object None : ItemsChoiceType()
+ object SingleChoice : ItemsChoiceType()
+ object MultiChoice : ItemsChoiceType()
+}
+
+context(Context)
+fun UiText?.asString(): String? {
+ return this.parseString(this@Context)
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt b/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt
new file mode 100644
index 00000000..160e5890
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt
@@ -0,0 +1,148 @@
+package com.meloda.fast.ext
+
+import android.content.res.Configuration
+import android.content.res.Resources
+import android.util.DisplayMetrics
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.common.net.MediaType
+import com.meloda.fast.common.AppGlobal
+import com.meloda.fast.screens.settings.SettingsFragment
+import com.meloda.fast.util.AndroidUtils
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+@Deprecated("use resources or rewrite in Compose")
+fun Int.dpToPx(): Int {
+ val metrics = Resources.getSystem().displayMetrics
+ return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt()
+}
+
+@Deprecated("use resources or rewrite in Compose")
+fun Float.dpToPx(): Int {
+ val metrics = Resources.getSystem().displayMetrics
+ return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt()
+}
+
+val MediaType.mimeType: String get() = "${type()}/${subtype()}"
+
+@Throws(NullPointerException::class)
+fun T?.notNull(lazyMessage: (() -> Any)? = null): T {
+ return if (lazyMessage != null) {
+ requireNotNull(this, lazyMessage)
+ } else {
+ requireNotNull(this)
+ }
+}
+
+inline fun Iterable.findIndex(predicate: (T) -> Boolean): Int? {
+ return indexOf(firstOrNull(predicate)).let { if (it == -1) null else it }
+}
+
+inline fun > Iterable.toMap(
+ destination: M,
+ keySelector: (T) -> K,
+): M {
+ for (element in this) {
+ val key = keySelector(element)
+ destination[key] = element
+ }
+ return destination
+}
+
+fun MutableList.addIf(element: T, condition: () -> Boolean) {
+ if (condition.invoke()) add(element)
+}
+
+context(ViewModel)
+fun Flow.listenValue(action: suspend (T) -> Unit) = listenValue(viewModelScope, action)
+
+fun Flow.listenValue(
+ coroutineScope: CoroutineScope,
+ action: suspend (T) -> Unit
+): Job = onEach(action::invoke).launchIn(coroutineScope)
+
+fun isSystemUsingDarkMode(): Boolean {
+ val nightThemeMode = AppGlobal.preferences.getInt(
+ SettingsFragment.KEY_APPEARANCE_DARK_THEME,
+ SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME
+ )
+ val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES
+ val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
+
+ val systemUiNightMode = AppGlobal.resources.configuration.uiMode
+
+ val isSystemBatterySaver = AndroidUtils.isBatterySaverOn()
+ val isSystemUsingDarkTheme =
+ systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+
+ return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+}
+
+fun createTimerFlow(
+ time: Int,
+ onStartAction: suspend () -> Unit,
+ onTickAction: suspend (remainedTime: Int) -> Unit,
+ onTimeoutAction: suspend () -> Unit,
+ interval: Duration = 1.seconds
+): Flow = (time downTo 0)
+ .asSequence()
+ .asFlow()
+ .onStart { onStartAction() }
+ .onEach { timeLeft ->
+ onTickAction(timeLeft)
+ if (timeLeft == 0) {
+ onTimeoutAction()
+ } else {
+ delay(interval)
+ }
+ }
+
+fun createTimerFlow(
+ isNeedToEndCondition: suspend () -> Boolean,
+ onStartAction: (suspend () -> Unit)? = null,
+ onTickAction: (suspend () -> Unit)? = null,
+ onEndAction: (suspend () -> Unit)? = null,
+ interval: Duration = 1.seconds
+): Flow = flow {
+ while (true) {
+ val isNeedToEnd = isNeedToEndCondition()
+ emit(isNeedToEnd)
+ if (isNeedToEnd) break
+ }
+}
+ .onStart { onStartAction?.invoke() }
+ .onEach { isNeedToEnd ->
+ onTickAction?.invoke()
+ if (isNeedToEnd) {
+ onEndAction?.invoke()
+ } else {
+ delay(interval)
+ }
+ }
+
+context(ViewModel)
+fun MutableSharedFlow.emitOnMainScope(value: T) = emitOnScope(value, Dispatchers.Main)
+
+context(ViewModel)
+fun MutableSharedFlow.emitOnScope(
+ value: T,
+ dispatcher: CoroutineDispatcher = Dispatchers.Default,
+) {
+ viewModelScope.launch(dispatcher) {
+ emit(value)
+ }
+}
+
+context(CoroutineScope)
+suspend fun MutableSharedFlow.emitWithMain(value: T) {
+ withContext(Dispatchers.Main) {
+ emit(value)
+ }
+}
+
+context(ViewModel)
+fun MutableStateFlow.updateValue(newValue: T) = this.update { newValue }
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt
new file mode 100644
index 00000000..9663cd05
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt
@@ -0,0 +1,47 @@
+package com.meloda.fast.ext
+
+import android.graphics.drawable.Drawable
+import android.widget.Toast
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.meloda.fast.model.base.UiText
+import com.meloda.fast.model.base.parseString
+import kotlinx.coroutines.flow.Flow
+
+context(Fragment)
+fun Flow.listenValue(
+ action: suspend (T) -> Unit
+) = listenValue(lifecycleScope, action)
+
+
+context(Fragment)
+fun String.toast(duration: Int = Toast.LENGTH_LONG) = toast(requireContext(), duration)
+
+context(Fragment)
+fun color(@ColorRes resId: Int): Int {
+ return ContextCompat.getColor(requireContext(), resId)
+}
+
+context(Fragment)
+fun drawable(@DrawableRes resId: Int): Drawable? {
+ return ContextCompat.getDrawable(requireContext(), resId)
+}
+
+context(Fragment)
+fun string(@StringRes resId: Int): String {
+ return getString(resId)
+}
+
+context(Fragment)
+fun string(@StringRes resId: Int, vararg args: Any?): String {
+ return getString(resId, *args)
+}
+
+context(Fragment)
+fun UiText?.asString(): String? {
+ return this.parseString(this@Fragment.requireContext())
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt
similarity index 51%
rename from app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt
rename to app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt
index 04cf5275..6f8c2e73 100644
--- a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt
+++ b/app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt
@@ -1,4 +1,4 @@
-package com.meloda.fast.extensions
+package com.meloda.fast.ext
import android.graphics.Bitmap
import android.graphics.drawable.ColorDrawable
@@ -27,71 +27,85 @@ object ImageLoader {
this.setImageDrawable(null)
}
- fun ImageView.loadWithGlide(
- url: String? = null,
- uri: Uri? = null,
- drawableRes: Int? = null,
- drawable: Drawable? = null,
- placeholderDrawable: Drawable? = null,
- placeholderColor: Int? = null,
- errorDrawable: Drawable? = placeholderDrawable,
- errorColor: Int? = null,
- crossFade: Boolean = false,
- crossFadeDuration: Int? = null,
- asCircle: Boolean = false,
- transformations: List = emptyList(),
- onLoadedAction: (() -> Unit)? = null,
- onFailedAction: (() -> Unit)? = null,
- priority: Priority = Priority.NORMAL,
- cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL
- ) {
+ fun ImageView.loadWithGlide(block: GlideParams.() -> Unit) {
+ val params = GlideParams()
+ block.invoke(params)
+ loadWithGlide(params)
+ }
+
+ fun ImageView.loadWithGlide(params: GlideParams) {
val request = Glide.with(this)
var builder = when {
- url != null -> request.load(url)
- uri != null -> request.load(uri)
- drawableRes != null -> request.load(drawableRes)
+ params.imageUrl != null -> request.load(params.imageUrl)
+ params.imageUri != null -> request.load(params.imageUri)
+ params.drawableRes != null -> request.load(params.drawableRes)
drawable != null -> request.load(drawable)
else -> request.load(null as Drawable?)
}
- val transforms = transformations.toMutableList()
- if (asCircle) {
+ val transforms = params.transformations.toMutableList()
+ if (params.asCircle) {
transforms += TypeTransformations.CircleCrop
}
builder = builder
.apply(TypeTransformations.createRequestOptions(transforms))
.error(
- errorDrawable
- ?: if (errorColor != null) ColorDrawable(errorColor) else null
+ params.errorDrawable
+ ?: if (params.errorColor != null) {
+ ColorDrawable(requireNotNull(params.errorColor))
+ } else null
)
.placeholder(
- placeholderDrawable
- ?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null
+ params.placeholderDrawable
+ ?: if (params.placeholderColor != null) {
+ ColorDrawable(requireNotNull(params.placeholderColor))
+ } else null
)
- .addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction))
- .diskCacheStrategy(cacheStrategy)
- .priority(priority)
+ .addListener(ImageLoadRequestListener(params.onLoadedAction, params.onFailedAction))
+ .addListener(ImageLoadDoneListener(params.onDoneAction))
+ .diskCacheStrategy(params.cacheStrategy)
+ .priority(params.loadPriority)
- if (crossFade || crossFadeDuration != null) {
- builder = builder.transition(withCrossFade(crossFadeDuration ?: 200))
+ if (params.crossFade || params.crossFadeDuration != null) {
+ builder = builder.transition(withCrossFade(params.crossFadeDuration ?: 200))
}
builder.into(this)
}
}
+data class GlideParams(
+ var imageUrl: String? = null,
+ var imageUri: Uri? = null,
+ var drawableRes: Int? = null,
+ var imageDrawable: Drawable? = null,
+ var placeholderDrawable: Drawable? = null,
+ var placeholderColor: Int? = null,
+ var errorDrawable: Drawable? = placeholderDrawable,
+ var errorColor: Int? = null,
+ var crossFade: Boolean = false,
+ var crossFadeDuration: Int? = null,
+ var asCircle: Boolean = false,
+ var transformations: List = emptyList(),
+ var onLoadedAction: (() -> Unit)? = null,
+ var onFailedAction: (() -> Unit)? = null,
+ var onDoneAction: (() -> Unit)? = null,
+ var loadPriority: Priority = Priority.NORMAL,
+ var cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL,
+)
+
class ImageLoadRequestListener(
private val onLoadedAction: (() -> Unit)?,
- private val onFailedAction: (() -> Unit)?
+ private val onFailedAction: (() -> Unit)?,
) : RequestListener {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target?,
- isFirstResource: Boolean
+ isFirstResource: Boolean,
): Boolean {
onFailedAction?.invoke()
return false
@@ -102,13 +116,36 @@ class ImageLoadRequestListener(
model: Any?,
target: Target?,
dataSource: DataSource?,
- isFirstResource: Boolean
+ isFirstResource: Boolean,
): Boolean {
onLoadedAction?.invoke()
return false
}
}
+class ImageLoadDoneListener(private val onDoneAction: (() -> Unit)?) : RequestListener {
+ override fun onLoadFailed(
+ e: GlideException?,
+ model: Any?,
+ target: Target?,
+ isFirstResource: Boolean,
+ ): Boolean {
+ onDoneAction?.invoke()
+ return false
+ }
+
+ override fun onResourceReady(
+ resource: Drawable?,
+ model: Any?,
+ target: Target?,
+ dataSource: DataSource?,
+ isFirstResource: Boolean,
+ ): Boolean {
+ onDoneAction?.invoke()
+ return false
+ }
+}
+
sealed class TypeTransformations {
object CenterCrop : TypeTransformations()
@@ -123,7 +160,7 @@ sealed class TypeTransformations {
val topLeft: Float,
val topRight: Float,
val bottomRight: Float,
- val bottomLeft: Float
+ val bottomLeft: Float,
) : TypeTransformations()
fun toGlideTransform(): Transformation = when (this) {
@@ -149,4 +186,4 @@ sealed class TypeTransformations {
return RequestOptions().transform(* mappedTransformations)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt
new file mode 100644
index 00000000..e8b16cb1
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt
@@ -0,0 +1 @@
+package com.meloda.fast.ext
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt
new file mode 100644
index 00000000..3c615a2f
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt
@@ -0,0 +1,30 @@
+package com.meloda.fast.ext
+
+import android.content.Context
+import android.widget.Toast
+import com.meloda.fast.model.base.UiText
+
+inline fun String?.ifEmpty(defaultValue: () -> String?): String? =
+ if (this?.isEmpty() == true) defaultValue() else this
+
+fun String?.orDots(count: Int = 3): String {
+ return this ?: ("." * count)
+}
+
+operator fun String.times(count: Int): String {
+ val builder = StringBuilder()
+ for (i in 0 until count) {
+ builder.append(this)
+ }
+
+ return builder.toString()
+}
+
+fun String.toast(context: Context, duration: Int = Toast.LENGTH_LONG) {
+ Toast.makeText(context, this, duration).show()
+}
+
+context (Context)
+fun String.toast(duration: Int = Toast.LENGTH_LONG) = toast(this@Context, duration)
+
+fun String.asUiText(): UiText = UiText.Simple(this)
diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt
new file mode 100644
index 00000000..4c131c38
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt
@@ -0,0 +1,196 @@
+package com.meloda.fast.ext
+
+import android.content.Context
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.annotation.Px
+import androidx.appcompat.widget.Toolbar
+import androidx.core.view.*
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import com.meloda.fast.R
+import com.meloda.fast.common.AppGlobal
+import com.meloda.fast.databinding.ToolbarMenuItemAvatarBinding
+import com.meloda.fast.ext.ImageLoader.loadWithGlide
+
+val EditText.trimmedText: String get() = text.toString().trim()
+fun EditText.selectLast() {
+ setSelection(text.length)
+}
+
+inline fun EditText.onDone(crossinline callback: () -> Unit) {
+ imeOptions = EditorInfo.IME_ACTION_DONE
+ maxLines = 1
+ setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ callback.invoke()
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+}
+
+@Deprecated("use InsetManager")
+fun View.showKeyboard(flags: Int = 0) {
+ (AppGlobal.Instance.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+ .showSoftInput(this, flags)
+}
+
+@Deprecated("use InsetManager")
+fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) {
+ (AppGlobal.Instance.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+ .hideSoftInputFromWindow(focusedView?.windowToken ?: this.windowToken, flags)
+}
+
+fun TextInputLayout.clearError() {
+ if (error != null) error = null
+}
+
+fun TextInputLayout.toggleError(errorText: String, isNeedToShow: Boolean) {
+ if (isNeedToShow) {
+ this.error = errorText
+ } else {
+ clearError()
+ }
+}
+
+fun TextInputLayout.clearTextOnErrorIconClick(textField: TextInputEditText) {
+ setErrorIconOnClickListener {
+ textField.text = null
+ textField.showKeyboard()
+ }
+}
+
+@JvmOverloads
+fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) {
+ visibility = if (drawable != null) View.VISIBLE else visibilityWhenFalse
+}
+
+@JvmOverloads
+fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) {
+ visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse
+}
+
+fun View.setMarginsPx(
+ @Px leftMargin: Int? = null,
+ @Px topMargin: Int? = null,
+ @Px rightMargin: Int? = null,
+ @Px bottomMargin: Int? = null,
+) {
+ (layoutParams as? ViewGroup.MarginLayoutParams)?.let { params ->
+ leftMargin?.run { params.leftMargin = this }
+ topMargin?.run { params.topMargin = this }
+ rightMargin?.run { params.rightMargin = this }
+ bottomMargin?.run { params.bottomMargin = this }
+
+ requestLayout()
+ }
+}
+
+fun TextView.clear() {
+ text = null
+}
+
+fun View.invisible() = run { isInvisible = true }
+fun View.visible() = run { isVisible = true }
+fun View.gone() = run { isGone = true }
+
+@JvmOverloads
+fun View.toggleVisibility(visible: Boolean?, visibilityWhenFalse: Int = View.GONE) =
+ run { visibility = if (visible == true) View.VISIBLE else visibilityWhenFalse }
+
+fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) {
+ menu.forEach { item ->
+ item.icon?.setTint(colorToTint)
+ }
+}
+
+fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem {
+ val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate(
+ LayoutInflater.from(context), null, false
+ )
+
+ val avatarMenuItem = menu.add(context.getString(R.string.navigation_profile))
+ avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
+ avatarMenuItem.actionView = avatarMenuItemBinding.root
+
+ val imageView = avatarMenuItemBinding.avatar
+
+ when {
+ urlToLoad != null -> {
+ imageView.loadWithGlide {
+ imageUrl = urlToLoad
+ transformations = ImageLoader.userAvatarTransformations
+ }
+ }
+
+ drawable != null -> {
+ imageView.loadWithGlide {
+ imageDrawable = drawable
+ transformations = ImageLoader.userAvatarTransformations
+ }
+ }
+ }
+
+ return avatarMenuItem
+}
+
+fun View.doOnApplyWindowInsets(
+ block: (
+ view: View,
+ insets: WindowInsetsCompat,
+ paddings: Rect,
+ margins: Rect
+ ) -> WindowInsetsCompat
+) {
+ val initialPaddings = recordInitialPaddingsForView(this)
+ val initialMargins = recordInitialMarginsForView(this)
+
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ block(view, insets, initialPaddings, initialMargins)
+ }
+
+ requestApplyInsetsWhenAttached()
+}
+
+private fun recordInitialPaddingsForView(view: View) =
+ Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)
+
+private fun recordInitialMarginsForView(view: View) =
+ Rect(view.marginStart, view.marginTop, view.marginEnd, view.marginBottom)
+
+fun View.requestApplyInsetsWhenAttached() {
+ if (isAttachedToWindow) {
+ requestApplyInsets()
+ } else {
+ doOnAttach { requestApplyInsets() }
+ }
+}
+
+fun EditText.updateTextIfDiffer(text: String?) {
+ if (this.text?.toString() == text) return
+ setText(text)
+}
+
+fun ViewGroup.bulkIsEnabled(isEnabled: Boolean) {
+ this.isEnabled = isEnabled
+ toggleChildrenIsEnabled(isEnabled)
+}
+
+fun ViewGroup.toggleChildrenIsEnabled(isEnabled: Boolean) {
+ children.forEach { view -> view.toggleIsEnabled(isEnabled) }
+}
+
+fun View.toggleIsEnabled(isEnabled: Boolean) {
+ this.isEnabled = isEnabled
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt
deleted file mode 100644
index 1f2eb77b..00000000
--- a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt
+++ /dev/null
@@ -1,185 +0,0 @@
-package com.meloda.fast.extensions
-
-import android.animation.ValueAnimator
-import android.content.res.Resources
-import android.graphics.drawable.Drawable
-import android.os.Parcelable
-import android.util.DisplayMetrics
-import android.util.SparseArray
-import android.view.LayoutInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import android.widget.EditText
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.annotation.ColorInt
-import androidx.annotation.Px
-import androidx.appcompat.widget.Toolbar
-import androidx.core.view.children
-import androidx.core.view.forEach
-import androidx.lifecycle.MutableLiveData
-import com.google.common.net.MediaType
-import com.meloda.fast.common.AppGlobal
-import com.meloda.fast.databinding.ToolbarMenuItemAvatarBinding
-import com.meloda.fast.extensions.ImageLoader.loadWithGlide
-
-fun Int.dpToPx(): Int {
- val metrics = Resources.getSystem().displayMetrics
- return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt()
-}
-
-fun Float.dpToPx(): Int {
- val metrics = Resources.getSystem().displayMetrics
- return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt()
-}
-
-fun TextView.clear() {
- text = null
-}
-
-fun ViewGroup.saveChildViewStates(): SparseArray {
- val childViewStates = SparseArray()
- children.forEach { child -> child.saveHierarchyState(childViewStates) }
- return childViewStates
-}
-
-fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray) {
- children.forEach { child -> child.restoreHierarchyState(childViewStates) }
-}
-
-fun View.invisible() = run { visibility = View.INVISIBLE }
-
-fun View.visible() = run { visibility = View.VISIBLE }
-fun View.gone() = run { visibility = View.GONE }
-
-@JvmOverloads
-fun View.toggleVisibility(visible: Boolean?, visibilityWhenFalse: Int = View.GONE) =
- run { visibility = if (visible == true) View.VISIBLE else visibilityWhenFalse }
-
-fun ValueAnimator.startWithIntValues(from: Int, to: Int) {
- setIntValues(from, to)
- start()
-}
-
-fun ValueAnimator.startWithFloatValues(from: Float, to: Float) {
- setFloatValues(from, to)
- start()
-}
-
-fun View.setMarginsPx(
- @Px leftMargin: Int? = null,
- @Px topMargin: Int? = null,
- @Px rightMargin: Int? = null,
- @Px bottomMargin: Int? = null
-) {
- if (layoutParams is ViewGroup.MarginLayoutParams) {
- val params = layoutParams as ViewGroup.MarginLayoutParams
- leftMargin?.run { params.leftMargin = this }
- topMargin?.run { params.topMargin = this }
- rightMargin?.run { params.rightMargin = this }
- bottomMargin?.run { params.bottomMargin = this }
- requestLayout()
- }
-}
-
-inline fun Pair.runIfElementsNotNull(block: (T, K) -> Unit) {
- val firstCopy = first
- val secondCopy = second
- if (firstCopy != null && secondCopy != null) {
- block(firstCopy, secondCopy)
- }
-}
-
-@JvmOverloads
-fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) {
- visibility = if (drawable != null) View.VISIBLE else visibilityWhenFalse
-}
-
-@JvmOverloads
-fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) {
- visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse
-}
-
-fun View.showKeyboard(flags: Int = 0) {
- AppGlobal.inputMethodManager.showSoftInput(this, flags)
-}
-
-fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) {
- AppGlobal.inputMethodManager.hideSoftInputFromWindow(
- focusedView?.windowToken ?: this.windowToken, flags
- )
-}
-
-fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) {
- menu.forEach { item ->
- item.icon?.setTint(colorToTint)
- }
-}
-
-fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem {
- val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate(
- LayoutInflater.from(context), null, false
- )
-
- val avatarMenuItem = menu.add("Profile")
- avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
- avatarMenuItem.actionView = avatarMenuItemBinding.root
-
- val imageView = avatarMenuItemBinding.avatar
-
- when {
- urlToLoad != null -> {
- imageView.loadWithGlide(
- url = urlToLoad,
- transformations = ImageLoader.userAvatarTransformations
- )
- }
- drawable != null -> {
- imageView.loadWithGlide(
- drawable = drawable,
- transformations = ImageLoader.userAvatarTransformations
- )
- }
- }
-
- return avatarMenuItem
-}
-
-fun MutableLiveData.notifyObservers() {
- this.value = this.value
-}
-
-fun MutableLiveData.setIfNotEquals(item: T) {
- if (this.value != item) this.value = item
-}
-
-fun MutableLiveData.requireValue(): T {
- return this.value!!
-}
-
-val EditText.trimmedText: String get() = text.toString().trim()
-
-val MediaType.mimeType: String get() = "${type()}/${subtype()}"
-
-fun EditText.selectLast() {
- setSelection(text.length)
-}
-
-fun T?.requireNotNull(): T {
- return requireNotNull(this)
-}
-
-
-fun String?.orDots(count: Int = 3): String {
- return this ?: ("." * count)
-}
-
-private operator fun String.times(count: Int): String {
- val builder = StringBuilder()
- for (i in 0 until count) {
- builder.append(this)
- }
-
- return builder.toString()
-}
diff --git a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt
deleted file mode 100644
index 965aa08c..00000000
--- a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.meloda.fast.model
-
-abstract class DataItem {
- abstract val dataItemId: IdType
-
- object Header : DataItem() {
- override val dataItemId = Int.MIN_VALUE
- }
-
- object Footer : DataItem() {
- override val dataItemId = Int.MIN_VALUE + 1
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt b/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt
index e17b0aa4..1eb415c4 100644
--- a/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt
+++ b/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt
@@ -6,17 +6,10 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
-open class SelectableItem constructor(
- @Ignore
- val selectableItemId: Int = 0
-) : DataItem(), Parcelable {
+open class SelectableItem : Parcelable {
@Ignore
@IgnoredOnParcel
var isSelected: Boolean = false
- @Ignore
- @IgnoredOnParcel
- override val dataItemId = selectableItemId
-
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt
index 208b2b9a..e293551f 100644
--- a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt
+++ b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt
@@ -18,7 +18,7 @@ data class UpdateItem(
val originalName: String,
val fileSize: Int,
val preRelease: Int,
- val downloadLink: String
+ val downloadLink: String,
) : Parcelable {
fun isMandatory(): Boolean = mandatory == 1
@@ -29,7 +29,25 @@ data class UpdateItem(
return Gson().toJson(this)
}
+ companion object {
+ val EMPTY = UpdateItem(
+ id = 0,
+ versionName = "1.0.0",
+ versionCode = 2,
+ mandatory = 1,
+ changelog = "Some kind of simple changelog",
+ enabled = 1,
+ fileName = "bruhmeme.apk",
+ date = System.currentTimeMillis(),
+ extension = "",
+ originalName = "",
+ fileSize = 0,
+ preRelease = 0,
+ downloadLink = "https://c4.kemono.party/data/98/8c/988cf166f1ee9cd318e2407e6bfbabf60bffa53ed229ea0b2434009f1598e039.png?f=JessieGym002b4pt.png"
+ )
+ }
+
}
@Parcelize
-data class UpdateActualUrl(val url: String) : Parcelable
\ No newline at end of file
+data class UpdateActualUrl(val url: String) : Parcelable
diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt b/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt
new file mode 100644
index 00000000..015fde04
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt
@@ -0,0 +1,14 @@
+package com.meloda.fast.model.base
+
+interface AdapterDiffItem {
+
+ val id: Int
+
+ fun areItemsTheSame(newItem: AdapterDiffItem): Boolean {
+ return id == newItem.id
+ }
+
+ fun areContentsTheSame(newItem: AdapterDiffItem): Boolean {
+ return this == newItem
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt b/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt
new file mode 100644
index 00000000..96878c4e
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt
@@ -0,0 +1,3 @@
+package com.meloda.fast.model.base
+
+interface DisplayableItem
diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt b/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt
new file mode 100644
index 00000000..f5042622
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt
@@ -0,0 +1,94 @@
+package com.meloda.fast.model.base
+
+import android.content.Context
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import com.meloda.fast.ext.GlideParams
+import com.meloda.fast.ext.ImageLoader.loadWithGlide
+
+sealed class UiImage {
+
+ data class Resource(@DrawableRes val resId: Int) : UiImage()
+
+ data class Simple(val drawable: Drawable?) : UiImage()
+
+ data class Color(@ColorInt val color: Int) : UiImage()
+
+ data class ColorResource(@ColorRes val resId: Int) : UiImage()
+
+ data class Url(val url: String) : UiImage()
+
+ fun extractUrl(): String? = when (this) {
+ is Url -> this.url
+ else -> null
+ }
+
+ fun getResourceId(): Int? = when(this) {
+ is Resource -> this.resId
+ else -> null
+ }
+}
+
+fun ImageView.setImage(image: UiImage, glideBlock: GlideParams.() -> Unit) {
+ val glideParams = GlideParams()
+ glideBlock.invoke(glideParams)
+ this.setImage(image, glideParams)
+}
+
+fun ImageView.setImage(image: UiImage, glideParams: GlideParams? = null) {
+ image.attachTo(this, glideParams)
+}
+
+fun UiImage?.attachTo(imageView: ImageView, glideBlock: GlideParams.() -> Unit) {
+ val glideParams = GlideParams()
+ glideBlock.invoke(glideParams)
+ this.attachTo(imageView, glideParams)
+}
+
+fun UiImage?.attachTo(imageView: ImageView, glideParams: GlideParams? = null) {
+ when (this) {
+ is UiImage.Simple -> imageView.setImageDrawable(drawable)
+ is UiImage.Resource -> imageView.setImageResource(resId)
+ is UiImage.Color -> imageView.setImageDrawable(ColorDrawable(color))
+ is UiImage.ColorResource -> imageView.setImageDrawable(
+ ColorDrawable(ContextCompat.getColor(imageView.context, resId))
+ )
+
+ is UiImage.Url -> glideParams?.let { params ->
+ params.imageUrl = url
+ imageView.loadWithGlide(params)
+ }
+
+ else -> Unit
+ }
+}
+
+fun UiImage?.asDrawable(context: Context): Drawable? {
+ return when (this) {
+ is UiImage.Simple -> drawable
+ is UiImage.Resource -> ContextCompat.getDrawable(context, resId)
+ is UiImage.Color -> ColorDrawable(color)
+ is UiImage.ColorResource -> ColorDrawable(ContextCompat.getColor(context, resId))
+ else -> null
+ }
+}
+
+@Composable
+fun UiImage?.getImage(): Any? {
+ val context = LocalContext.current
+ return when(this) {
+ is UiImage.Color -> ColorDrawable(color)
+ is UiImage.ColorResource -> ColorDrawable(ContextCompat.getColor(context, resId))
+ is UiImage.Resource -> ContextCompat.getDrawable(context, resId)
+ is UiImage.Simple -> drawable
+ is UiImage.Url -> url
+ null -> null
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt b/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt
new file mode 100644
index 00000000..6fdcac24
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt
@@ -0,0 +1,42 @@
+package com.meloda.fast.model.base
+
+import android.content.Context
+import android.os.Parcelable
+import androidx.annotation.PluralsRes
+import androidx.annotation.StringRes
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+
+@Parcelize
+sealed class UiText : Parcelable {
+
+ data class Resource(@StringRes val resId: Int) : UiText()
+
+ data class ResourceParams(
+ @StringRes val value: Int,
+ val args: List<@RawValue Any?>,
+ ) : UiText()
+
+ data class Simple(val text: String) : UiText()
+
+ data class QuantityResource(@PluralsRes val resId: Int, val quantity: Int) : UiText()
+}
+
+fun UiText?.parseString(context: Context): String? {
+ return when (this) {
+ is UiText.Resource -> context.getString(resId)
+ is UiText.ResourceParams -> {
+ val processedArgs = args.map { any ->
+ when (any) {
+ is UiText -> any.parseString(context)
+ else -> any
+ }
+ }
+ context.getString(value, *processedArgs.toTypedArray())
+ }
+
+ is UiText.QuantityResource -> context.resources.getQuantityString(resId, quantity, quantity)
+ is UiText.Simple -> text
+ else -> null
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt
new file mode 100644
index 00000000..6379f645
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt
@@ -0,0 +1,36 @@
+package com.meloda.fast.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.edit
+import com.meloda.fast.common.AppGlobal
+import com.meloda.fast.screens.main.activity.LongPollState
+import com.meloda.fast.screens.main.activity.MainActivity
+import com.meloda.fast.screens.settings.SettingsFragment
+import kotlinx.coroutines.flow.update
+
+class StopLongPollServiceReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == ACTION_STOP) {
+ val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
+
+ if (notificationId != -1) {
+ NotificationManagerCompat.from(context).cancel(notificationId)
+ }
+
+ AppGlobal.preferences.edit {
+ putBoolean(SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, false)
+ }
+
+ MainActivity.longPollState.update { LongPollState.Stop }
+ MainActivity.longPollState.update { LongPollState.DefaultService }
+ }
+ }
+
+ companion object {
+ const val ACTION_STOP = "stop_long_poll"
+ const val NOTIFICATION_ID = "notification_id"
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt
new file mode 100644
index 00000000..e6542189
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt
@@ -0,0 +1,11 @@
+package com.meloda.fast.screens.captcha
+
+import com.github.terrakok.cicerone.androidx.FragmentScreen
+import com.meloda.fast.screens.captcha.presentation.CaptchaFragment
+
+object CaptchaScreens {
+
+ fun captchaScreen() = FragmentScreen(key = "CaptchaScreen") {
+ CaptchaFragment.newInstance()
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt
new file mode 100644
index 00000000..90de93a8
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt
@@ -0,0 +1,36 @@
+package com.meloda.fast.screens.captcha.di
+
+import com.meloda.fast.di.navigationModule
+import com.meloda.fast.screens.captcha.presentation.CaptchaCoordinator
+import com.meloda.fast.screens.captcha.presentation.CaptchaCoordinatorImpl
+import com.meloda.fast.screens.captcha.presentation.CaptchaViewModelImpl
+import com.meloda.fast.screens.captcha.screen.CaptchaScreen
+import com.meloda.fast.screens.captcha.validation.CaptchaValidator
+import org.koin.androidx.viewmodel.dsl.viewModelOf
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.qualifier.named
+import org.koin.core.scope.Scope
+import org.koin.dsl.bind
+import org.koin.dsl.module
+
+val captchaModule = module {
+ val moduleQualifier = named("captcha")
+
+ includes(navigationModule)
+
+ single(moduleQualifier) { screen().resultFlow }
+ single { screen().getArguments() }
+
+ single {
+ CaptchaCoordinatorImpl(
+ resultFlow = get(moduleQualifier),
+ router = get()
+ )
+ } bind CaptchaCoordinator::class
+
+ singleOf(::CaptchaValidator)
+ viewModelOf(::CaptchaViewModelImpl)
+}
+
+private fun Scope.screen(): CaptchaScreen = get()
+
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt
new file mode 100644
index 00000000..ba8062b7
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt
@@ -0,0 +1,18 @@
+package com.meloda.fast.screens.captcha.model
+
+data class CaptchaScreenState(
+ val captchaSid: String,
+ val captchaImage: String,
+ val captchaCode: String,
+ val codeError: Boolean
+) {
+
+ companion object {
+ val EMPTY = CaptchaScreenState(
+ captchaSid = "",
+ captchaImage = "",
+ captchaCode = "",
+ codeError = false
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt
new file mode 100644
index 00000000..6fddec93
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt
@@ -0,0 +1,8 @@
+package com.meloda.fast.screens.captcha.model
+
+sealed class CaptchaValidationResult {
+ object Empty : CaptchaValidationResult()
+ object Valid : CaptchaValidationResult()
+
+ fun isValid() = this == Valid
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt
new file mode 100644
index 00000000..45164b7e
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt
@@ -0,0 +1,21 @@
+package com.meloda.fast.screens.captcha.presentation
+
+import com.github.terrakok.cicerone.Router
+import com.meloda.fast.screens.captcha.screen.CaptchaResult
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+interface CaptchaCoordinator {
+
+ fun finishWithResult(result: CaptchaResult)
+}
+
+class CaptchaCoordinatorImpl constructor(
+ val resultFlow: MutableSharedFlow,
+ val router: Router
+) : CaptchaCoordinator {
+
+ override fun finishWithResult(result: CaptchaResult) {
+ resultFlow.tryEmit(result)
+ router.exit()
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt
new file mode 100644
index 00000000..292da0f2
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt
@@ -0,0 +1,234 @@
+package com.meloda.fast.screens.captcha.presentation
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.addCallback
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.request.ImageRequest
+import com.meloda.fast.R
+import com.meloda.fast.base.BaseFragment
+import com.meloda.fast.screens.captcha.model.CaptchaScreenState
+import com.meloda.fast.ui.*
+import com.meloda.fast.ui.widgets.CoilImage
+import com.meloda.fast.ui.widgets.TextFieldErrorText
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class CaptchaFragment : BaseFragment() {
+
+ private val viewModel: CaptchaViewModel by viewModel()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ) = ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ AppTheme {
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding()
+ ) {
+ val state by viewModel.screenState.collectAsStateWithLifecycle()
+
+ CaptchaScreen(
+ onCancelButtonClicked = viewModel::onCancelButtonClicked,
+ onCodeInputChanged = viewModel::onCodeInputChanged,
+ onTextFieldDoneClicked = viewModel::onTextFieldDoneClicked,
+ onDoneButtonClicked = viewModel::onDoneButtonClicked,
+ state = state
+ )
+ }
+ }
+ }
+ }
+
+ @Preview
+ @Composable
+ fun CaptchaScreenPreview() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CaptchaScreen(
+ onCancelButtonClicked = {},
+ onCodeInputChanged = {},
+ onTextFieldDoneClicked = {},
+ onDoneButtonClicked = {},
+ state = CaptchaScreenState.EMPTY
+ )
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun CaptchaScreen(
+ onCancelButtonClicked: () -> Unit,
+ onCodeInputChanged: (String) -> Unit,
+ onTextFieldDoneClicked: () -> Unit,
+ onDoneButtonClicked: () -> Unit,
+ state: CaptchaScreenState,
+ ) {
+ val focusManager = LocalFocusManager.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(30.dp),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ ExtendedFloatingActionButton(
+ onClick = onCancelButtonClicked,
+ text = {
+ Text(
+ text = "Cancel",
+ color = MaterialTheme.colorScheme.primary
+ )
+ },
+ icon = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_round_close_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = "Captcha",
+ style = MaterialTheme.typography.displayMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(modifier = Modifier.height(38.dp))
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "To proceed with your action, enter a code from the picture",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(0.5f)
+ )
+ Spacer(modifier = Modifier.width(24.dp))
+
+ CoilImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(state.captchaImage)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .border(
+ 2.dp,
+ MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(10.dp)
+ )
+ .clip(RoundedCornerShape(10.dp))
+ .height(48.dp)
+ .width(130.dp),
+ contentScale = ContentScale.FillBounds,
+ previewPainter = painterResource(id = R.drawable.test_captcha)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(30.dp))
+
+ var code by remember { mutableStateOf(TextFieldValue(state.captchaCode)) }
+ val showError = state.codeError
+
+ TextField(
+ value = code,
+ onValueChange = { newText ->
+ code = newText
+ onCodeInputChanged(newText.text)
+ },
+ label = { Text(text = "Code") },
+ placeholder = { Text(text = "Code") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp)),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.round_qr_code_24),
+ contentDescription = null,
+ tint = if (showError) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ )
+ },
+ shape = RoundedCornerShape(10.dp),
+ keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ onTextFieldDoneClicked()
+ }
+ ),
+ isError = showError
+ )
+
+ AnimatedVisibility(visible = showError) {
+ TextFieldErrorText(text = "Field must not be empty")
+ }
+ }
+
+ FloatingActionButton(
+ onClick = onDoneButtonClicked,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_round_done_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ activity?.onBackPressedDispatcher?.addCallback {
+ viewModel.onBackButtonClicked()
+ }
+ }
+
+ companion object {
+
+ fun newInstance(): CaptchaFragment {
+ return CaptchaFragment()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt
new file mode 100644
index 00000000..f1da9c4e
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt
@@ -0,0 +1,79 @@
+package com.meloda.fast.screens.captcha.presentation
+
+import androidx.lifecycle.ViewModel
+import com.meloda.fast.ext.updateValue
+import com.meloda.fast.screens.captcha.model.CaptchaScreenState
+import com.meloda.fast.screens.captcha.screen.CaptchaArguments
+import com.meloda.fast.screens.captcha.screen.CaptchaResult
+import com.meloda.fast.screens.captcha.validation.CaptchaValidator
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+interface CaptchaViewModel {
+
+ val screenState: StateFlow
+
+ fun onCodeInputChanged(newCode: String)
+
+ fun onBackButtonClicked()
+ fun onCancelButtonClicked()
+ fun onTextFieldDoneClicked()
+ fun onDoneButtonClicked()
+}
+
+class CaptchaViewModelImpl constructor(
+ private val coordinator: CaptchaCoordinator,
+ private val validator: CaptchaValidator,
+ arguments: CaptchaArguments
+) : CaptchaViewModel, ViewModel() {
+
+ override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
+
+ init {
+ screenState.updateValue(
+ screenState.value.copy(
+ captchaSid = arguments.captchaSid,
+ captchaImage = arguments.captchaImage
+ )
+ )
+ }
+
+ override fun onCodeInputChanged(newCode: String) {
+ val newState = screenState.value.copy(captchaCode = newCode.trim())
+ screenState.update { newState }
+ processValidation()
+ }
+
+ override fun onBackButtonClicked() {
+ onCancelButtonClicked()
+ }
+
+ override fun onCancelButtonClicked() {
+ coordinator.finishWithResult(CaptchaResult.Cancelled)
+ }
+
+ override fun onTextFieldDoneClicked() {
+ onDoneButtonClicked()
+ }
+
+ override fun onDoneButtonClicked() {
+ if (!processValidation()) return
+
+ val captchaSid = screenState.value.captchaSid
+ val captchaCode = screenState.value.captchaCode
+
+ coordinator.finishWithResult(
+ CaptchaResult.Success(
+ sid = captchaSid,
+ code = captchaCode
+ )
+ )
+ }
+
+ private fun processValidation(): Boolean {
+ val isValid = validator.validate(screenState.value).isValid()
+ screenState.updateValue(screenState.value.copy(codeError = !isValid))
+ return isValid
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt
new file mode 100644
index 00000000..751f2223
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt
@@ -0,0 +1,3 @@
+package com.meloda.fast.screens.captcha.screen
+
+data class CaptchaArguments(val captchaSid: String, val captchaImage: String)
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt
new file mode 100644
index 00000000..1504a66e
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt
@@ -0,0 +1,6 @@
+package com.meloda.fast.screens.captcha.screen
+
+sealed class CaptchaResult {
+ object Cancelled : CaptchaResult()
+ data class Success(val sid: String, val code: String) : CaptchaResult()
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt
new file mode 100644
index 00000000..7aeca346
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt
@@ -0,0 +1,21 @@
+package com.meloda.fast.screens.captcha.screen
+
+import com.github.terrakok.cicerone.Router
+import com.meloda.fast.base.screen.AppScreen
+import com.meloda.fast.base.screen.createResultFlow
+import com.meloda.fast.screens.captcha.CaptchaScreens
+import kotlin.properties.Delegates
+
+class CaptchaScreen : AppScreen {
+
+ override val resultFlow = createResultFlow()
+
+ override var args: CaptchaArguments by Delegates.notNull()
+
+ override fun show(router: Router, args: CaptchaArguments) {
+ this.args = args
+ router.navigateTo(CaptchaScreens.captchaScreen())
+ }
+}
+
+
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt
new file mode 100644
index 00000000..d3e332a2
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt
@@ -0,0 +1,14 @@
+package com.meloda.fast.screens.captcha.validation
+
+import com.meloda.fast.screens.captcha.model.CaptchaScreenState
+import com.meloda.fast.screens.captcha.model.CaptchaValidationResult
+
+class CaptchaValidator {
+
+ fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
+ return when {
+ screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty
+ else -> CaptchaValidationResult.Valid
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt
new file mode 100644
index 00000000..1131d301
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt
@@ -0,0 +1,299 @@
+package com.meloda.fast.screens.chatinfo
+
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import by.kirich1409.viewbindingdelegate.viewBinding
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.tabs.TabLayoutMediator
+import com.meloda.fast.R
+import com.meloda.fast.api.model.VkChat
+import com.meloda.fast.api.model.VkGroup
+import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.api.model.domain.VkConversationDomain
+import com.meloda.fast.base.viewmodel.BaseViewModelFragment
+import com.meloda.fast.base.viewmodel.VkEvent
+import com.meloda.fast.databinding.FragmentChatInfoBinding
+import com.meloda.fast.ext.ImageLoader.loadWithGlide
+import com.meloda.fast.ext.getParcelableCompat
+import com.meloda.fast.ext.gone
+import com.meloda.fast.ext.orDots
+import com.meloda.fast.ext.visible
+import com.meloda.fast.screens.messages.MessagesHistoryFragment
+import dev.chrisbanes.insetter.applyInsetter
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class ChatInfoFragment : BaseViewModelFragment(R.layout.fragment_chat_info) {
+
+ companion object {
+ const val KeyConfirmRemoveChatUser = "confirm_remove_chat_user"
+ const val KeyRemoveChatUser = "remove_chat_user"
+ const val ArgMemberId = "member_id"
+
+ private const val ArgConversation = "conversation"
+ private const val ArgUser = "user"
+ private const val ArgGroup = "group"
+
+ fun newInstance(
+ conversation: VkConversationDomain,
+ user: VkUser?,
+ group: VkGroup?,
+ ): ChatInfoFragment {
+ val fragment = ChatInfoFragment()
+ fragment.arguments = bundleOf(
+ ArgConversation to conversation,
+ ArgUser to user,
+ ArgGroup to group
+ )
+
+ return fragment
+ }
+ }
+
+ override val viewModel: ChatInfoViewModel by viewModel()
+
+ private val binding by viewBinding(FragmentChatInfoBinding::bind)
+
+ private val user: VkUser? by lazy {
+ requireArguments().getParcelableCompat(MessagesHistoryFragment.ARG_USER, VkUser::class.java)
+ }
+
+ private val group: VkGroup? by lazy {
+ requireArguments().getParcelableCompat(
+ MessagesHistoryFragment.ARG_GROUP,
+ VkGroup::class.java
+ )
+ }
+
+ private val conversation: VkConversationDomain by lazy {
+ requireNotNull(
+ requireArguments().getParcelableCompat(
+ MessagesHistoryFragment.ARG_CONVERSATION,
+ VkConversationDomain::class.java
+ )
+ )
+ }
+
+ private val chatProfiles: MutableList = mutableListOf()
+ private val chatGroups: MutableList = mutableListOf()
+ private val chatMembers: MutableList = mutableListOf()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel.getConversationMembers(conversation.id)
+
+ val title = when {
+ conversation.isChat() -> conversation.conversationTitle
+ conversation.isUser() -> user?.toString()
+ conversation.isGroup() -> group?.name
+ else -> null
+ }
+
+
+ binding.toolbar.applyInsetter {
+ type(statusBars = true) { padding() }
+ }
+ binding.progresBar.applyInsetter {
+ type(navigationBars = true) { padding() }
+ }
+ binding.toolbar.title = title.orDots()
+
+ updateStatus()
+
+ val avatar = when {
+ conversation.isUser() -> user?.photo200
+ conversation.isGroup() -> group?.photo200
+ conversation.isChat() -> conversation.conversationPhoto
+ else -> null
+ }
+
+ val avatarImageView = binding.toolbar.avatarImageView
+ avatarImageView.visible()
+ avatarImageView.loadWithGlide {
+ imageUrl = avatar
+ asCircle = true
+ crossFade = true
+ }
+
+ binding.toolbar.avatarClickAction = {
+ showAvatarOptions()
+ }
+ binding.toolbar.startButtonClickAction =
+ { requireActivity().onBackPressedDispatcher.onBackPressed() }
+
+ binding.viewPager.offscreenPageLimit = getTabsCount() - 1
+
+ childFragmentManager.setFragmentResultListener(
+ KeyConfirmRemoveChatUser,
+ this
+ ) { _, bundle ->
+ val memberId = bundle.getInt(ArgMemberId)
+ showConfirmRemoveMemberAlert(memberId)
+ }
+ }
+
+ override fun onEvent(event: VkEvent) {
+ super.onEvent(event)
+
+ when (event) {
+ is GetConversationMembersEvent -> {
+ fillChatInfo(event)
+ }
+
+ is RemoveChatUserEvent -> {
+ val memberId = event.memberId
+ childFragmentManager.setFragmentResult(
+ KeyRemoveChatUser, bundleOf(
+ ArgMemberId to memberId
+ )
+ )
+ }
+ }
+ }
+
+ // TODO: 17.04.2023, Danil Nikolaev: handle loading
+ private fun onProgressStart() {
+ binding.tabs.gone()
+ binding.viewPager.gone()
+ binding.progresBar.visible()
+ }
+
+ private fun onProgressStop() {
+ binding.tabs.visible()
+ binding.viewPager.visible()
+ binding.progresBar.gone()
+ }
+
+ private fun fillChatInfo(event: GetConversationMembersEvent) {
+ val onlineMembers = event.profiles.filter { it.online }
+ updateStatus(onlineMembers.size)
+
+ val eventChatMembers = event.items.map { vkChatMember ->
+ val memberUser: VkUser? = if (vkChatMember.memberId < 0) null
+ else event.profiles.firstOrNull { it.id == vkChatMember.memberId }
+
+ val memberGroup: VkGroup? = if (vkChatMember.memberId > 0) null
+ else event.groups.firstOrNull { it.id == vkChatMember.memberId }
+
+ VkChat.ChatMember(
+ id = vkChatMember.memberId,
+ type = if (vkChatMember.memberId > 0) VkChat.ChatMember.ChatMemberType.Profile else VkChat.ChatMember.ChatMemberType.Group,
+ isOnline = memberUser?.online,
+ lastSeen = memberUser?.lastSeen,
+ name = memberGroup?.name,
+ firstName = memberUser?.firstName,
+ lastName = memberUser?.lastName,
+ invitedBy = vkChatMember.invitedBy,
+ photo50 = null,
+ photo100 = null,
+ photo200 = memberUser?.photo200 ?: memberGroup?.photo200,
+ isOwner = vkChatMember.isOwner,
+ isAdmin = vkChatMember.isAdmin,
+ canKick = vkChatMember.canKick
+ )
+ }
+
+ chatProfiles.addAll(event.profiles)
+ chatGroups.addAll(event.groups)
+ chatMembers.addAll(eventChatMembers)
+ prepareTabs()
+ }
+
+ private fun updateStatus(onlineMembersCount: Int? = null) {
+ val status = when {
+ conversation.isChat() -> {
+ val membersCountText = "${conversation.membersCount} members"
+ if (onlineMembersCount == null) membersCountText
+ else {
+ "$membersCountText, $onlineMembersCount online"
+ }
+ }
+
+ conversation.isUser() -> when {
+ // TODO: 9/15/2021 user normal time
+ user?.online == true -> "Online"
+ user?.lastSeen != null -> "Last seen at ${
+ SimpleDateFormat(
+ "HH:mm",
+ Locale.getDefault()
+ ).format(user?.lastSeen!! * 1000L)
+ }"
+
+ else -> if (user?.lastSeenStatus != null) "Last seen ${user?.lastSeenStatus!!}" else "Last seen recently"
+ }
+
+ conversation.isGroup() -> if (group?.membersCount != null) "${group?.membersCount} members" else "Group"
+ else -> null
+ }
+
+ binding.toolbar.subtitle = status.orDots()
+
+ }
+
+ fun getTabsCount(): Int {
+ return if (conversation.isChat()) 6 else 5
+ }
+
+ fun createTabFragment(position: Int): Fragment {
+ if (conversation.isChat() && position == 0) {
+ return ChatInfoMembersFragment.newInstance(
+ chatProfiles,
+ chatGroups,
+ chatMembers
+ )
+ }
+
+ return Fragment()
+ }
+
+ private fun prepareTabs() {
+ val titles = mutableListOf("Members", "Photos", "Videos", "Audios", "Files", "Links")
+
+ if (!conversation.isChat()) {
+ titles.removeAt(0)
+ }
+
+ binding.viewPager.adapter = ChatInfoPagerAdapter(this)
+
+ TabLayoutMediator(binding.tabs, binding.viewPager) { tab, position ->
+ tab.text = titles[position]
+ }.attach()
+ }
+
+ private fun showConfirmRemoveMemberAlert(memberId: Int) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.warning)
+ .setMessage(R.string.confirm_remove_chat_user)
+ .setPositiveButton(R.string.yes) { _, _ ->
+ viewModel.removeChatUser(conversation.localId, memberId)
+ }
+ .setNegativeButton(R.string.no, null)
+ .show()
+ }
+
+ private fun showAvatarOptions() {
+ val options = mutableListOf("Open")
+
+ if (conversation.canChangeInfo) {
+ options += listOf("Edit", "Delete")
+ }
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setItems(options.toTypedArray()) { _, which ->
+ when (options[which]) {
+ "Open" -> {
+ Toast.makeText(requireContext(), "Open photo", Toast.LENGTH_SHORT).show()
+ }
+
+ else ->
+ Toast.makeText(requireContext(), "Change info", Toast.LENGTH_SHORT).show()
+ }
+ }
+ .show()
+ }
+}
diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt
new file mode 100644
index 00000000..b9a45f04
--- /dev/null
+++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt
@@ -0,0 +1,111 @@
+package com.meloda.fast.screens.chatinfo
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import com.meloda.fast.R
+import com.meloda.fast.api.UserConfig
+import com.meloda.fast.api.model.VkChat
+import com.meloda.fast.api.model.VkGroup
+import com.meloda.fast.api.model.VkUser
+import com.meloda.fast.base.adapter.BaseAdapter
+import com.meloda.fast.base.adapter.BaseHolder
+import com.meloda.fast.databinding.ItemChatMemberBinding
+import com.meloda.fast.ext.ImageLoader.loadWithGlide
+import com.meloda.fast.ext.toggleVisibility
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.Objects
+
+class ChatInfoMembersAdapter(
+ context: Context,
+ preAddedValues: List