upstream changes

upstream changes
This commit is contained in:
2023-08-09 03:49:14 +03:00
committed by GitHub
parent d3bbdc75f5
commit 8a6378f509
302 changed files with 13608 additions and 6377 deletions
+112 -51
View File
@@ -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.api.BaseVariantOutputImpl
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage", "\"\"") val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage", "\"\"")
val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint", "\"\"") val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint", "\"\"")
@@ -17,13 +19,14 @@ plugins {
id("kotlin-android") id("kotlin-android")
id("kotlin-kapt") id("kotlin-kapt")
id("kotlin-parcelize") id("kotlin-parcelize")
id("dagger.hilt.android.plugin") id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
} }
android { android {
namespace = "com.meloda.fast" namespace = "com.meloda.fast"
compileSdk = 32 compileSdk = 34
applicationVariants.all { applicationVariants.all {
outputs.all { outputs.all {
@@ -34,14 +37,14 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.meloda.fast" applicationId = "com.meloda.fast"
minSdk = 23 minSdk = 24
targetSdk = 32 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "alpha" versionName = "alpha"
javaCompileOptions { javaCompileOptions {
annotationProcessorOptions { 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn") jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
} }
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = "1.4.5"
useLiveLiterals = true
}
packagingOptions {
jniLibs {
useLegacyPackaging = false
}
}
}
kapt {
correctErrorTypes = true
} }
fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion" fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
@@ -92,74 +128,99 @@ fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
val currentTime get() = (System.currentTimeMillis() / 1000).toInt() val currentTime get() = (System.currentTimeMillis() / 1000).toInt()
dependencies { dependencies {
implementation(kotlin("reflect", "1.6.10"))
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.viewmodel) // DI zone
implementation(libs.androidx.lifecycle.livedata) implementation("io.insert-koin:koin-android:3.4.0")
implementation(libs.androidx.lifecycle.runtime) // end of DI zone
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.lifecycle.common.java8)
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("androidx.preference:preference-ktx:1.2.0")
implementation(libs.androidx.room.runtime)
kapt(libs.androidx.room.compiler)
implementation(libs.cicerone) implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation(libs.waveformSeekBar) implementation("androidx.recyclerview:recyclerview:1.3.1")
implementation(libs.glide) implementation("androidx.constraintlayout:constraintlayout:2.1.4")
kapt(libs.glide.compiler)
implementation(libs.kPermissions) implementation("com.google.accompanist:accompanist-systemuicontroller:0.27.0")
implementation(libs.kPermissions.coroutines)
implementation(libs.appCenter.analytics) implementation("androidx.room:room-ktx:2.5.2")
implementation(libs.appCenter.crashes) implementation("androidx.room:room-runtime:2.5.2")
ksp("androidx.room:room-compiler:2.5.2")
implementation(libs.hilt) implementation("com.github.terrakok:cicerone:7.1")
kapt(libs.hilt.compiler)
implementation(libs.retrofit) implementation("com.github.massoudss:waveformSeekBar:5.0.0")
implementation(libs.retrofit.gson.converter)
implementation(libs.okhttp3) implementation("com.github.bumptech.glide:glide:4.15.1")
implementation(libs.okhttp3.interceptor) ksp("com.github.bumptech.glide:compiler:4.15.1")
implementation(libs.coroutines.core) implementation("com.github.fondesa:kpermissions:3.4.0")
implementation(libs.coroutines.android) 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) 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
} }
@@ -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')"
]
}
}
@@ -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')"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#5B37DD</color>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Fast Dev</string>
</resources>
+64 -17
View File
@@ -3,36 +3,35 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".common.AppGlobal" android:name=".common.AppGlobal"
android:allowBackup="false" android:allowBackup="false"
android:extractNativeLibs="false" android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:localeConfig="@xml/locales_config"
android:supportsRtl="true" android:supportsRtl="false"
android:testOnly="false" android:testOnly="false"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="DataExtractionRules"
tools:replace="android:allowBackup" tools:replace="android:allowBackup"
tools:ignore="DataExtractionRules"> tools:targetApi="tiramisu">
<activity <activity
android:name=".screens.main.MainActivity" android:name=".screens.main.activity.MainActivity"
android:theme="@style/AppTheme.Splash"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -40,15 +39,64 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".screens.testing.TestActivity" />
<service <service
android:name=".service.LongPollService" android:name=".service.LongPollService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false"
android:foregroundServiceType="dataSync" />
<service <service
android:name=".service.OnlineService" android:name=".service.OnlineService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".service.LongPollQSTileService"
android:enabled="true"
android:exported="true"
android:icon="@drawable/ic_round_settings_24"
android:label="Open settings"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service
android:name=".service.MyCustomControlService"
android:exported="true"
android:label="Fast"
android:permission="android.permission.BIND_CONTROLS">
<intent-filter>
<action android:name="android.service.controls.ControlsProviderService" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<receiver
android:name=".receiver.StopLongPollServiceReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.meloda.fast.receiver.ACTION_STOP" />
</intent-filter>
</receiver>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -60,5 +108,4 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
</application> </application>
</manifest> </manifest>
@@ -7,8 +7,6 @@ enum class ApiEvent(val value: Int) {
MessageEdit(5), MessageEdit(5),
MessageReadIncoming(6), MessageReadIncoming(6),
MessageReadOutgoing(7), MessageReadOutgoing(7),
FriendOnline(8),
FriendOffline(9),
MessagesDeleted(13), MessagesDeleted(13),
PinUnpinConversation(20), PinUnpinConversation(20),
PrivateTyping(61), PrivateTyping(61),
@@ -1,10 +1,10 @@
package com.meloda.fast.api package com.meloda.fast.api
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.VkUser
import com.meloda.fast.common.AppGlobal import com.meloda.fast.common.AppGlobal
import com.meloda.fast.model.AppAccount import com.meloda.fast.model.AppAccount
import kotlinx.coroutines.flow.MutableStateFlow
object UserConfig { object UserConfig {
@@ -42,6 +42,6 @@ object UserConfig {
return currentUserId > 0 && userId > 0 && accessToken.isNotBlank() return currentUserId > 0 && userId > 0 && accessToken.isNotBlank()
} }
val vkUser = MutableLiveData<VkUser?>(null) val vkUser: MutableStateFlow<VkUser?> = MutableStateFlow(null)
} }
@@ -8,11 +8,11 @@ object VKConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified" const val GROUP_FIELDS = "description,members_count,counters,status,verified"
const val USER_FIELDS = 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 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 LP_VERSION = 10
const val VK_APP_ID = "2274003" const val VK_APP_ID = "2274003"
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,8 @@ open class ApiError(
val error: String? = null, val error: String? = null,
@SerializedName("error_msg", alternate = ["error_description"]) @SerializedName("error_msg", alternate = ["error_description"])
open val errorMessage: String? = null, open val errorMessage: String? = null,
@SerializedName("error_type")
val errorType: String? = null,
val throwable: Throwable? = null val throwable: Throwable? = null
) : IOException() { ) : IOException() {
@@ -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\""
)
@@ -9,12 +9,26 @@ sealed class LongPollEvent {
data class VkMessageNewEvent( data class VkMessageNewEvent(
val message: VkMessage, val message: VkMessage,
val profiles: HashMap<Int, VkUser>, val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup> val groups: HashMap<Int, VkGroup>,
) : LongPollEvent() ) : LongPollEvent()
data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent() data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent()
data class VkMessageReadIncomingEvent(val peerId: Int, val messageId: Int) : LongPollEvent() data class VkMessageReadIncomingEvent(
data class VkMessageReadOutgoingEvent(val peerId: Int, val messageId: Int) : LongPollEvent() val peerId: Int,
val messageId: Int,
val unreadCount: Int,
) : LongPollEvent()
data class VkMessageReadOutgoingEvent(
val peerId: Int,
val messageId: Int,
val unreadCount: Int,
) : LongPollEvent()
data class VkConversationPinStateChangedEvent(
val peerId: Int,
val majorId: Int,
) : LongPollEvent()
} }
@@ -10,7 +10,12 @@ import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.messages.MessagesGetByIdRequest import com.meloda.fast.api.network.messages.MessagesGetByIdRequest
import com.meloda.fast.base.viewmodel.VkEventCallback import com.meloda.fast.base.viewmodel.VkEventCallback
import com.meloda.fast.data.messages.MessagesRepository 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.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -47,10 +52,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
ApiEvent.MessageEdit -> parseMessageEdit(eventType, event) ApiEvent.MessageEdit -> parseMessageEdit(eventType, event)
ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event) ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event)
ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event) ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event)
ApiEvent.FriendOnline -> parseFriendOnline(eventType, event)
ApiEvent.FriendOffline -> parseFriendOffline(eventType, event)
ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event) ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event)
ApiEvent.PinUnpinConversation -> onNewEvent(eventType, event) ApiEvent.PinUnpinConversation -> parseConversationPinStateChanged(eventType, event)
ApiEvent.PrivateTyping -> onNewEvent(eventType, event) ApiEvent.PrivateTyping -> onNewEvent(eventType, event)
ApiEvent.ChatTyping -> onNewEvent(eventType, event) ApiEvent.ChatTyping -> onNewEvent(eventType, event)
ApiEvent.OneMoreTyping -> onNewEvent(eventType, event) ApiEvent.OneMoreTyping -> onNewEvent(eventType, event)
@@ -67,6 +70,27 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event") 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<LongPollEvent.VkConversationPinStateChangedEvent>)
.onEvent(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = majorId
)
)
}
}
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) { private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
} }
@@ -119,6 +143,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt val peerId = event[1].asInt
val messageId = event[2].asInt val messageId = event[2].asInt
val unreadCount = event[3].asInt
launch { launch {
listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners -> listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners ->
@@ -127,7 +152,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent( .onEvent(
LongPollEvent.VkMessageReadIncomingEvent( LongPollEvent.VkMessageReadIncomingEvent(
peerId = peerId, peerId = peerId,
messageId = messageId messageId = messageId,
unreadCount = unreadCount
) )
) )
} }
@@ -139,6 +165,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt val peerId = event[1].asInt
val messageId = event[2].asInt val messageId = event[2].asInt
val unreadCount = event[3].asInt
launch { launch {
listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners -> listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners ->
@@ -147,7 +174,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent( .onEvent(
LongPollEvent.VkMessageReadOutgoingEvent( LongPollEvent.VkMessageReadOutgoingEvent(
peerId = peerId, 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) { private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
} }
private suspend fun <T : LongPollEvent> loadNormalMessage(eventType: ApiEvent, messageId: Int) = private suspend fun <T : LongPollEvent> loadNormalMessage(eventType: ApiEvent, messageId: Int) =
coroutineScope { coroutineScope {
suspendCoroutine<T> { suspendCoroutine {
launch { launch {
val normalMessageResponse = messagesRepository.getById( val normalMessageResponse = messagesRepository.getById(
MessagesGetByIdRequest( MessagesGetByIdRequest(
@@ -179,7 +199,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
) )
) )
if (!normalMessageResponse.isSuccessful()) { if (normalMessageResponse.isError()) {
normalMessageResponse.error.throwable?.run { throw this } normalMessageResponse.error.throwable?.run { throw this }
} }
@@ -195,12 +215,12 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
val profiles = hashMapOf<Int, VkUser>() val profiles = hashMapOf<Int, VkUser>()
messagesResponse.profiles?.forEach { baseUser -> messagesResponse.profiles?.forEach { baseUser ->
baseUser.asVkUser().let { user -> profiles[user.id] = user } baseUser.mapToDomain().let { user -> profiles[user.id] = user }
} }
val groups = hashMapOf<Int, VkGroup>() val groups = hashMapOf<Int, VkGroup>()
messagesResponse.groups?.forEach { baseGroup -> 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) { val resumeValue: LongPollEvent? = when (eventType) {
@@ -228,6 +248,14 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
} }
} }
fun onConversationPinStateChanged(listener: VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) {
registerListener(ApiEvent.PinUnpinConversation, listener)
}
fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) {
onConversationPinStateChanged(assembleEventCallback(block))
}
fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) { fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) {
registerListener(ApiEvent.MessageReadIncoming, listener) registerListener(ApiEvent.MessageReadIncoming, listener)
} }
@@ -265,8 +293,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
} }
} }
internal inline fun <R : Any> assembleEventCallback(crossinline block: (R) -> Unit): VkEventCallback<R> { internal inline fun <R : Any> assembleEventCallback(
return object : VkEventCallback<R> { crossinline block: (R) -> Unit,
override fun onEvent(event: R) = block.invoke(event) ): VkEventCallback<R> {
} return VkEventCallback { event -> block.invoke(event) }
} }
@@ -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
}
}
}
}
@@ -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
}
}
}
}
@@ -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<ChatMember> = 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 }
}
}
}
}
@@ -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
@@ -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<VkUser?>()
@Ignore
@IgnoredOnParcel
val group = MutableLiveData<VkGroup?>()
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
}
@@ -7,11 +7,13 @@ import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage 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.model.SelectableItem
import com.meloda.fast.util.TimeUtils import com.meloda.fast.util.TimeUtils
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
// TODO: 05.08.2023, Danil Nikolaev: create other class for storing in database
@Entity(tableName = "messages") @Entity(tableName = "messages")
@Parcelize @Parcelize
data class VkMessage constructor( data class VkMessage constructor(
@@ -38,7 +40,7 @@ data class VkMessage constructor(
var replyMessage: VkMessage? = null, var replyMessage: VkMessage? = null,
val geo: BaseVkMessage.Geo? = null, val geo: BaseVkMessage.Geo? = null,
) : SelectableItem(id) { ) : SelectableItem() {
@Ignore @Ignore
@IgnoredOnParcel @IgnoredOnParcel
@@ -48,6 +50,14 @@ data class VkMessage constructor(
@IgnoredOnParcel @IgnoredOnParcel
var group: VkGroup? = null var group: VkGroup? = null
@Ignore
@IgnoredOnParcel
var actionUser: VkUser? = null
@Ignore
@IgnoredOnParcel
var actionGroup: VkGroup? = null
@Ignore @Ignore
@IgnoredOnParcel @IgnoredOnParcel
var state: State = State.Sent var state: State = State.Sent
@@ -58,7 +68,7 @@ data class VkMessage constructor(
fun isGroup() = fromId < 0 fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) = fun isRead(conversation: VkConversationDomain) =
if (isOut) { if (isOut) {
conversation.outRead - id >= 0 conversation.outRead - id >= 0
} else { } else {
@@ -15,7 +15,8 @@ data class VkUser(
val online: Boolean, val online: Boolean,
val photo200: String?, val photo200: String?,
val lastSeen: Int?, val lastSeen: Int?,
val lastSeenStatus: String? val lastSeenStatus: String?,
val birthday: String?
) : Parcelable { ) : Parcelable {
override fun toString() = fullName override fun toString() = fullName
@@ -1,15 +1,11 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.model.DataItem
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
open class VkAttachment : DataItem<Int>(), Parcelable { open class VkAttachment : Parcelable {
@IgnoredOnParcel
override val dataItemId: Int = -1
open fun asString(withAccessKey: Boolean = true) = "" open fun asString(withAccessKey: Boolean = true) = ""
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkCurator( data class VkCurator(
val id: Int val id: Int,
) : VkAttachment() ) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -1,5 +1,6 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -14,4 +15,7 @@ data class VkStory(
fun isFromGroup() = ownerId < 0 fun isFromGroup() = ownerId < 0
@IgnoredOnParcel
val className: String = this::class.java.name
} }
@@ -13,7 +13,7 @@ data class VkVideo(
val images: List<VideoImage>, val images: List<VideoImage>,
val firstFrames: List<BaseVkVideo.FirstFrame>?, val firstFrames: List<BaseVkVideo.FirstFrame>?,
val accessKey: String?, val accessKey: String?,
val title: String val title: String,
) : VkAttachment() { ) : VkAttachment() {
@IgnoredOnParcel @IgnoredOnParcel
@@ -47,11 +47,11 @@ data class VkVideo(
val width: Int, val width: Int,
val height: Int, val height: Int,
val url: String, val url: String,
val withPadding: Boolean val withPadding: Boolean,
) : Parcelable { ) : Parcelable {
@IgnoredOnParcel @IgnoredOnParcel
var shapeKind: ShapeKind var shapeKind: ShapeKind? = null
init { init {
val ratio = width.toFloat() / height.toFloat() val ratio = width.toFloat() / height.toFloat()
@@ -64,10 +64,21 @@ data class VkVideo(
} }
} }
sealed class ShapeKind { open class ShapeKind(val value: Int) {
object Vertical : ShapeKind() object Square : ShapeKind(0)
object Horizontal : ShapeKind() object Vertical : ShapeKind(1)
object Square : ShapeKind() 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( override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class VkWidget( data class VkWidget(
val id: Int val id: Int
) : VkAttachment() ) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -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
}
@@ -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
)
}
@@ -20,7 +20,7 @@ data class BaseVkGroup(
val members_count: Int? val members_count: Int?
) : Parcelable { ) : Parcelable {
fun asVkGroup() = VkGroup( fun mapToDomain() = VkGroup(
id = -id, id = -id,
name = name, name = name,
screenName = screen_name, screenName = screen_name,
@@ -18,7 +18,8 @@ data class BaseVkUser(
val photo_200: String?, val photo_200: String?,
val online: Int?, val online: Int?,
val online_info: OnlineInfo?, val online_info: OnlineInfo?,
val screen_name: String val screen_name: String,
val bdate: String?
//...other fields //...other fields
) : Parcelable { ) : Parcelable {
@@ -32,14 +33,15 @@ data class BaseVkUser(
val app_id: Int? val app_id: Int?
) : Parcelable ) : Parcelable
fun asVkUser() = VkUser( fun mapToDomain() = VkUser(
id = id, id = id,
firstName = first_name, firstName = first_name,
lastName = last_name, lastName = last_name,
online = online == 1, online = online == 1,
photo200 = photo_200, photo200 = photo_200,
lastSeen = online_info?.last_seen, lastSeen = online_info?.last_seen,
lastSeenStatus = online_info?.status lastSeenStatus = online_info?.status,
birthday = bdate
) )
} }
@@ -1,9 +1,12 @@
package com.meloda.fast.api.model.base package com.meloda.fast.api.model.data
import android.os.Parcelable 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.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.base.attachments.BaseVkGroupCall
import com.meloda.fast.api.model.domain.VkConversationDomain
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -12,6 +15,8 @@ data class BaseVkConversation(
val last_message_id: Int, val last_message_id: Int,
val in_read: Int, val in_read: Int,
val out_read: Int, val out_read: Int,
val in_read_cmid: Int,
val out_read_cmid: Int,
val sort_id: SortId, val sort_id: SortId,
val last_conversation_message_id: Int, val last_conversation_message_id: Int,
val is_marked_unread: Boolean, val is_marked_unread: Boolean,
@@ -22,43 +27,20 @@ data class BaseVkConversation(
val can_receive_money: Boolean, val can_receive_money: Boolean,
val chat_settings: ChatSettings?, val chat_settings: ChatSettings?,
val call_in_progress: CallInProgress?, val call_in_progress: CallInProgress?,
val unread_count: Int? val unread_count: Int?,
) : Parcelable { ) : 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 @Parcelize
data class Peer( data class Peer(
val id: Int, val id: Int,
val type: String, val type: String,
val local_id: Int val local_id: Int,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
data class SortId( data class SortId(
val major_id: Int, val major_id: Int,
val minor_id: Int val minor_id: Int,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
@@ -66,12 +48,12 @@ data class BaseVkConversation(
val disabled_forever: Boolean, val disabled_forever: Boolean,
val no_sound: Boolean, val no_sound: Boolean,
val disabled_mentions: Boolean, val disabled_mentions: Boolean,
val disabled_mass_mentions: Boolean val disabled_mass_mentions: Boolean,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
data class CanWrite( data class CanWrite(
val allowed: Boolean val allowed: Boolean,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
@@ -89,7 +71,7 @@ data class BaseVkConversation(
val is_disappearing: Boolean, val is_disappearing: Boolean,
val is_service: Boolean, val is_service: Boolean,
val theme: String?, val theme: String?,
val pinned_message: BaseVkMessage? val pinned_message: BaseVkMessage?,
) : Parcelable { ) : Parcelable {
@Parcelize @Parcelize
@@ -104,7 +86,7 @@ data class BaseVkConversation(
val can_copy_chat: Boolean, val can_copy_chat: Boolean,
val can_call: Boolean, val can_call: Boolean,
val can_use_mass_mentions: Boolean, val can_use_mass_mentions: Boolean,
val can_change_style: Boolean val can_change_style: Boolean,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
@@ -112,21 +94,54 @@ data class BaseVkConversation(
val photo_50: String?, val photo_50: String?,
val photo_100: String?, val photo_100: String?,
val photo_200: String?, val photo_200: String?,
val is_default_photo: Boolean val is_default_photo: Boolean,
) : Parcelable ) : Parcelable
} }
@Parcelize @Parcelize
data class CallInProgress( data class CallInProgress(
val participants: BaseVkGroupCall.Participants, val participants: BaseVkGroupCall.Participants,
val join_link: String val join_link: String,
) : Parcelable { ) : Parcelable {
@Parcelize @Parcelize
data class Participants( data class Participants(
val list: List<Int>, val list: List<Int>,
val count: Int val count: Int,
) : Parcelable ) : Parcelable
} }
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
}
} }
@@ -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
)
}
@@ -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
}
@@ -43,9 +43,10 @@ object VkErrorCodes {
const val InvalidDocId = 1150 const val InvalidDocId = 1150
const val InvalidDocTitle = 1152 const val InvalidDocTitle = 1152
const val AccessToDocDenied = 1153 const val AccessToDocDenied = 1153
const val AccessTokenExpired = 1117
} }
@Suppress("unused")
object VkErrors { object VkErrors {
const val Unknown = "unknown_error" 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( data class ValidationRequiredError(
@SerializedName("validation_type") @SerializedName("validation_type")
@@ -76,3 +88,23 @@ data class CaptchaRequiredError(
@SerializedName("captcha_img") @SerializedName("captcha_img")
val captchaImg: String val captchaImg: String
) : ApiError() ) : 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
)
}
@@ -3,6 +3,7 @@ package com.meloda.fast.api.network
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.network.account.AccountUrls import com.meloda.fast.api.network.account.AccountUrls
import com.meloda.fast.api.network.ota.OtaUrls
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import java.net.URLEncoder import java.net.URLEncoder
@@ -14,7 +15,7 @@ class AuthInterceptor : Interceptor {
val url = builder.build().toUrl().toString() 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")) builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8"))
} }
@@ -14,11 +14,11 @@ import java.lang.reflect.Type
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract import kotlin.contracts.contract
class ResultCallFactory : CallAdapter.Factory() { class ResultCallFactory(private val gson: Gson) : CallAdapter.Factory() {
override fun get( override fun get(
returnType: Type, returnType: Type,
annotations: Array<out Annotation>, annotations: Array<out Annotation>,
retrofit: Retrofit retrofit: Retrofit,
): CallAdapter<*, *>? { ): CallAdapter<*, *>? {
val rawReturnType: Class<*> = getRawType(returnType) val rawReturnType: Class<*> = getRawType(returnType)
if (rawReturnType == Call::class.java) { if (rawReturnType == Call::class.java) {
@@ -27,9 +27,9 @@ class ResultCallFactory : CallAdapter.Factory() {
if (getRawType(callInnerType) == ApiAnswer::class.java) { if (getRawType(callInnerType) == ApiAnswer::class.java) {
if (callInnerType is ParameterizedType) { if (callInnerType is ParameterizedType) {
val resultInnerType = getParameterUpperBound(0, callInnerType) val resultInnerType = getParameterUpperBound(0, callInnerType)
return ResultCallAdapter<Any?>(resultInnerType) return ResultCallAdapter<Any?>(resultInnerType, gson)
} }
return ResultCallAdapter<Nothing>(Nothing::class.java) return ResultCallAdapter<Nothing>(Nothing::class.java, gson)
} }
} }
} }
@@ -58,30 +58,29 @@ internal abstract class CallDelegate<In, Out>(protected val proxy: Call<In>) : C
abstract fun cloneImpl(): Call<Out> abstract fun cloneImpl(): Call<Out>
} }
private class ResultCallAdapter<R>(private val type: Type) : CallAdapter<R, Call<ApiAnswer<R>>> { private class ResultCallAdapter<R>(private val type: Type, private val gson: Gson) : CallAdapter<R, Call<ApiAnswer<R>>> {
override fun responseType() = type override fun responseType() = type
override fun adapt(call: Call<R>): Call<ApiAnswer<R>> = ResultCall(call) override fun adapt(call: Call<R>): Call<ApiAnswer<R>> = ResultCall(call, gson)
} }
internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, ApiAnswer<T>>(proxy) { internal class ResultCall<T>(proxy: Call<T>, private val gson: Gson) : CallDelegate<T, ApiAnswer<T>>(proxy) {
override fun enqueueImpl(callback: Callback<ApiAnswer<T>>) { override fun enqueueImpl(callback: Callback<ApiAnswer<T>>) {
proxy.enqueue(ResultCallback(this, callback)) proxy.enqueue(ResultCallback(this, callback, gson))
} }
override fun cloneImpl(): ResultCall<T> { override fun cloneImpl(): ResultCall<T> {
return ResultCall(proxy.clone()) return ResultCall(proxy.clone(), gson)
} }
private class ResultCallback<T>( private class ResultCallback<T>(
private val proxy: ResultCall<T>, private val proxy: ResultCall<T>,
private val callback: Callback<ApiAnswer<T>> private val callback: Callback<ApiAnswer<T>>,
private val gson: Gson
) : Callback<T> { ) : Callback<T> {
val gson = Gson()
override fun onResponse(call: Call<T>, response: Response<T>) { override fun onResponse(call: Call<T>, response: Response<T>) {
val result: ApiAnswer<T> = val result: ApiAnswer<T> =
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -117,13 +116,11 @@ internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, ApiAnswer<T>>(pro
} }
private fun checkErrors(call: Call<T>, result: ApiAnswer<*>): Boolean { private fun checkErrors(call: Call<T>, result: ApiAnswer<*>): Boolean {
if (!result.isSuccessful()) { if (result.isError()) {
result.error.throwable?.run { result.error.throwable?.run {
onFailure(call, this) onFailure(call, this)
return true return true
} }
} else {
return false
} }
return false return false
@@ -143,8 +140,16 @@ sealed class ApiAnswer<out R> {
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
fun isSuccessful(): Boolean { fun isSuccessful(): Boolean {
contract { contract {
returns(false) implies (this@ApiAnswer is Error) returns(true) implies (this@ApiAnswer is Success)
} }
return this is Success return this is Success
} }
@OptIn(ExperimentalContracts::class)
fun isError(): Boolean {
contract {
returns(true) implies (this@ApiAnswer is Error)
}
return this is Error
}
} }
@@ -4,37 +4,6 @@ object VkUrls {
const val OAUTH = "https://oauth.vk.com" const val OAUTH = "https://oauth.vk.com"
const val API = "https://api.vk.com/method" 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"
}
} }
@@ -6,10 +6,15 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class AuthDirectResponse( data class AuthDirectResponse(
@SerializedName("access_token") val accessToken: String? = null, @SerializedName("access_token") val accessToken: String?,
@SerializedName("user_id") val userId: Int? = null, @SerializedName("user_id") val userId: Int?,
@SerializedName("trusted_hash") val twoFaHash: String? = null, @SerializedName("trusted_hash") val twoFaHash: String?,
@SerializedName("validation_sid") val validationSid: String? = null @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 ) : Parcelable
@Parcelize @Parcelize
@@ -2,7 +2,7 @@ package com.meloda.fast.api.network.conversations
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName 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.BaseVkGroup
import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.BaseVkUser import com.meloda.fast.api.model.base.BaseVkUser
@@ -189,5 +189,51 @@ data class MessagesGetByIdRequest(
extended?.let { this["extended"] = it.intString } extended?.let { this["extended"] = it.intString }
fields?.let { this["fields"] = it } 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()
)
}
@@ -1,10 +1,8 @@
package com.meloda.fast.api.network.messages package com.meloda.fast.api.network.messages
import android.os.Parcelable import android.os.Parcelable
import com.meloda.fast.api.model.base.BaseVkConversation import com.meloda.fast.api.model.base.*
import com.meloda.fast.api.model.base.BaseVkGroup import com.meloda.fast.api.model.data.BaseVkConversation
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.BaseVkUser
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -23,3 +21,11 @@ data class MessagesGetByIdResponse(
val profiles: List<BaseVkUser>?, val profiles: List<BaseVkUser>?,
val groups: List<BaseVkGroup>? val groups: List<BaseVkGroup>?
) : Parcelable ) : Parcelable
@Parcelize
data class MessagesGetConversationMembersResponse(
val count: Int,
val items: List<BaseVkChatMember> = emptyList(),
val profiles: List<BaseVkUser>?,
val groups: List<BaseVkGroup>?
) : Parcelable
@@ -15,5 +15,8 @@ object MessagesUrls {
const val Edit = "${VkUrls.API}/messages.edit" const val Edit = "${VkUrls.API}/messages.edit"
const val GetById = "${VkUrls.API}/messages.getById" const val GetById = "${VkUrls.API}/messages.getById"
const val MarkAsRead = "${VkUrls.API}/messages.markAsRead" 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"
} }
@@ -8,5 +8,4 @@ abstract class BaseActivity : AppCompatActivity {
constructor() : super() constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId) constructor(@LayoutRes resId: Int) : super(resId)
} }
@@ -1,47 +1,11 @@
package com.meloda.fast.base package com.meloda.fast.base
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.meloda.fast.screens.main.MainActivity
abstract class BaseFragment : Fragment { abstract class BaseFragment : Fragment {
constructor() : super() constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId) 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) {}
} }
@@ -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<AdapterDiffItem>? = null,
vararg delegates: AdapterDelegate<out List<AdapterDiffItem>>,
) : AsyncListDifferDelegationAdapter<AdapterDiffItem>(customDiffCallback ?: DIFF_CALLBACK) {
constructor(
vararg delegates: AdapterDelegate<out List<AdapterDiffItem>>,
) : this(customDiffCallback = null) {
delegates.forEach(::addDelegate)
}
init {
delegates.forEach(::addDelegate)
}
fun addDelegates(vararg delegates: AdapterDelegate<out List<AdapterDiffItem>>) {
delegates.forEach(::addDelegate)
}
@Suppress("UNCHECKED_CAST")
fun addDelegate(delegate: AdapterDelegate<out List<AdapterDiffItem>>) {
(delegate as? AdapterDelegate<List<AdapterDiffItem>>)?.let(delegatesManager::addDelegate)
}
fun isEmpty() = itemCount == 0
fun isNotEmpty() = itemCount > 0
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<AdapterDiffItem>() {
override fun areItemsTheSame(
oldItem: AdapterDiffItem,
newItem: AdapterDiffItem,
): Boolean {
return oldItem.areItemsTheSame(newItem)
}
override fun areContentsTheSame(
oldItem: AdapterDiffItem,
newItem: AdapterDiffItem,
): Boolean {
return oldItem.areContentsTheSame(newItem)
}
}
}
}
@@ -9,12 +9,11 @@ import android.widget.Filter
import android.widget.Filterable import android.widget.Filterable
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import com.meloda.fast.model.DataItem
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlin.properties.Delegates import kotlin.properties.Delegates
@Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST") @Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST")
abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor( abstract class BaseAdapter<T : Any, VH : BaseHolder> constructor(
var context: Context, var context: Context,
diffUtil: DiffUtil.ItemCallback<T>, diffUtil: DiffUtil.ItemCallback<T>,
preAddedValues: List<T> = emptyList(), preAddedValues: List<T> = emptyList(),
@@ -59,27 +58,18 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun add( fun add(
item: T, item: T,
position: Int? = null, position: Int? = null,
beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null commitCallback: (() -> Unit)? = null
) = addAll(listOf(item), position, beforeFooter, commitCallback) ) = addAll(listOf(item), position, commitCallback)
fun addAll( fun addAll(
items: List<T>, items: List<T>,
position: Int? = null, position: Int? = null,
beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null commitCallback: (() -> Unit)? = null
) { ) {
adapterScope.launch { adapterScope.launch {
val newList = cloneCurrentList() val newList = cloneCurrentList()
if (position == null) { if (position == null) {
val mutableItems = items.toMutableList() val mutableItems = items.toMutableList()
if (beforeFooter && newList.lastOrNull() is DataItem.Footer) {
newList.removeLastOrNull()
}
if (beforeFooter) {
mutableItems += DataItem.Footer as T
}
newList.addAll(mutableItems) newList.addAll(mutableItems)
cleanList.addAll(mutableItems) cleanList.addAll(mutableItems)
@@ -100,40 +90,34 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun removeAll(items: List<T>, commitCallback: (() -> Unit)? = null) { fun removeAll(items: List<T>, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList() val newList = cloneCurrentList()
newList.removeAll(items) newList.removeAll(items)
submitList(newList, commitCallback)
cleanList.removeAll(items) cleanList.removeAll(items)
submitList(newList, commitCallback)
} }
fun removeAt(index: Int, commitCallback: (() -> Unit)? = null) { fun removeAt(index: Int, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList() val newList = cloneCurrentList()
newList.removeAt(index) newList.removeAt(index)
submitList(newList, commitCallback)
cleanList.removeAt(index) cleanList.removeAt(index)
submitList(newList, commitCallback)
} }
fun clear(commitCallback: (() -> Unit)? = null) = removeAll(currentList, commitCallback) fun clear(commitCallback: (() -> Unit)? = null) = removeAll(currentList, commitCallback)
fun setItem( fun setItem(
item: T, item: T,
withHeader: Boolean = false,
withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null commitCallback: (() -> Unit)? = null
) = setItems(listOf(item), withHeader, withFooter, commitCallback) ) = setItems(listOf(item), commitCallback)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun setItems( fun setItems(
list: List<T>?, list: List<T>?,
withHeader: Boolean = false,
withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null commitCallback: (() -> Unit)? = null
) { ) {
adapterScope.launch { adapterScope.launch {
val items = mutableListOf<T>() val items = mutableListOf<T>()
if (withHeader) items.add(DataItem.Header as T)
if (!list.isNullOrEmpty()) items.addAll(list) if (!list.isNullOrEmpty()) items.addAll(list)
if (withFooter) items.add(DataItem.Footer as T)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (items == currentList) { if (items == currentList) {
@@ -165,9 +149,9 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun setItem(position: Int, item: T, commitCallback: (() -> Unit)? = null) { fun setItem(position: Int, item: T, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList() val newList = cloneCurrentList()
newList[position] = item newList[position] = item
submitList(newList, commitCallback)
cleanList[position] = item cleanList[position] = item
submitList(newList, commitCallback)
} }
fun isEmpty() = currentList.isEmpty() fun isEmpty() = currentList.isEmpty()
@@ -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<EmptyHeaderAdapter.Holder>() {
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
}
}
@@ -1,9 +1,9 @@
package com.meloda.fast.base.adapter package com.meloda.fast.base.adapter
interface OnItemClickListener { fun interface OnItemClickListener<T> {
fun onItemClick(position: Int) fun onItemClick(item: T)
} }
interface OnItemLongClickListener { fun interface OnItemLongClickListener<T> {
fun onItemLongClick(position: Int) fun onLongItemClick(item: T): Boolean
} }
@@ -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<ArgType, ResultType> {
val resultFlow: MutableSharedFlow<ResultType>
var args: ArgType
fun show(router: Router, args: ArgType)
fun getArguments(): ArgType = args
}
@Suppress("unused")
fun <ArgType, ResultType> AppScreen<ArgType, ResultType>.createResultFlow(): MutableSharedFlow<ResultType> {
return MutableSharedFlow(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
@@ -1,110 +1,97 @@
package com.meloda.fast.base.viewmodel package com.meloda.fast.base.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.base.ApiError
import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.*
import com.meloda.fast.api.network.AuthorizationError import com.meloda.fast.ext.isTrue
import com.meloda.fast.api.network.CaptchaRequiredError import com.meloda.fast.ext.notNull
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
@Suppress("MemberVisibilityCanBePrivate")
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
var unknownErrorDefaultText: String = "" open suspend fun sendSingleEvent(event: VkEvent) {}
protected val tasksEventChannel = Channel<VkEvent>() suspend fun <T> sendRequestNotNull(
val tasksEvent = tasksEventChannel.receiveAsFlow() onError: ErrorHandler? = null,
request: suspend () -> ApiAnswer<T>
): T = sendRequest(onError, request).notNull()
protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> suspend fun <T> sendRequest(
viewModelScope.launch { onException(throwable) } onError: ErrorHandler? = null,
} request: suspend () -> ApiAnswer<T>,
): T? {
fun launch(block: suspend CoroutineScope.() -> Unit): Job { return when (val response = request()) {
return viewModelScope.launch(exceptionHandler, block = block) is ApiAnswer.Success -> response.data
}
protected suspend fun <T> makeSuspendJob(
job: suspend () -> ApiAnswer<T>, onAnswer: suspend (T) -> Unit = {},
onStart: (suspend () -> Unit)? = null,
onEnd: (suspend () -> Unit)? = null,
onError: (suspend (Throwable) -> Unit)? = null
): ApiAnswer<T> {
onStart?.invoke() ?: onStart()
val response = job()
when (response) {
is ApiAnswer.Success -> onAnswer(response.data)
is ApiAnswer.Error -> { 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 null
}
protected fun <T> makeJob(
job: suspend () -> ApiAnswer<T>,
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()
} }
} }
} }
protected open suspend fun onException(throwable: Throwable) {
checkErrors(throwable)
}
protected suspend fun onStart() {
sendEvent(StartProgressEvent)
}
protected suspend fun onStop() {
sendEvent(StopProgressEvent)
}
protected suspend fun <T : VkEvent> sendEvent(event: T) = tasksEventChannel.send(event)
protected suspend fun checkErrors(throwable: Throwable) { protected suspend fun checkErrors(throwable: Throwable) {
when (throwable) { when (throwable) {
is TokenExpiredError -> {
sendSingleEvent(TokenExpiredErrorEvent)
}
is AuthorizationError -> { 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 -> { 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 -> { is CaptchaRequiredError -> {
sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg)) sendSingleEvent(
CaptchaRequiredEvent(
sid = throwable.captchaSid,
image = throwable.captchaImg
)
)
} }
is ApiError -> { is ApiError -> {
sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText)) sendSingleEvent(
if (throwable.errorMessage == null) {
UnknownErrorEvent
} else {
ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage))
}
)
} }
else -> { else -> {
sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText)) sendSingleEvent(
if (throwable.message == null) {
UnknownErrorEvent
} else {
ErrorTextEvent(requireNotNull(throwable.message))
}
)
} }
} }
} }
} }
@@ -5,10 +5,10 @@ import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.meloda.fast.base.BaseFragment import com.meloda.fast.base.BaseFragment
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment { @Deprecated("", ReplaceWith("BaseFragment"))
abstract class BaseViewModelFragment<VM : DeprecatedBaseViewModel> : BaseFragment {
constructor() : super() constructor() : super()
@@ -25,7 +25,7 @@ abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment {
ViewModelUtils.parseEvent(this, event) ViewModelUtils.parseEvent(this, event)
} }
protected fun <T : BaseViewModel> subscribeToViewModel(viewModel: T) { protected fun <T : DeprecatedBaseViewModel> subscribeToViewModel(viewModel: T) {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.tasksEvent.collect { onEvent(it) } viewModel.tasksEvent.collect { onEvent(it) }
} }
@@ -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<VkEvent>()
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 <T> sendRequestNotNull(
onError: ErrorHandler? = null,
request: suspend () -> ApiAnswer<T>
): T = sendRequest(onError, request).notNull()
suspend fun <T> sendRequest(
onError: ErrorHandler? = null,
request: suspend () -> ApiAnswer<T>,
): 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 <T> makeJob(
job: suspend () -> ApiAnswer<T>,
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 <T : VkEvent> 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))
}
)
}
}
}
@@ -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
}
@@ -1,18 +1,30 @@
package com.meloda.fast.base.viewmodel package com.meloda.fast.base.viewmodel
abstract class VkEvent import com.meloda.fast.model.base.UiText
abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
abstract class VkProgressEvent : VkEvent()
abstract class VkEvent
abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
object UnknownErrorEvent : VkErrorEvent()
open class ErrorTextEvent(override val errorText: String) : VkErrorEvent() open class ErrorTextEvent(override val errorText: String) : VkErrorEvent()
object AuthorizationErrorEvent : VkErrorEvent() object AuthorizationErrorEvent : VkErrorEvent()
object TokenExpiredErrorEvent : VkErrorEvent()
data class CaptchaRequiredEvent(val sid: String, val image: String) : 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() data class UserBannedEvent(
object StopProgressEvent : VkProgressEvent() val memberName: String, val message: String, val restoreUrl: String, val accessToken: String,
) : VkErrorEvent()
interface VkEventCallback<in T : Any> { fun interface VkEventCallback<in T : Any> {
fun onEvent(event: T) fun onEvent(event: T)
} }
@@ -6,12 +6,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.meloda.fast.R import com.meloda.fast.R
import com.meloda.fast.api.UserConfig import com.meloda.fast.api.UserConfig
import com.meloda.fast.base.BaseFragment import com.meloda.fast.ext.showDialog
import com.meloda.fast.screens.main.MainActivity import com.meloda.fast.model.base.UiText
import com.meloda.fast.util.ViewUtils.showErrorDialog import com.meloda.fast.screens.main.activity.MainActivity
object ViewModelUtils { object ViewModelUtils {
@Deprecated("rewrite")
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
fun parseEvent(activity: FragmentActivity, event: VkEvent) { fun parseEvent(activity: FragmentActivity, event: VkEvent) {
when (event) { when (event) {
@@ -24,26 +25,47 @@ object ViewModelUtils {
activity.finishAffinity() activity.finishAffinity()
activity.startActivity(Intent(activity, MainActivity::class.java)) 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 -> { is VkErrorEvent -> {
event.errorText?.run { 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) { fun parseEvent(fragment: Fragment, event: VkEvent) {
if (event is VkProgressEvent) { parseEvent(fragment.requireActivity(), event)
if (fragment is BaseFragment) {
if (event is StartProgressEvent) {
fragment.startProgress()
} else if (event is StopProgressEvent) {
fragment.stopProgress()
}
}
} else {
parseEvent(fragment.requireActivity(), event)
}
} }
} }
@@ -1,94 +1,83 @@
package com.meloda.fast.common package com.meloda.fast.common
import android.app.Application import android.app.Application
import android.app.DownloadManager
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Resources import android.content.res.Resources
import android.net.ConnectivityManager import android.media.AudioManager
import android.util.Log import androidx.appcompat.app.AppCompatDelegate
import android.view.inputmethod.InputMethodManager
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.room.Room import com.google.android.material.color.DynamicColors
import com.meloda.fast.database.AppDatabase import com.meloda.fast.common.di.applicationModule
import dagger.hilt.android.HiltAndroidApp 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.roundToInt
import kotlin.math.sqrt import kotlin.properties.Delegates
@HiltAndroidApp
class AppGlobal : Application() { 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() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache") if (preferences.getBoolean(
// .fallbackToDestructiveMigration() SettingsFragment.KEY_USE_DYNAMIC_COLORS,
.build() SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS
)
preferences = PreferenceManager.getDefaultSharedPreferences(this) ) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES) val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES)
versionName = info.versionName versionName = info.versionName
versionCode = PackageInfoCompat.getLongVersionCode(info).toInt() versionCode = PackageInfoCompat.getLongVersionCode(info).toInt()
Companion.resources = resources screenWidth80 = (AndroidUtils.getDisplayWidth() * 0.8).roundToInt()
Companion.packageName = packageName
Companion.packageManager = packageManager
screenWidth = resources.displayMetrics.widthPixels audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
screenHeight = resources.displayMetrics.heightPixels
screenWidth80 = (screenWidth * 0.8).roundToInt() applyDarkTheme()
val density = resources.displayMetrics.density initKoin()
val densityDpi = resources.displayMetrics.densityDpi }
val densityScaled = resources.displayMetrics.scaledDensity
val xDpi = resources.displayMetrics.xdpi
val yDpi = resources.displayMetrics.ydpi
val diagonal = sqrt( private fun applyDarkTheme() {
(screenWidth * screenWidth - screenHeight * screenHeight).toFloat() val nightMode = preferences.getInt(
SettingsFragment.KEY_APPEARANCE_DARK_THEME,
SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME
) )
AppCompatDelegate.setDefaultNightMode(nightMode)
}
Log.i( private fun initKoin() {
"Fast::DeviceInfo", startKoin {
"width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" androidLogger()
) androidContext(this@AppGlobal)
modules(applicationModule)
}
}
inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager companion object {
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private lateinit var instance: AppGlobal
clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 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()
} }
} }
@@ -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<Preferences> by preferencesDataStore(
name = "settings",
corruptionHandler = null,
scope = CoroutineScope(Dispatchers.IO + Job())
)
@@ -1,39 +1,37 @@
package com.meloda.fast.common package com.meloda.fast.common
import com.github.terrakok.cicerone.androidx.FragmentScreen 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.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser 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.model.UpdateItem
import com.meloda.fast.screens.chatinfo.ChatInfoFragment
import com.meloda.fast.screens.conversations.ConversationsFragment import com.meloda.fast.screens.conversations.ConversationsFragment
import com.meloda.fast.screens.login.LoginFragment import com.meloda.fast.screens.login.LoginFragment
import com.meloda.fast.screens.main.MainFragment import com.meloda.fast.screens.main.MainFragment
import com.meloda.fast.screens.messages.ForwardedMessagesFragment import com.meloda.fast.screens.messages.ForwardedMessagesFragment
import com.meloda.fast.screens.messages.MessagesHistoryFragment 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.updates.UpdatesFragment
import com.meloda.fast.screens.userbanned.UserBannedFragment
@Suppress("FunctionName") @Suppress("FunctionName")
object Screens { object Screens {
fun Main() = FragmentScreen { MainFragment() } fun Main() = FragmentScreen { MainFragment.newInstance() }
fun Login( fun Login() = FragmentScreen { LoginFragment.newInstance() }
getFastToken: Boolean = false
) = FragmentScreen {
LoginFragment.newInstance(getFastToken)
}
fun Conversations() = FragmentScreen { ConversationsFragment() } fun Conversations() = FragmentScreen { ConversationsFragment() }
fun MessagesHistory( fun MessagesHistory(
conversation: VkConversation, conversation: VkConversationDomain,
user: VkUser?, user: VkUser?,
group: VkGroup? group: VkGroup?
) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) } ) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) }
fun ForwardedMessages( fun ForwardedMessages(
conversation: VkConversation, conversation: VkConversationDomain,
messages: List<VkMessage>, messages: List<VkMessage>,
profiles: HashMap<Int, VkUser> = hashMapOf(), profiles: HashMap<Int, VkUser> = hashMapOf(),
groups: HashMap<Int, VkGroup> = hashMapOf() groups: HashMap<Int, VkGroup> = 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) = fun Updates(updateItem: UpdateItem? = null) =
FragmentScreen { UpdatesFragment.newInstance(updateItem) } FragmentScreen { UpdatesFragment.newInstance(updateItem) }
fun Settings() = FragmentScreen { SettingsRootFragment() } fun Settings() = FragmentScreen { SettingsFragment.newInstance() }
fun UserBanned(
memberName: String,
message: String,
restoreUrl: String,
accessToken: String
) = FragmentScreen {
UserBannedFragment.newInstance(
memberName, message, restoreUrl, accessToken
)
}
} }
@@ -1,37 +1,39 @@
package com.meloda.fast.common package com.meloda.fast.common
import androidx.lifecycle.MutableLiveData
import com.meloda.fast.BuildConfig import com.meloda.fast.BuildConfig
import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse
import com.meloda.fast.data.ota.OtaApi import com.meloda.fast.data.ota.OtaApi
import com.meloda.fast.extensions.setIfNotEquals
import com.meloda.fast.model.UpdateActualUrl import com.meloda.fast.model.UpdateActualUrl
import com.meloda.fast.model.UpdateItem import com.meloda.fast.model.UpdateItem
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder import java.net.URLEncoder
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class UpdateManager(private val repo: OtaApi) : CoroutineScope { interface UpdateManager {
val stateFlow: Flow<UpdateManagerState>
override val coroutineContext: CoroutineContext fun checkUpdates(): Job
get() = Dispatchers.Default }
companion object { class UpdateManagerImpl(private val repo: OtaApi) : UpdateManager {
val newUpdate = MutableLiveData<UpdateItem?>(null)
val updateError = MutableLiveData<Throwable?>(null)
var otaBaseUrl: String? = null private val coroutineContext: CoroutineContext
private set 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<UpdateActualUrl> = { repo.getActualUrl() } val job: suspend () -> ApiAnswer<UpdateActualUrl> = { repo.getActualUrl() }
when (val jobResponse = job()) { when (val jobResponse = job()) {
@@ -44,47 +46,55 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope {
is ApiAnswer.Error -> { is ApiAnswer.Error -> {
otaBaseUrl = null otaBaseUrl = null
val throwable = jobResponse.error.throwable val throwable = jobResponse.error.throwable
listener?.invoke(null, throwable)
withContext(Dispatchers.Main) { val newForm = stateFlow.value.copy(
updateError.setIfNotEquals(throwable) updateItem = null,
} throwable = throwable
)
stateFlow.emit(newForm)
} }
} }
} }
private fun getLatestRelease() = launch { private fun getLatestRelease() = coroutineScope.launch {
val url = "$otaBaseUrl/releases-latest" val url = "$otaBaseUrl/releases-latest"
val job: suspend () -> ApiAnswer<ApiResponse<OtaGetLatestReleaseResponse>> = { val job: suspend () -> ApiAnswer<ApiResponse<OtaGetLatestReleaseResponse>> = {
repo.getLatestRelease(url = url, secretCode = getOtaSecret()) repo.getLatestRelease(url = url, secretCode = getOtaSecret())
} }
withContext(Dispatchers.Main) { when (val jobResponse = job()) {
when (val jobResponse = job()) { is ApiAnswer.Success -> {
is ApiAnswer.Success -> { val response = jobResponse.data.response ?: return@launch
val response = jobResponse.data.response ?: return@withContext val latestRelease = response.release
val latestRelease = response.release
if (latestRelease != null && val updateItem = if (latestRelease != null &&
(AppGlobal.versionName (AppGlobal.versionName
.split("_") .split("_")
.getOrNull(1) != latestRelease.versionName || .getOrNull(1) != latestRelease.versionName ||
AppGlobal.versionCode < latestRelease.versionCode) AppGlobal.versionCode < latestRelease.versionCode)
) { ) {
newUpdate.setIfNotEquals(latestRelease) latestRelease
listener?.invoke(latestRelease, null) } else {
} else { null
newUpdate.setIfNotEquals(null)
listener?.invoke(null, null)
}
} }
is ApiAnswer.Error -> { val newForm = stateFlow.value.copy(
val throwable = jobResponse.error.throwable updateItem = updateItem,
updateError.setIfNotEquals(throwable) throwable = null
listener?.invoke(null, throwable) )
}
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 { private fun getOtaSecret(): String {
return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8") return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8")
} }
}
fun checkUpdates(block: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null) = launch { data class UpdateManagerState(
this@UpdateManager.listener = block val updateItem: UpdateItem?,
getActualUrl() val throwable: Throwable?,
) {
companion object {
val EMPTY = UpdateManagerState(
updateItem = null, throwable = null
)
} }
} }
@@ -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,
)
}
@@ -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
)
}
@@ -15,4 +15,7 @@ interface AccountsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(values: List<AppAccount>) suspend fun insert(values: List<AppAccount>)
@Query("DELETE FROM accounts WHERE userId = :userId")
suspend fun deleteById(userId: Int)
} }
@@ -4,15 +4,15 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.domain.VkConversationDomain
@Dao @Dao
interface ConversationsDao { interface ConversationsDao {
@Query("SELECT * FROM conversations") @Query("SELECT * FROM conversations")
suspend fun getAll(): List<VkConversation> suspend fun getAll(): List<VkConversationDomain>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(values: List<VkConversation>) suspend fun insert(values: List<VkConversationDomain>)
} }
@@ -1,11 +1,10 @@
package com.meloda.fast.data.conversations 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.ConversationsDeleteRequest
import com.meloda.fast.api.network.conversations.ConversationsGetRequest import com.meloda.fast.api.network.conversations.ConversationsGetRequest
import com.meloda.fast.api.network.conversations.ConversationsPinRequest import com.meloda.fast.api.network.conversations.ConversationsPinRequest
import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest
import kotlinx.coroutines.sync.Mutex
class ConversationsRepository( class ConversationsRepository(
private val conversationsApi: ConversationsApi, private val conversationsApi: ConversationsApi,
@@ -20,6 +19,6 @@ class ConversationsRepository(
suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map) suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map)
suspend fun store(conversations: List<VkConversation>) = conversationsDao.insert(conversations) suspend fun store(conversations: List<VkConversationDomain>) = conversationsDao.insert(conversations)
} }
@@ -1,10 +1,12 @@
package com.meloda.fast.data.messages package com.meloda.fast.data.messages
import com.meloda.fast.api.base.ApiResponse 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.BaseVkLongPoll
import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.messages.MessagesGetByIdResponse 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.MessagesGetHistoryResponse
import com.meloda.fast.api.network.messages.MessagesUrls import com.meloda.fast.api.network.messages.MessagesUrls
import retrofit2.http.FieldMap import retrofit2.http.FieldMap
@@ -53,4 +55,16 @@ interface MessagesApi {
@POST(MessagesUrls.MarkAsRead) @POST(MessagesUrls.MarkAsRead)
suspend fun markAsRead(@FieldMap params: Map<String, String>): ApiAnswer<ApiResponse<Int>> suspend fun markAsRead(@FieldMap params: Map<String, String>): ApiAnswer<ApiResponse<Int>>
@FormUrlEncoded
@POST(MessagesUrls.GetChat)
suspend fun getChat(@FieldMap params: Map<String, String>): ApiAnswer<ApiResponse<BaseVkChat>>
@FormUrlEncoded
@POST(MessagesUrls.GetConversationMembers)
suspend fun getConversationMembers(@FieldMap params: Map<String, String>): ApiAnswer<ApiResponse<MessagesGetConversationMembersResponse>>
@FormUrlEncoded
@POST(MessagesUrls.RemoveChatUser)
suspend fun removeChatUser(@FieldMap params: Map<String, String>): ApiAnswer<ApiResponse<Int>>
} }
@@ -2,15 +2,28 @@ package com.meloda.fast.data.messages
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest 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.data.longpoll.LongPollApi
import com.meloda.fast.api.network.messages.*
class MessagesRepository( class MessagesRepository(
private val messagesApi: MessagesApi, private val messagesApi: MessagesApi,
private val messagesDao: MessagesDao, 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<VkMessage>) = messagesDao.insert(messages) suspend fun store(messages: List<VkMessage>) = messagesDao.insert(messages)
suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId)
@@ -41,7 +54,7 @@ class MessagesRepository(
suspend fun getLongPollUpdates( suspend fun getLongPollUpdates(
serverUrl: String, serverUrl: String,
params: LongPollGetUpdatesRequest params: LongPollGetUpdatesRequest,
) = longPollApi.getResponse(serverUrl, params.map) ) = longPollApi.getResponse(serverUrl, params.map)
suspend fun getById(params: MessagesGetByIdRequest) = suspend fun getById(params: MessagesGetByIdRequest) =
@@ -50,7 +63,7 @@ class MessagesRepository(
suspend fun markAsRead( suspend fun markAsRead(
peerId: Int, peerId: Int,
messagesIds: List<Int>? = null, messagesIds: List<Int>? = null,
startMessageId: Int? = null startMessageId: Int? = null,
) = messagesApi.markAsRead( ) = messagesApi.markAsRead(
mutableMapOf("peer_id" to peerId.toString()).apply { mutableMapOf("peer_id" to peerId.toString()).apply {
messagesIds?.let { messagesIds?.let {
@@ -62,4 +75,30 @@ class MessagesRepository(
} }
) )
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)
} }
@@ -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
}
@@ -1,38 +1,30 @@
package com.meloda.fast.database package com.meloda.fast.database
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser 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.conversations.ConversationsDao
import com.meloda.fast.data.groups.GroupsDao import com.meloda.fast.data.groups.GroupsDao
import com.meloda.fast.data.messages.MessagesDao import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.users.UsersDao import com.meloda.fast.data.users.UsersDao
import com.meloda.fast.model.AppAccount
@Database( @Database(
entities = [ entities = [
AppAccount::class, VkConversationDomain::class,
VkConversation::class,
VkMessage::class, VkMessage::class,
VkUser::class, VkUser::class,
VkGroup::class VkGroup::class
], ],
version = 34, version = 42,
exportSchema = true, exportSchema = false
autoMigrations = [
AutoMigration(from = 33, to = 34)
]
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract val accountsDao: AccountsDao
abstract val conversationsDao: ConversationsDao abstract val conversationsDao: ConversationsDao
abstract val messagesDao: MessagesDao abstract val messagesDao: MessagesDao
abstract val usersDao: UsersDao abstract val usersDao: UsersDao
@@ -2,6 +2,7 @@ package com.meloda.fast.database
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.google.gson.Gson 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.VkMessage
import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.BaseVkMessage
@@ -18,25 +19,37 @@ class Converters {
fun fromGeoToString(geo: BaseVkMessage.Geo?): String? { fun fromGeoToString(geo: BaseVkMessage.Geo?): String? {
if (geo == null) return null 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 @TypeConverter
fun fromStringToGeo(string: String?): BaseVkMessage.Geo? { fun fromStringToGeo(string: String?): BaseVkMessage.Geo? {
if (string == null) return null 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 @TypeConverter
fun fromListVkMessageToString(messages: List<VkMessage>?): String? { fun fromListVkMessageToString(messages: List<VkMessage>?): String? {
if (messages == null) return null 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 return string
} }
@@ -46,40 +59,52 @@ class Converters {
if (string == null) return null if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) { if (string.contains(CACHE_SEPARATOR)) {
val messages = val messages = string
string.split(CACHE_SEPARATOR).map { fromStringToVkMessage(it)!! } .split(CACHE_SEPARATOR)
.mapNotNull(::fromStringToVkMessage)
return messages return messages
} }
val message = fromStringToVkMessage(string)!! val message = fromStringToVkMessage(string)
return message?.let { listOf(it) }
return listOf(message)
} }
@TypeConverter @TypeConverter
fun fromVkMessageToString(message: VkMessage?): String? { fun fromVkMessageToString(message: VkMessage?): String? {
if (message == null) return null 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 @TypeConverter
fun fromStringToVkMessage(string: String?): VkMessage? { fun fromStringToVkMessage(string: String?): VkMessage? {
if (string == null) return null 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 @TypeConverter
fun fromListVkAttachmentToString(attachments: List<VkAttachment>?): String? { fun fromListVkAttachmentToString(attachments: List<VkAttachment>?): String? {
if (attachments == null) return null if (attachments == null) return null
val string = val string = attachments
attachments.map { fromVkAttachmentToString(it)!! }.joinToString { CACHE_SEPARATOR } .mapNotNull(::fromVkAttachmentToString)
.joinToString(separator = CACHE_SEPARATOR)
return string return string
} }
@@ -88,34 +113,48 @@ class Converters {
if (string == null) return null if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) { if (string.contains(CACHE_SEPARATOR)) {
val attachments = val attachments = string
string.split(CACHE_SEPARATOR).map { fromStringToVkAttachment(it)!! } .split(CACHE_SEPARATOR)
.mapNotNull(::fromStringToVkAttachment)
return attachments return attachments
} }
val attachment = fromStringToVkAttachment(string)
val attachment = fromStringToVkAttachment(string)!! return attachment?.let { listOf(it) }
return listOf(attachment)
} }
@TypeConverter @TypeConverter
fun fromVkAttachmentToString(attachment: VkAttachment?): String? { fun fromVkAttachmentToString(attachment: VkAttachment?): String? {
if (attachment == null) return null if (attachment == null) return null
val string = Gson().toJson(attachment) try {
attachment.javaClass.getDeclaredField("className")
return string } catch (e: NoSuchFieldException) {
throw AttachmentClassNameIsEmptyException(attachment)
}
return try {
val string = Gson().toJson(attachment)
string
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
@TypeConverter @TypeConverter
fun fromStringToVkAttachment(string: String?): VkAttachment? { 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
}
} }
} }
@@ -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 <T> Scope.api(className: Class<T>): T = retrofit().create(className)
@@ -1,103 +1,26 @@
package com.meloda.fast.di 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.account.AccountsRepository
import com.meloda.fast.data.audios.AudiosApi
import com.meloda.fast.data.audios.AudiosRepository 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.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.conversations.ConversationsRepository
import com.meloda.fast.data.files.FilesApi
import com.meloda.fast.data.files.FilesRepository 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.messages.MessagesRepository
import com.meloda.fast.data.photos.PhotosApi
import com.meloda.fast.data.photos.PhotosRepository 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.users.UsersRepository
import com.meloda.fast.data.videos.VideosApi
import com.meloda.fast.data.videos.VideosRepository import com.meloda.fast.data.videos.VideosRepository
import dagger.Module import org.koin.core.module.dsl.singleOf
import dagger.Provides import org.koin.dsl.module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@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)
// 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)
} }
@@ -1,50 +1,28 @@
package com.meloda.fast.di package com.meloda.fast.di
import androidx.room.Room
import com.meloda.fast.common.AppGlobal import com.meloda.fast.common.AppGlobal
import com.meloda.fast.data.account.AccountsDao import com.meloda.fast.database.AccountsDatabase
import com.meloda.fast.data.conversations.ConversationsDao import com.meloda.fast.database.CacheDatabase
import com.meloda.fast.data.groups.GroupsDao import org.koin.core.scope.Scope
import com.meloda.fast.data.messages.MessagesDao import org.koin.dsl.module
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
@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {
@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
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 }
} }
private fun Scope.cache(): CacheDatabase = get()
private fun Scope.accounts(): AccountsDatabase = get()
@@ -2,24 +2,19 @@ package com.meloda.fast.di
import com.github.terrakok.cicerone.Cicerone import com.github.terrakok.cicerone.Cicerone
import com.github.terrakok.cicerone.Router import com.github.terrakok.cicerone.Router
import dagger.Module import com.meloda.fast.screens.captcha.screen.CaptchaScreen
import dagger.Provides import com.meloda.fast.screens.twofa.screen.TwoFaScreen
import dagger.hilt.InstallIn import org.koin.core.module.dsl.singleOf
import dagger.hilt.components.SingletonComponent import org.koin.core.scope.Scope
import javax.inject.Singleton import org.koin.dsl.module
@InstallIn(SingletonComponent::class) val navigationModule = module {
@Module single { Cicerone.create() }
object NavigationModule { single { cicerone().router }
@Provides single { cicerone().getNavigatorHolder() }
@Singleton
fun getCicerone(): Cicerone<Router> = Cicerone.create()
@Provides singleOf(::CaptchaScreen)
@Singleton singleOf(::TwoFaScreen)
fun getRouter(cicerone: Cicerone<Router>) = cicerone.router
@Provides
@Singleton
fun getNavigationHolder(cicerone: Cicerone<Router>) = cicerone.getNavigatorHolder()
} }
private fun Scope.cicerone(): Cicerone<Router> = get()
@@ -2,96 +2,34 @@ package com.meloda.fast.di
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.google.gson.Gson
import com.google.gson.GsonBuilder 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.AuthInterceptor
import com.meloda.fast.api.network.ResultCallFactory import com.meloda.fast.api.network.ResultCallFactory
import com.meloda.fast.api.network.VkUrls 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.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor 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.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@InstallIn(SingletonComponent::class) val networkModule = module {
@Module single { ChuckerCollector(get()) }
object NetworkModule { single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
singleOf(::AuthInterceptor)
/* single { GsonBuilder().setLenient().create() }
single {
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 =
OkHttpClient.Builder() OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor())
.addInterceptor(chuckerInterceptor) .addInterceptor(
chuckerInterceptor().apply {
redactHeader("Secret-Code")
}
)
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.addInterceptor( .addInterceptor(
@@ -99,92 +37,17 @@ val chuckerInterceptor = ChuckerInterceptor.Builder(context)
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
} }
).build() ).build()
}
@Singleton single {
@Provides Retrofit.Builder()
fun provideGson(): Gson = GsonBuilder() .baseUrl("${VkUrls.API}/")
.setLenient() .addConverterFactory(GsonConverterFactory.create(get()))
.create() .addCallAdapterFactory(ResultCallFactory(get()))
.client(get())
@Singleton .build()
@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)
} }
internal fun Scope.retrofit(): Retrofit = get()
private fun Scope.authInterceptor(): AuthInterceptor = get()
private fun Scope.chuckerInterceptor(): ChuckerInterceptor = get()
@@ -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<UpdateManager>() }
}
@@ -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 <T> Flow<T>.listenValue(action: suspend (T) -> Unit) = listenValue(lifecycleScope, action)
@@ -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
}
@@ -0,0 +1,5 @@
package com.meloda.fast.ext
val Boolean?.isTrue: Boolean get() = this == true
val Boolean?.isFalse: Boolean get() = this == false
@@ -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 <T : Parcelable> Bundle.getParcelableArrayListCompat(
key: String?,
clazz: Class<T>
): java.util.ArrayList<T>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableArrayList(key, clazz)
} else {
getParcelableArrayList<Parcelable>(key) as ArrayList<T>
}
}
@Suppress("DEPRECATION")
fun <T : Parcelable> Bundle.getParcelableCompat(key: String?, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
getParcelable(key)
}
}
@Suppress("DEPRECATION", "UNCHECKED_CAST")
fun <T: Serializable> Bundle.getSerializableCompat(key: String?, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, clazz)
} else {
getSerializable(key) as? T
}
}
@@ -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
)
}
@@ -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<UiText>? = null,
itemsChoiceType: ItemsChoiceType = ItemsChoiceType.None,
itemsClickAction: ((index: Int, value: String) -> Unit)? = null,
itemsMultiChoiceClickAction: ((index: Int, value: String, isChecked: Boolean) -> Unit)? = null,
checkedItems: List<Int>? = 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)
}
@@ -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> T?.notNull(lazyMessage: (() -> Any)? = null): T {
return if (lazyMessage != null) {
requireNotNull(this, lazyMessage)
} else {
requireNotNull(this)
}
}
inline fun <T> Iterable<T>.findIndex(predicate: (T) -> Boolean): Int? {
return indexOf(firstOrNull(predicate)).let { if (it == -1) null else it }
}
inline fun <reified T, K, M : MutableMap<in K, T>> Iterable<T>.toMap(
destination: M,
keySelector: (T) -> K,
): M {
for (element in this) {
val key = keySelector(element)
destination[key] = element
}
return destination
}
fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element)
}
context(ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit) = listenValue(viewModelScope, action)
fun <T> Flow<T>.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<Int> = (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<Boolean> = 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 <T> MutableSharedFlow<T>.emitOnMainScope(value: T) = emitOnScope(value, Dispatchers.Main)
context(ViewModel)
fun <T> MutableSharedFlow<T>.emitOnScope(
value: T,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
viewModelScope.launch(dispatcher) {
emit(value)
}
}
context(CoroutineScope)
suspend fun <T> MutableSharedFlow<T>.emitWithMain(value: T) {
withContext(Dispatchers.Main) {
emit(value)
}
}
context(ViewModel)
fun <T> MutableStateFlow<T>.updateValue(newValue: T) = this.update { newValue }
@@ -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 <T> Flow<T>.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())
}
@@ -1,4 +1,4 @@
package com.meloda.fast.extensions package com.meloda.fast.ext
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
@@ -27,71 +27,85 @@ object ImageLoader {
this.setImageDrawable(null) this.setImageDrawable(null)
} }
fun ImageView.loadWithGlide( fun ImageView.loadWithGlide(block: GlideParams.() -> Unit) {
url: String? = null, val params = GlideParams()
uri: Uri? = null, block.invoke(params)
drawableRes: Int? = null, loadWithGlide(params)
drawable: Drawable? = null, }
placeholderDrawable: Drawable? = null,
placeholderColor: Int? = null, fun ImageView.loadWithGlide(params: GlideParams) {
errorDrawable: Drawable? = placeholderDrawable,
errorColor: Int? = null,
crossFade: Boolean = false,
crossFadeDuration: Int? = null,
asCircle: Boolean = false,
transformations: List<TypeTransformations> = emptyList(),
onLoadedAction: (() -> Unit)? = null,
onFailedAction: (() -> Unit)? = null,
priority: Priority = Priority.NORMAL,
cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL
) {
val request = Glide.with(this) val request = Glide.with(this)
var builder = when { var builder = when {
url != null -> request.load(url) params.imageUrl != null -> request.load(params.imageUrl)
uri != null -> request.load(uri) params.imageUri != null -> request.load(params.imageUri)
drawableRes != null -> request.load(drawableRes) params.drawableRes != null -> request.load(params.drawableRes)
drawable != null -> request.load(drawable) drawable != null -> request.load(drawable)
else -> request.load(null as Drawable?) else -> request.load(null as Drawable?)
} }
val transforms = transformations.toMutableList() val transforms = params.transformations.toMutableList()
if (asCircle) { if (params.asCircle) {
transforms += TypeTransformations.CircleCrop transforms += TypeTransformations.CircleCrop
} }
builder = builder builder = builder
.apply(TypeTransformations.createRequestOptions(transforms)) .apply(TypeTransformations.createRequestOptions(transforms))
.error( .error(
errorDrawable params.errorDrawable
?: if (errorColor != null) ColorDrawable(errorColor) else null ?: if (params.errorColor != null) {
ColorDrawable(requireNotNull(params.errorColor))
} else null
) )
.placeholder( .placeholder(
placeholderDrawable params.placeholderDrawable
?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null ?: if (params.placeholderColor != null) {
ColorDrawable(requireNotNull(params.placeholderColor))
} else null
) )
.addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction)) .addListener(ImageLoadRequestListener(params.onLoadedAction, params.onFailedAction))
.diskCacheStrategy(cacheStrategy) .addListener(ImageLoadDoneListener(params.onDoneAction))
.priority(priority) .diskCacheStrategy(params.cacheStrategy)
.priority(params.loadPriority)
if (crossFade || crossFadeDuration != null) { if (params.crossFade || params.crossFadeDuration != null) {
builder = builder.transition(withCrossFade(crossFadeDuration ?: 200)) builder = builder.transition(withCrossFade(params.crossFadeDuration ?: 200))
} }
builder.into(this) 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<TypeTransformations> = 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( class ImageLoadRequestListener(
private val onLoadedAction: (() -> Unit)?, private val onLoadedAction: (() -> Unit)?,
private val onFailedAction: (() -> Unit)? private val onFailedAction: (() -> Unit)?,
) : RequestListener<Drawable> { ) : RequestListener<Drawable> {
override fun onLoadFailed( override fun onLoadFailed(
e: GlideException?, e: GlideException?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean {
onFailedAction?.invoke() onFailedAction?.invoke()
return false return false
@@ -102,13 +116,36 @@ class ImageLoadRequestListener(
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
dataSource: DataSource?, dataSource: DataSource?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean {
onLoadedAction?.invoke() onLoadedAction?.invoke()
return false return false
} }
} }
class ImageLoadDoneListener(private val onDoneAction: (() -> Unit)?) : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean,
): Boolean {
onDoneAction?.invoke()
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean,
): Boolean {
onDoneAction?.invoke()
return false
}
}
sealed class TypeTransformations { sealed class TypeTransformations {
object CenterCrop : TypeTransformations() object CenterCrop : TypeTransformations()
@@ -123,7 +160,7 @@ sealed class TypeTransformations {
val topLeft: Float, val topLeft: Float,
val topRight: Float, val topRight: Float,
val bottomRight: Float, val bottomRight: Float,
val bottomLeft: Float val bottomLeft: Float,
) : TypeTransformations() ) : TypeTransformations()
fun toGlideTransform(): Transformation<Bitmap> = when (this) { fun toGlideTransform(): Transformation<Bitmap> = when (this) {
@@ -0,0 +1 @@
package com.meloda.fast.ext
@@ -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)
@@ -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
}
@@ -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<Parcelable> {
val childViewStates = SparseArray<Parcelable>()
children.forEach { child -> child.saveHierarchyState(childViewStates) }
return childViewStates
}
fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray<Parcelable>) {
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 <T, K> Pair<T?, K?>.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 <T> MutableLiveData<T>.notifyObservers() {
this.value = this.value
}
fun <T> MutableLiveData<T>.setIfNotEquals(item: T) {
if (this.value != item) this.value = item
}
fun <T> MutableLiveData<T>.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> 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()
}
@@ -1,13 +0,0 @@
package com.meloda.fast.model
abstract class DataItem<IdType> {
abstract val dataItemId: IdType
object Header : DataItem<Int>() {
override val dataItemId = Int.MIN_VALUE
}
object Footer : DataItem<Int>() {
override val dataItemId = Int.MIN_VALUE + 1
}
}
@@ -6,17 +6,10 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
open class SelectableItem constructor( open class SelectableItem : Parcelable {
@Ignore
val selectableItemId: Int = 0
) : DataItem<Int>(), Parcelable {
@Ignore @Ignore
@IgnoredOnParcel @IgnoredOnParcel
var isSelected: Boolean = false var isSelected: Boolean = false
@Ignore
@IgnoredOnParcel
override val dataItemId = selectableItemId
} }
@@ -18,7 +18,7 @@ data class UpdateItem(
val originalName: String, val originalName: String,
val fileSize: Int, val fileSize: Int,
val preRelease: Int, val preRelease: Int,
val downloadLink: String val downloadLink: String,
) : Parcelable { ) : Parcelable {
fun isMandatory(): Boolean = mandatory == 1 fun isMandatory(): Boolean = mandatory == 1
@@ -29,6 +29,24 @@ data class UpdateItem(
return Gson().toJson(this) 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 @Parcelize
@@ -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
}
}
@@ -0,0 +1,3 @@
package com.meloda.fast.model.base
interface DisplayableItem
@@ -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
}
}
@@ -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
}
}

Some files were not shown because too many files have changed in this diff Show More