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.cxx.configure.gradleLocalProperties
val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage", "\"\"")
val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint", "\"\"")
@@ -17,13 +19,14 @@ plugins {
id("kotlin-android")
id("kotlin-kapt")
id("kotlin-parcelize")
id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.meloda.fast"
compileSdk = 32
compileSdk = 34
applicationVariants.all {
outputs.all {
@@ -34,14 +37,14 @@ android {
defaultConfig {
applicationId = "com.meloda.fast"
minSdk = 23
targetSdk = 32
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "alpha"
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
// arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
}
}
}
@@ -73,18 +76,51 @@ android {
}
}
val flavorDimension = "version"
flavorDimensions += flavorDimension
productFlavors {
create("dev") {
resourceConfigurations += listOf("en", "xxhdpi")
dimension = flavorDimension
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
}
create("full") {
dimension = flavorDimension
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn")
jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.5"
useLiveLiterals = true
}
packagingOptions {
jniLibs {
useLegacyPackaging = false
}
}
}
kapt {
correctErrorTypes = true
}
fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
@@ -92,74 +128,99 @@ fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion"
val currentTime get() = (System.currentTimeMillis() / 1000).toInt()
dependencies {
implementation(kotlin("reflect", "1.6.10"))
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.lifecycle.common.java8)
// DI zone
implementation("io.insert-koin:koin-android:3.4.0")
// end of DI zone
implementation(libs.androidx.splashScreen)
implementation("com.github.skydoves:cloudy:0.1.2")
implementation(libs.androidx.dataStore)
implementation("io.coil-kt:coil-compose:2.3.0")
implementation("io.coil-kt:coil:2.3.0")
implementation(libs.androidx.appCompat)
implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2")
implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2")
implementation(libs.androidx.activity)
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.21")
implementation(libs.androidx.fragment)
implementation("androidx.core:core-ktx:1.10.1")
implementation(libs.androidx.preference)
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation(libs.androidx.swipeRefreshLayout)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.recyclerView)
implementation("androidx.appcompat:appcompat:1.6.1")
implementation(libs.androidx.cardView)
implementation("androidx.activity:activity-ktx:1.7.2")
implementation(libs.androidx.constraintLayout)
implementation("androidx.fragment:fragment-ktx:1.6.1")
implementation(libs.androidx.room)
implementation(libs.androidx.room.runtime)
kapt(libs.androidx.room.compiler)
implementation("androidx.preference:preference-ktx:1.2.0")
implementation(libs.cicerone)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation(libs.waveformSeekBar)
implementation("androidx.recyclerview:recyclerview:1.3.1")
implementation(libs.glide)
kapt(libs.glide.compiler)
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation(libs.kPermissions)
implementation(libs.kPermissions.coroutines)
implementation("com.google.accompanist:accompanist-systemuicontroller:0.27.0")
implementation(libs.appCenter.analytics)
implementation(libs.appCenter.crashes)
implementation("androidx.room:room-ktx:2.5.2")
implementation("androidx.room:room-runtime:2.5.2")
ksp("androidx.room:room-compiler:2.5.2")
implementation(libs.hilt)
kapt(libs.hilt.compiler)
implementation("com.github.terrakok:cicerone:7.1")
implementation(libs.retrofit)
implementation(libs.retrofit.gson.converter)
implementation("com.github.massoudss:waveformSeekBar:5.0.0")
implementation(libs.okhttp3)
implementation(libs.okhttp3.interceptor)
implementation("com.github.bumptech.glide:glide:4.15.1")
ksp("com.github.bumptech.glide:compiler:4.15.1")
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
implementation("com.github.fondesa:kpermissions:3.4.0")
implementation("com.github.fondesa:kpermissions-coroutines:3.4.0")
implementation(libs.viewBindingDelegate)
implementation("com.microsoft.appcenter:appcenter-analytics:5.0.1")
implementation("com.microsoft.appcenter:appcenter-crashes:5.0.1")
implementation(libs.google.gson)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation(libs.google.guava)
implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11")
implementation(libs.google.material)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.7.1")
implementation(libs.jsoup)
implementation("com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.9")
implementation(libs.chucker)
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">
<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.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.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".common.AppGlobal"
android:allowBackup="false"
android:extractNativeLibs="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:localeConfig="@xml/locales_config"
android:supportsRtl="false"
android:testOnly="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="DataExtractionRules"
tools:replace="android:allowBackup"
tools:ignore="DataExtractionRules">
tools:targetApi="tiramisu">
<activity
android:name=".screens.main.MainActivity"
android:theme="@style/AppTheme.Splash"
android:name=".screens.main.activity.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -40,15 +39,64 @@
</intent-filter>
</activity>
<activity android:name=".screens.testing.TestActivity" />
<service
android:name=".service.LongPollService"
android:enabled="true"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".service.OnlineService"
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
android:name="androidx.core.content.FileProvider"
@@ -60,5 +108,4 @@
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>
@@ -7,8 +7,6 @@ enum class ApiEvent(val value: Int) {
MessageEdit(5),
MessageReadIncoming(6),
MessageReadOutgoing(7),
FriendOnline(8),
FriendOffline(9),
MessagesDeleted(13),
PinUnpinConversation(20),
PrivateTyping(61),
@@ -1,10 +1,10 @@
package com.meloda.fast.api
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.model.AppAccount
import kotlinx.coroutines.flow.MutableStateFlow
object UserConfig {
@@ -42,6 +42,6 @@ object UserConfig {
return currentUserId > 0 && userId > 0 && accessToken.isNotBlank()
}
val vkUser = MutableLiveData<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 USER_FIELDS =
"photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info"
"photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate"
const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS"
const val API_VERSION = "5.189"
const val API_VERSION = "5.173"
const val LP_VERSION = 10
const val VK_APP_ID = "2274003"
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,8 @@ open class ApiError(
val error: String? = null,
@SerializedName("error_msg", alternate = ["error_description"])
open val errorMessage: String? = null,
@SerializedName("error_type")
val errorType: String? = null,
val throwable: Throwable? = null
) : IOException() {
@@ -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(
val message: VkMessage,
val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup>
val groups: HashMap<Int, VkGroup>,
) : LongPollEvent()
data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent()
data class VkMessageReadIncomingEvent(val peerId: Int, val messageId: Int) : LongPollEvent()
data class VkMessageReadOutgoingEvent(val peerId: Int, val messageId: Int) : LongPollEvent()
data class VkMessageReadIncomingEvent(
val peerId: Int,
val messageId: Int,
val unreadCount: Int,
) : LongPollEvent()
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.base.viewmodel.VkEventCallback
import com.meloda.fast.data.messages.MessagesRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -47,10 +52,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
ApiEvent.MessageEdit -> parseMessageEdit(eventType, event)
ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event)
ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event)
ApiEvent.FriendOnline -> parseFriendOnline(eventType, event)
ApiEvent.FriendOffline -> parseFriendOffline(eventType, event)
ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event)
ApiEvent.PinUnpinConversation -> onNewEvent(eventType, event)
ApiEvent.PinUnpinConversation -> parseConversationPinStateChanged(eventType, event)
ApiEvent.PrivateTyping -> onNewEvent(eventType, event)
ApiEvent.ChatTyping -> onNewEvent(eventType, event)
ApiEvent.OneMoreTyping -> onNewEvent(eventType, event)
@@ -67,6 +70,27 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event")
}
private fun parseConversationPinStateChanged(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt
val majorId = event[2].asInt
launch {
listenersMap[ApiEvent.PinUnpinConversation]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>)
.onEvent(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = majorId
)
)
}
}
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
@@ -119,6 +143,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt
val messageId = event[2].asInt
val unreadCount = event[3].asInt
launch {
listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners ->
@@ -127,7 +152,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent(
LongPollEvent.VkMessageReadIncomingEvent(
peerId = peerId,
messageId = messageId
messageId = messageId,
unreadCount = unreadCount
)
)
}
@@ -139,6 +165,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt
val messageId = event[2].asInt
val unreadCount = event[3].asInt
launch {
listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners ->
@@ -147,7 +174,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
.onEvent(
LongPollEvent.VkMessageReadOutgoingEvent(
peerId = peerId,
messageId = messageId
messageId = messageId,
unreadCount = unreadCount
)
)
}
@@ -155,21 +183,13 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
}
}
private fun parseFriendOnline(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseFriendOffline(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private suspend fun <T : LongPollEvent> loadNormalMessage(eventType: ApiEvent, messageId: Int) =
coroutineScope {
suspendCoroutine<T> {
suspendCoroutine {
launch {
val normalMessageResponse = messagesRepository.getById(
MessagesGetByIdRequest(
@@ -179,7 +199,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
)
)
if (!normalMessageResponse.isSuccessful()) {
if (normalMessageResponse.isError()) {
normalMessageResponse.error.throwable?.run { throw this }
}
@@ -195,12 +215,12 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
val profiles = hashMapOf<Int, VkUser>()
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>()
messagesResponse.groups?.forEach { baseGroup ->
baseGroup.asVkGroup().let { group -> groups[group.id] = group }
baseGroup.mapToDomain().let { group -> groups[group.id] = group }
}
val resumeValue: LongPollEvent? = when (eventType) {
@@ -228,6 +248,14 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository)
}
}
fun onConversationPinStateChanged(listener: VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) {
registerListener(ApiEvent.PinUnpinConversation, listener)
}
fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) {
onConversationPinStateChanged(assembleEventCallback(block))
}
fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) {
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> {
return object : VkEventCallback<R> {
override fun onEvent(event: R) = block.invoke(event)
}
internal inline fun <R : Any> assembleEventCallback(
crossinline block: (R) -> Unit,
): 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.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.model.SelectableItem
import com.meloda.fast.util.TimeUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
// TODO: 05.08.2023, Danil Nikolaev: create other class for storing in database
@Entity(tableName = "messages")
@Parcelize
data class VkMessage constructor(
@@ -38,7 +40,7 @@ data class VkMessage constructor(
var replyMessage: VkMessage? = null,
val geo: BaseVkMessage.Geo? = null,
) : SelectableItem(id) {
) : SelectableItem() {
@Ignore
@IgnoredOnParcel
@@ -48,6 +50,14 @@ data class VkMessage constructor(
@IgnoredOnParcel
var group: VkGroup? = null
@Ignore
@IgnoredOnParcel
var actionUser: VkUser? = null
@Ignore
@IgnoredOnParcel
var actionGroup: VkGroup? = null
@Ignore
@IgnoredOnParcel
var state: State = State.Sent
@@ -58,7 +68,7 @@ data class VkMessage constructor(
fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) =
fun isRead(conversation: VkConversationDomain) =
if (isOut) {
conversation.outRead - id >= 0
} else {
@@ -15,7 +15,8 @@ data class VkUser(
val online: Boolean,
val photo200: String?,
val lastSeen: Int?,
val lastSeenStatus: String?
val lastSeenStatus: String?,
val birthday: String?
) : Parcelable {
override fun toString() = fullName
@@ -1,15 +1,11 @@
package com.meloda.fast.api.model.attachments
import android.os.Parcelable
import com.meloda.fast.model.DataItem
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
open class VkAttachment : DataItem<Int>(), Parcelable {
@IgnoredOnParcel
override val dataItemId: Int = -1
open class VkAttachment : Parcelable {
open fun asString(withAccessKey: Boolean = true) = ""
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkCurator(
val id: Int
) : VkAttachment()
val id: Int,
) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -1,5 +1,6 @@
package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -14,4 +15,7 @@ data class VkStory(
fun isFromGroup() = ownerId < 0
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -13,7 +13,7 @@ data class VkVideo(
val images: List<VideoImage>,
val firstFrames: List<BaseVkVideo.FirstFrame>?,
val accessKey: String?,
val title: String
val title: String,
) : VkAttachment() {
@IgnoredOnParcel
@@ -47,11 +47,11 @@ data class VkVideo(
val width: Int,
val height: Int,
val url: String,
val withPadding: Boolean
val withPadding: Boolean,
) : Parcelable {
@IgnoredOnParcel
var shapeKind: ShapeKind
var shapeKind: ShapeKind? = null
init {
val ratio = width.toFloat() / height.toFloat()
@@ -64,10 +64,21 @@ data class VkVideo(
}
}
sealed class ShapeKind {
object Vertical : ShapeKind()
object Horizontal : ShapeKind()
object Square : ShapeKind()
open class ShapeKind(val value: Int) {
object Square : ShapeKind(0)
object Vertical : ShapeKind(1)
object Horizontal : ShapeKind(2)
companion object {
fun parse(value: Int) = when (value) {
0 -> Square
1 -> Vertical
2 -> Horizontal
else -> throw IllegalArgumentException("Unknown value: $value")
}
}
}
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
@@ -1,8 +1,13 @@
package com.meloda.fast.api.model.attachments
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkWidget(
val id: Int
) : VkAttachment()
) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -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?
) : Parcelable {
fun asVkGroup() = VkGroup(
fun mapToDomain() = VkGroup(
id = -id,
name = name,
screenName = screen_name,
@@ -18,7 +18,8 @@ data class BaseVkUser(
val photo_200: String?,
val online: Int?,
val online_info: OnlineInfo?,
val screen_name: String
val screen_name: String,
val bdate: String?
//...other fields
) : Parcelable {
@@ -32,14 +33,15 @@ data class BaseVkUser(
val app_id: Int?
) : Parcelable
fun asVkUser() = VkUser(
fun mapToDomain() = VkUser(
id = id,
firstName = first_name,
lastName = last_name,
online = online == 1,
photo200 = photo_200,
lastSeen = online_info?.last_seen,
lastSeenStatus = online_info?.status
lastSeenStatus = online_info?.status,
birthday = bdate
)
}
@@ -1,9 +1,12 @@
package com.meloda.fast.api.model.base
package com.meloda.fast.api.model.data
import android.os.Parcelable
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.attachments.BaseVkGroupCall
import com.meloda.fast.api.model.domain.VkConversationDomain
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -12,6 +15,8 @@ data class BaseVkConversation(
val last_message_id: Int,
val in_read: Int,
val out_read: Int,
val in_read_cmid: Int,
val out_read_cmid: Int,
val sort_id: SortId,
val last_conversation_message_id: Int,
val is_marked_unread: Boolean,
@@ -22,43 +27,20 @@ data class BaseVkConversation(
val can_receive_money: Boolean,
val chat_settings: ChatSettings?,
val call_in_progress: CallInProgress?,
val unread_count: Int?
val unread_count: Int?,
) : Parcelable {
fun asVkConversation(lastMessage: VkMessage? = null) = VkConversation(
id = peer.id,
title = chat_settings?.title,
photo200 = chat_settings?.photo?.photo_200,
type = peer.type,
callInProgress = call_in_progress != null,
isPhantom = chat_settings?.is_disappearing == true,
lastConversationMessageId = last_conversation_message_id,
inRead = in_read,
outRead = out_read,
isMarkedUnread = is_marked_unread,
lastMessageId = last_message_id,
unreadCount = unread_count ?: 0,
membersCount = chat_settings?.members_count,
ownerId = chat_settings?.owner_id,
majorId = sort_id.major_id,
minorId = sort_id.minor_id,
canChangePin = chat_settings?.acl?.can_change_pin == true
).apply {
this.lastMessage = lastMessage
this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage()
}
@Parcelize
data class Peer(
val id: Int,
val type: String,
val local_id: Int
val local_id: Int,
) : Parcelable
@Parcelize
data class SortId(
val major_id: Int,
val minor_id: Int
val minor_id: Int,
) : Parcelable
@Parcelize
@@ -66,12 +48,12 @@ data class BaseVkConversation(
val disabled_forever: Boolean,
val no_sound: Boolean,
val disabled_mentions: Boolean,
val disabled_mass_mentions: Boolean
val disabled_mass_mentions: Boolean,
) : Parcelable
@Parcelize
data class CanWrite(
val allowed: Boolean
val allowed: Boolean,
) : Parcelable
@Parcelize
@@ -89,7 +71,7 @@ data class BaseVkConversation(
val is_disappearing: Boolean,
val is_service: Boolean,
val theme: String?,
val pinned_message: BaseVkMessage?
val pinned_message: BaseVkMessage?,
) : Parcelable {
@Parcelize
@@ -104,7 +86,7 @@ data class BaseVkConversation(
val can_copy_chat: Boolean,
val can_call: Boolean,
val can_use_mass_mentions: Boolean,
val can_change_style: Boolean
val can_change_style: Boolean,
) : Parcelable
@Parcelize
@@ -112,21 +94,54 @@ data class BaseVkConversation(
val photo_50: String?,
val photo_100: String?,
val photo_200: String?,
val is_default_photo: Boolean
val is_default_photo: Boolean,
) : Parcelable
}
@Parcelize
data class CallInProgress(
val participants: BaseVkGroupCall.Participants,
val join_link: String
val join_link: String,
) : Parcelable {
@Parcelize
data class Participants(
val list: List<Int>,
val count: Int
val count: Int,
) : 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 InvalidDocTitle = 1152
const val AccessToDocDenied = 1153
const val AccessTokenExpired = 1117
}
@Suppress("unused")
object VkErrors {
const val Unknown = "unknown_error"
@@ -55,7 +56,18 @@ object VkErrors {
}
class AuthorizationError : ApiError()
object VkErrorTypes {
const val OtpFormatIncorrect = "otp_format_is_incorrect"
const val WrongOtp = "wrong_otp"
}
object VkErrorMessages {
const val UserBanned = "user has been banned"
}
open class AuthorizationError : ApiError()
class TokenExpiredError : AuthorizationError()
data class ValidationRequiredError(
@SerializedName("validation_type")
@@ -76,3 +88,23 @@ data class CaptchaRequiredError(
@SerializedName("captcha_img")
val captchaImg: String
) : 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.VKConstants
import com.meloda.fast.api.network.account.AccountUrls
import com.meloda.fast.api.network.ota.OtaUrls
import okhttp3.Interceptor
import okhttp3.Response
import java.net.URLEncoder
@@ -14,7 +15,7 @@ class AuthInterceptor : Interceptor {
val url = builder.build().toUrl().toString()
if (!url.contains("upload.php")) {
if (!url.contains("upload.php") && !url.contains(OtaUrls.GetActualUrl)) {
builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8"))
}
@@ -14,11 +14,11 @@ import java.lang.reflect.Type
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
class ResultCallFactory : CallAdapter.Factory() {
class ResultCallFactory(private val gson: Gson) : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
retrofit: Retrofit,
): CallAdapter<*, *>? {
val rawReturnType: Class<*> = getRawType(returnType)
if (rawReturnType == Call::class.java) {
@@ -27,9 +27,9 @@ class ResultCallFactory : CallAdapter.Factory() {
if (getRawType(callInnerType) == ApiAnswer::class.java) {
if (callInnerType is ParameterizedType) {
val resultInnerType = getParameterUpperBound(0, callInnerType)
return ResultCallAdapter<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>
}
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 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>>) {
proxy.enqueue(ResultCallback(this, callback))
proxy.enqueue(ResultCallback(this, callback, gson))
}
override fun cloneImpl(): ResultCall<T> {
return ResultCall(proxy.clone())
return ResultCall(proxy.clone(), gson)
}
private class ResultCallback<T>(
private val proxy: ResultCall<T>,
private val callback: Callback<ApiAnswer<T>>
private val callback: Callback<ApiAnswer<T>>,
private val gson: Gson
) : Callback<T> {
val gson = Gson()
override fun onResponse(call: Call<T>, response: Response<T>) {
val result: ApiAnswer<T> =
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 {
if (!result.isSuccessful()) {
if (result.isError()) {
result.error.throwable?.run {
onFailure(call, this)
return true
}
} else {
return false
}
return false
@@ -143,8 +140,16 @@ sealed class ApiAnswer<out R> {
@OptIn(ExperimentalContracts::class)
fun isSuccessful(): Boolean {
contract {
returns(false) implies (this@ApiAnswer is Error)
returns(true) implies (this@ApiAnswer is Success)
}
return this is Success
}
@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 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
data class AuthDirectResponse(
@SerializedName("access_token") val accessToken: String? = null,
@SerializedName("user_id") val userId: Int? = null,
@SerializedName("trusted_hash") val twoFaHash: String? = null,
@SerializedName("validation_sid") val validationSid: String? = null
@SerializedName("access_token") val accessToken: String?,
@SerializedName("user_id") val userId: Int?,
@SerializedName("trusted_hash") val twoFaHash: String?,
@SerializedName("validation_sid") val validationSid: String?,
@SerializedName("validation_type") val validationType: String?,
@SerializedName("phone_mask") val phoneMask: String?,
@SerializedName("redirect_uri") val redirectUrl: String?,
@SerializedName("validation_resend") val validationResend: String?,
@SerializedName("cant_get_code_open_restore") val isCanNotGetCodeNeedToOpenRestore: Boolean
) : Parcelable
@Parcelize
@@ -2,7 +2,7 @@ package com.meloda.fast.api.network.conversations
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.meloda.fast.api.model.base.BaseVkConversation
import com.meloda.fast.api.model.data.BaseVkConversation
import com.meloda.fast.api.model.base.BaseVkGroup
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.BaseVkUser
@@ -189,5 +189,51 @@ data class MessagesGetByIdRequest(
extended?.let { this["extended"] = it.intString }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesGetChatRequest(
val chatId: Int,
val fields: String? = null
) : Parcelable {
val map
get() = mutableMapOf(
"chat_id" to chatId.toString()
).apply {
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesGetConversationMembersRequest(
val peerId: Int,
val offset: Int? = null,
val count: Int? = null,
val extended: Boolean? = null,
val fields: String? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
offset?.let { this["offset"] = it.toString() }
count?.let { this["count"] = it.toString() }
extended?.let { this["extended"] = it.toString() }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesRemoveChatUserRequest(
val chatId: Int,
val memberId: Int
) : Parcelable {
val map
get() = mutableMapOf(
"chat_id" to chatId.toString(),
"member_id" to memberId.toString()
)
}
@@ -1,10 +1,8 @@
package com.meloda.fast.api.network.messages
import android.os.Parcelable
import com.meloda.fast.api.model.base.BaseVkConversation
import com.meloda.fast.api.model.base.BaseVkGroup
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.model.base.BaseVkUser
import com.meloda.fast.api.model.base.*
import com.meloda.fast.api.model.data.BaseVkConversation
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -23,3 +21,11 @@ data class MessagesGetByIdResponse(
val profiles: List<BaseVkUser>?,
val groups: List<BaseVkGroup>?
) : 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 GetById = "${VkUrls.API}/messages.getById"
const val MarkAsRead = "${VkUrls.API}/messages.markAsRead"
const val GetChat = "${VkUrls.API}/messages.getChat"
const val GetConversationMembers = "${VkUrls.API}/messages.getConversationMembers"
const val RemoveChatUser = "${VkUrls.API}/messages.removeChatUser"
}
@@ -8,5 +8,4 @@ abstract class BaseActivity : AppCompatActivity {
constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId)
}
@@ -1,47 +1,11 @@
package com.meloda.fast.base
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import com.meloda.fast.screens.main.MainActivity
abstract class BaseFragment : Fragment {
constructor() : super()
constructor(@LayoutRes resId: Int) : super(resId)
protected var shouldNavBarShown: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (arguments == null) arguments = Bundle()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(requireActivity() as? MainActivity)?.run {
toggleNavBarVisibility(shouldNavBarShown)
}
}
val activityRouter
get() = run {
if (requireActivity() is MainActivity) {
(requireActivity() as MainActivity).router
} else {
null
}
}
fun requireActivityRouter() = requireNotNull(activityRouter)
fun startProgress() = toggleProgress(true)
fun stopProgress() = toggleProgress(false)
protected open fun toggleProgress(isProgressing: Boolean) {}
}
@@ -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 androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.meloda.fast.model.DataItem
import kotlinx.coroutines.*
import kotlin.properties.Delegates
@Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST")
abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
abstract class BaseAdapter<T : Any, VH : BaseHolder> constructor(
var context: Context,
diffUtil: DiffUtil.ItemCallback<T>,
preAddedValues: List<T> = emptyList(),
@@ -59,27 +58,18 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun add(
item: T,
position: Int? = null,
beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) = addAll(listOf(item), position, beforeFooter, commitCallback)
) = addAll(listOf(item), position, commitCallback)
fun addAll(
items: List<T>,
position: Int? = null,
beforeFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) {
adapterScope.launch {
val newList = cloneCurrentList()
if (position == null) {
val mutableItems = items.toMutableList()
if (beforeFooter && newList.lastOrNull() is DataItem.Footer) {
newList.removeLastOrNull()
}
if (beforeFooter) {
mutableItems += DataItem.Footer as T
}
newList.addAll(mutableItems)
cleanList.addAll(mutableItems)
@@ -100,40 +90,34 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun removeAll(items: List<T>, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList.removeAll(items)
submitList(newList, commitCallback)
cleanList.removeAll(items)
submitList(newList, commitCallback)
}
fun removeAt(index: Int, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList.removeAt(index)
submitList(newList, commitCallback)
cleanList.removeAt(index)
submitList(newList, commitCallback)
}
fun clear(commitCallback: (() -> Unit)? = null) = removeAll(currentList, commitCallback)
fun setItem(
item: T,
withHeader: Boolean = false,
withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) = setItems(listOf(item), withHeader, withFooter, commitCallback)
) = setItems(listOf(item), commitCallback)
@Suppress("UNCHECKED_CAST")
fun setItems(
list: List<T>?,
withHeader: Boolean = false,
withFooter: Boolean = false,
commitCallback: (() -> Unit)? = null
) {
adapterScope.launch {
val items = mutableListOf<T>()
if (withHeader) items.add(DataItem.Header as T)
if (!list.isNullOrEmpty()) items.addAll(list)
if (withFooter) items.add(DataItem.Footer as T)
withContext(Dispatchers.Main) {
if (items == currentList) {
@@ -165,9 +149,9 @@ abstract class BaseAdapter<T : DataItem<*>, VH : BaseHolder> constructor(
fun setItem(position: Int, item: T, commitCallback: (() -> Unit)? = null) {
val newList = cloneCurrentList()
newList[position] = item
submitList(newList, commitCallback)
cleanList[position] = item
submitList(newList, commitCallback)
}
fun isEmpty() = currentList.isEmpty()
@@ -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
interface OnItemClickListener {
fun onItemClick(position: Int)
fun interface OnItemClickListener<T> {
fun onItemClick(item: T)
}
interface OnItemLongClickListener {
fun onItemLongClick(position: Int)
fun interface OnItemLongClickListener<T> {
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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.base.ApiError
import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.AuthorizationError
import com.meloda.fast.api.network.CaptchaRequiredError
import com.meloda.fast.api.network.ValidationRequiredError
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import com.meloda.fast.api.network.*
import com.meloda.fast.ext.isTrue
import com.meloda.fast.ext.notNull
@Suppress("MemberVisibilityCanBePrivate")
abstract class BaseViewModel : ViewModel() {
var unknownErrorDefaultText: String = ""
open suspend fun sendSingleEvent(event: VkEvent) {}
protected val tasksEventChannel = Channel<VkEvent>()
val tasksEvent = tasksEventChannel.receiveAsFlow()
suspend fun <T> sendRequestNotNull(
onError: ErrorHandler? = null,
request: suspend () -> ApiAnswer<T>
): T = sendRequest(onError, request).notNull()
protected val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
viewModelScope.launch { onException(throwable) }
}
fun launch(block: suspend CoroutineScope.() -> Unit): Job {
return viewModelScope.launch(exceptionHandler, block = block)
}
protected suspend fun <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)
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 -> {
onError?.invoke(response.error) ?: checkErrors(response.error)
}
}
val error = response.error
onEnd?.invoke()
if (!onError?.handleError(error).isTrue) {
checkErrors(error)
}
return response
}
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()
null
}
}
}
protected open suspend fun onException(throwable: Throwable) {
checkErrors(throwable)
}
protected suspend fun onStart() {
sendEvent(StartProgressEvent)
}
protected suspend fun onStop() {
sendEvent(StopProgressEvent)
}
protected suspend fun <T : VkEvent> sendEvent(event: T) = tasksEventChannel.send(event)
protected suspend fun checkErrors(throwable: Throwable) {
when (throwable) {
is TokenExpiredError -> {
sendSingleEvent(TokenExpiredErrorEvent)
}
is AuthorizationError -> {
sendEvent(AuthorizationErrorEvent)
sendSingleEvent(AuthorizationErrorEvent)
}
is UserBannedError -> {
throwable.banInfo.let { banInfo ->
sendSingleEvent(
UserBannedEvent(
memberName = banInfo.memberName,
message = banInfo.message,
restoreUrl = banInfo.restoreUrl,
accessToken = banInfo.accessToken
)
)
}
}
is ValidationRequiredError -> {
sendEvent(ValidationRequiredEvent(throwable.validationSid))
sendSingleEvent(
ValidationRequiredEvent(
sid = throwable.validationSid,
redirectUri = throwable.redirectUri,
phoneMask = throwable.phoneMask,
validationType = throwable.validationType,
canResendSms = throwable.validationResend == "sms",
codeError = null
)
)
}
is CaptchaRequiredError -> {
sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg))
sendSingleEvent(
CaptchaRequiredEvent(
sid = throwable.captchaSid,
image = throwable.captchaImg
)
)
}
is ApiError -> {
sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText))
sendSingleEvent(
if (throwable.errorMessage == null) {
UnknownErrorEvent
} else {
ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage))
}
)
}
else -> {
sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText))
sendSingleEvent(
if (throwable.message == null) {
UnknownErrorEvent
} else {
ErrorTextEvent(requireNotNull(throwable.message))
}
)
}
}
}
}
@@ -5,10 +5,10 @@ import android.view.View
import androidx.annotation.LayoutRes
import androidx.lifecycle.lifecycleScope
import com.meloda.fast.base.BaseFragment
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment {
@Deprecated("", ReplaceWith("BaseFragment"))
abstract class BaseViewModelFragment<VM : DeprecatedBaseViewModel> : BaseFragment {
constructor() : super()
@@ -25,7 +25,7 @@ abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment {
ViewModelUtils.parseEvent(this, event)
}
protected fun <T : BaseViewModel> subscribeToViewModel(viewModel: T) {
protected fun <T : DeprecatedBaseViewModel> subscribeToViewModel(viewModel: T) {
lifecycleScope.launch {
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
abstract class VkEvent
abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
abstract class VkProgressEvent : VkEvent()
import com.meloda.fast.model.base.UiText
abstract class VkEvent
abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent()
object UnknownErrorEvent : VkErrorEvent()
open class ErrorTextEvent(override val errorText: String) : VkErrorEvent()
object AuthorizationErrorEvent : VkErrorEvent()
object TokenExpiredErrorEvent : VkErrorEvent()
data class CaptchaRequiredEvent(val sid: String, val image: String) : VkErrorEvent()
data class ValidationRequiredEvent(val sid: String) : VkErrorEvent()
data class ValidationRequiredEvent(
val sid: String,
val redirectUri: String,
val phoneMask: String,
val validationType: String,
val canResendSms: Boolean,
val codeError: UiText?
) : VkErrorEvent()
object StartProgressEvent : VkProgressEvent()
object StopProgressEvent : VkProgressEvent()
data class UserBannedEvent(
val memberName: String, val message: String, val restoreUrl: String, val accessToken: String,
) : VkErrorEvent()
interface VkEventCallback<in T : Any> {
fun interface VkEventCallback<in T : Any> {
fun onEvent(event: T)
}
@@ -6,12 +6,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.meloda.fast.R
import com.meloda.fast.api.UserConfig
import com.meloda.fast.base.BaseFragment
import com.meloda.fast.screens.main.MainActivity
import com.meloda.fast.util.ViewUtils.showErrorDialog
import com.meloda.fast.ext.showDialog
import com.meloda.fast.model.base.UiText
import com.meloda.fast.screens.main.activity.MainActivity
object ViewModelUtils {
@Deprecated("rewrite")
@Suppress("MemberVisibilityCanBePrivate")
fun parseEvent(activity: FragmentActivity, event: VkEvent) {
when (event) {
@@ -24,26 +25,47 @@ object ViewModelUtils {
activity.finishAffinity()
activity.startActivity(Intent(activity, MainActivity::class.java))
}
is TokenExpiredErrorEvent -> {
Toast.makeText(
activity, R.string.token_expired, Toast.LENGTH_LONG
).show()
UserConfig.clear()
activity.finishAffinity()
activity.startActivity(Intent(activity, MainActivity::class.java))
}
is UserBannedEvent -> {
// TODO: 17.04.2023, Danil Nikolaev: handle banned event
// (activity as? MainActivity)?.accessRouter()?.newRootScreen(
// Screens.UserBanned(
// memberName = event.memberName,
// message = event.message,
// restoreUrl = event.restoreUrl,
// accessToken = event.accessToken
// )
// )
}
is UnknownErrorEvent -> {
activity.showDialog(
title = UiText.Resource(R.string.title_error),
message = UiText.Resource(R.string.unknown_error_occurred),
positiveText = UiText.Resource(R.string.ok)
)
}
is VkErrorEvent -> {
event.errorText?.run {
activity.showErrorDialog(this)
activity.showDialog(
title = UiText.Resource(R.string.title_error),
message = UiText.Simple(this),
positiveText = UiText.Resource(R.string.ok)
)
}
}
}
}
@Deprecated("rewrite")
fun parseEvent(fragment: Fragment, event: VkEvent) {
if (event is VkProgressEvent) {
if (fragment is BaseFragment) {
if (event is StartProgressEvent) {
fragment.startProgress()
} else if (event is StopProgressEvent) {
fragment.stopProgress()
}
}
} else {
parseEvent(fragment.requireActivity(), event)
}
parseEvent(fragment.requireActivity(), event)
}
}
@@ -1,94 +1,83 @@
package com.meloda.fast.common
import android.app.Application
import android.app.DownloadManager
import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.res.Resources
import android.net.ConnectivityManager
import android.util.Log
import android.view.inputmethod.InputMethodManager
import android.media.AudioManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.pm.PackageInfoCompat
import androidx.preference.PreferenceManager
import androidx.room.Room
import com.meloda.fast.database.AppDatabase
import dagger.hilt.android.HiltAndroidApp
import com.google.android.material.color.DynamicColors
import com.meloda.fast.common.di.applicationModule
import com.meloda.fast.screens.settings.SettingsFragment
import com.meloda.fast.util.AndroidUtils
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.properties.Delegates
@HiltAndroidApp
class AppGlobal : Application() {
companion object {
lateinit var inputMethodManager: InputMethodManager
lateinit var connectivityManager: ConnectivityManager
lateinit var clipboardManager: ClipboardManager
lateinit var downloadManager: DownloadManager
lateinit var preferences: SharedPreferences
lateinit var resources: Resources
lateinit var packageName: String
private lateinit var instance: AppGlobal
lateinit var appDatabase: AppDatabase
lateinit var packageManager: PackageManager
var versionName = ""
var versionCode = 0
var screenWidth = 0
var screenHeight = 0
var screenWidth80 = 0
val Instance get() = instance
}
override fun onCreate() {
super.onCreate()
instance = this
appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache")
// .fallbackToDestructiveMigration()
.build()
preferences = PreferenceManager.getDefaultSharedPreferences(this)
if (preferences.getBoolean(
SettingsFragment.KEY_USE_DYNAMIC_COLORS,
SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS
)
) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES)
versionName = info.versionName
versionCode = PackageInfoCompat.getLongVersionCode(info).toInt()
Companion.resources = resources
Companion.packageName = packageName
Companion.packageManager = packageManager
screenWidth80 = (AndroidUtils.getDisplayWidth() * 0.8).roundToInt()
screenWidth = resources.displayMetrics.widthPixels
screenHeight = resources.displayMetrics.heightPixels
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
screenWidth80 = (screenWidth * 0.8).roundToInt()
applyDarkTheme()
val density = resources.displayMetrics.density
val densityDpi = resources.displayMetrics.densityDpi
val densityScaled = resources.displayMetrics.scaledDensity
val xDpi = resources.displayMetrics.xdpi
val yDpi = resources.displayMetrics.ydpi
initKoin()
}
val diagonal = sqrt(
(screenWidth * screenWidth - screenHeight * screenHeight).toFloat()
private fun applyDarkTheme() {
val nightMode = preferences.getInt(
SettingsFragment.KEY_APPEARANCE_DARK_THEME,
SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME
)
AppCompatDelegate.setDefaultNightMode(nightMode)
}
Log.i(
"Fast::DeviceInfo",
"width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi"
)
private fun initKoin() {
startKoin {
androidLogger()
androidContext(this@AppGlobal)
modules(applicationModule)
}
}
inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
companion object {
private lateinit var instance: AppGlobal
val preferences: SharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(instance)
}
var versionName = ""
var versionCode = 0
var screenWidth80 = 0
val Instance: AppGlobal get() = instance
val resources: Resources get() = Instance.resources
val packageManager: PackageManager get() = Instance.packageManager
var audioManager: AudioManager by Delegates.notNull()
}
}
@@ -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
import com.github.terrakok.cicerone.androidx.FragmentScreen
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.model.UpdateItem
import com.meloda.fast.screens.chatinfo.ChatInfoFragment
import com.meloda.fast.screens.conversations.ConversationsFragment
import com.meloda.fast.screens.login.LoginFragment
import com.meloda.fast.screens.main.MainFragment
import com.meloda.fast.screens.messages.ForwardedMessagesFragment
import com.meloda.fast.screens.messages.MessagesHistoryFragment
import com.meloda.fast.screens.settings.SettingsRootFragment
import com.meloda.fast.screens.settings.SettingsFragment
import com.meloda.fast.screens.updates.UpdatesFragment
import com.meloda.fast.screens.userbanned.UserBannedFragment
@Suppress("FunctionName")
object Screens {
fun Main() = FragmentScreen { MainFragment() }
fun Main() = FragmentScreen { MainFragment.newInstance() }
fun Login(
getFastToken: Boolean = false
) = FragmentScreen {
LoginFragment.newInstance(getFastToken)
}
fun Login() = FragmentScreen { LoginFragment.newInstance() }
fun Conversations() = FragmentScreen { ConversationsFragment() }
fun MessagesHistory(
conversation: VkConversation,
conversation: VkConversationDomain,
user: VkUser?,
group: VkGroup?
) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) }
fun ForwardedMessages(
conversation: VkConversation,
conversation: VkConversationDomain,
messages: List<VkMessage>,
profiles: HashMap<Int, VkUser> = 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) =
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
import androidx.lifecycle.MutableLiveData
import com.meloda.fast.BuildConfig
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse
import com.meloda.fast.data.ota.OtaApi
import com.meloda.fast.extensions.setIfNotEquals
import com.meloda.fast.model.UpdateActualUrl
import com.meloda.fast.model.UpdateItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import kotlin.coroutines.CoroutineContext
class UpdateManager(private val repo: OtaApi) : CoroutineScope {
interface UpdateManager {
val stateFlow: Flow<UpdateManagerState>
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default
fun checkUpdates(): Job
}
companion object {
val newUpdate = MutableLiveData<UpdateItem?>(null)
val updateError = MutableLiveData<Throwable?>(null)
class UpdateManagerImpl(private val repo: OtaApi) : UpdateManager {
var otaBaseUrl: String? = null
private set
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.IO
private var listener: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null
private val coroutineScope = CoroutineScope(coroutineContext)
private fun getActualUrl() = launch {
private var otaBaseUrl: String? = null
override val stateFlow = MutableStateFlow(UpdateManagerState.EMPTY)
override fun checkUpdates() = coroutineScope.launch {
val job: suspend () -> ApiAnswer<UpdateActualUrl> = { repo.getActualUrl() }
when (val jobResponse = job()) {
@@ -44,47 +46,55 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope {
is ApiAnswer.Error -> {
otaBaseUrl = null
val throwable = jobResponse.error.throwable
listener?.invoke(null, throwable)
withContext(Dispatchers.Main) {
updateError.setIfNotEquals(throwable)
}
val newForm = stateFlow.value.copy(
updateItem = null,
throwable = throwable
)
stateFlow.emit(newForm)
}
}
}
private fun getLatestRelease() = launch {
private fun getLatestRelease() = coroutineScope.launch {
val url = "$otaBaseUrl/releases-latest"
val job: suspend () -> ApiAnswer<ApiResponse<OtaGetLatestReleaseResponse>> = {
repo.getLatestRelease(url = url, secretCode = getOtaSecret())
}
withContext(Dispatchers.Main) {
when (val jobResponse = job()) {
is ApiAnswer.Success -> {
val response = jobResponse.data.response ?: return@withContext
val latestRelease = response.release
when (val jobResponse = job()) {
is ApiAnswer.Success -> {
val response = jobResponse.data.response ?: return@launch
val latestRelease = response.release
if (latestRelease != null &&
(AppGlobal.versionName
.split("_")
.getOrNull(1) != latestRelease.versionName ||
AppGlobal.versionCode < latestRelease.versionCode)
) {
newUpdate.setIfNotEquals(latestRelease)
listener?.invoke(latestRelease, null)
} else {
newUpdate.setIfNotEquals(null)
listener?.invoke(null, null)
}
val updateItem = if (latestRelease != null &&
(AppGlobal.versionName
.split("_")
.getOrNull(1) != latestRelease.versionName ||
AppGlobal.versionCode < latestRelease.versionCode)
) {
latestRelease
} else {
null
}
is ApiAnswer.Error -> {
val throwable = jobResponse.error.throwable
updateError.setIfNotEquals(throwable)
listener?.invoke(null, throwable)
}
val newForm = stateFlow.value.copy(
updateItem = updateItem,
throwable = null
)
stateFlow.emit(newForm)
}
is ApiAnswer.Error -> {
val throwable = jobResponse.error.throwable
val newForm = stateFlow.value.copy(
updateItem = null,
throwable = throwable
)
stateFlow.emit(newForm)
}
}
}
@@ -92,9 +102,15 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope {
private fun getOtaSecret(): String {
return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8")
}
}
fun checkUpdates(block: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null) = launch {
this@UpdateManager.listener = block
getActualUrl()
data class UpdateManagerState(
val updateItem: UpdateItem?,
val throwable: Throwable?,
) {
companion object {
val EMPTY = UpdateManagerState(
updateItem = null, throwable = null
)
}
}
@@ -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)
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.OnConflictStrategy
import androidx.room.Query
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.domain.VkConversationDomain
@Dao
interface ConversationsDao {
@Query("SELECT * FROM conversations")
suspend fun getAll(): List<VkConversation>
suspend fun getAll(): List<VkConversationDomain>
@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
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.api.network.conversations.ConversationsDeleteRequest
import com.meloda.fast.api.network.conversations.ConversationsGetRequest
import com.meloda.fast.api.network.conversations.ConversationsPinRequest
import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest
import kotlinx.coroutines.sync.Mutex
class ConversationsRepository(
private val conversationsApi: ConversationsApi,
@@ -20,6 +19,6 @@ class ConversationsRepository(
suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map)
suspend fun store(conversations: List<VkConversation>) = conversationsDao.insert(conversations)
suspend fun store(conversations: List<VkConversationDomain>) = conversationsDao.insert(conversations)
}
@@ -1,10 +1,12 @@
package com.meloda.fast.data.messages
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.model.base.BaseVkChat
import com.meloda.fast.api.model.base.BaseVkLongPoll
import com.meloda.fast.api.model.base.BaseVkMessage
import com.meloda.fast.api.network.ApiAnswer
import com.meloda.fast.api.network.messages.MessagesGetByIdResponse
import com.meloda.fast.api.network.messages.MessagesGetConversationMembersResponse
import com.meloda.fast.api.network.messages.MessagesGetHistoryResponse
import com.meloda.fast.api.network.messages.MessagesUrls
import retrofit2.http.FieldMap
@@ -53,4 +55,16 @@ interface MessagesApi {
@POST(MessagesUrls.MarkAsRead)
suspend fun markAsRead(@FieldMap params: Map<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.network.longpoll.LongPollGetUpdatesRequest
import com.meloda.fast.api.network.messages.MessagesDeleteRequest
import com.meloda.fast.api.network.messages.MessagesEditRequest
import com.meloda.fast.api.network.messages.MessagesGetByIdRequest
import com.meloda.fast.api.network.messages.MessagesGetChatRequest
import com.meloda.fast.api.network.messages.MessagesGetConversationMembersRequest
import com.meloda.fast.api.network.messages.MessagesGetHistoryRequest
import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest
import com.meloda.fast.api.network.messages.MessagesMarkAsImportantRequest
import com.meloda.fast.api.network.messages.MessagesPinMessageRequest
import com.meloda.fast.api.network.messages.MessagesRemoveChatUserRequest
import com.meloda.fast.api.network.messages.MessagesSendRequest
import com.meloda.fast.api.network.messages.MessagesUnPinMessageRequest
import com.meloda.fast.data.longpoll.LongPollApi
import com.meloda.fast.api.network.messages.*
class MessagesRepository(
private val messagesApi: MessagesApi,
private val messagesDao: MessagesDao,
private val longPollApi: LongPollApi
private val longPollApi: LongPollApi,
) {
suspend fun store(message: VkMessage) = store(listOf(message))
suspend fun store(messages: List<VkMessage>) = messagesDao.insert(messages)
suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId)
@@ -41,7 +54,7 @@ class MessagesRepository(
suspend fun getLongPollUpdates(
serverUrl: String,
params: LongPollGetUpdatesRequest
params: LongPollGetUpdatesRequest,
) = longPollApi.getResponse(serverUrl, params.map)
suspend fun getById(params: MessagesGetByIdRequest) =
@@ -50,7 +63,7 @@ class MessagesRepository(
suspend fun markAsRead(
peerId: Int,
messagesIds: List<Int>? = null,
startMessageId: Int? = null
startMessageId: Int? = null,
) = messagesApi.markAsRead(
mutableMapOf("peer_id" to peerId.toString()).apply {
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
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.data.account.AccountsDao
import com.meloda.fast.api.model.domain.VkConversationDomain
import com.meloda.fast.data.conversations.ConversationsDao
import com.meloda.fast.data.groups.GroupsDao
import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.users.UsersDao
import com.meloda.fast.model.AppAccount
@Database(
entities = [
AppAccount::class,
VkConversation::class,
VkConversationDomain::class,
VkMessage::class,
VkUser::class,
VkGroup::class
],
version = 34,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 33, to = 34)
]
version = 42,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract class CacheDatabase : RoomDatabase() {
abstract val accountsDao: AccountsDao
abstract val conversationsDao: ConversationsDao
abstract val messagesDao: MessagesDao
abstract val usersDao: UsersDao
@@ -2,6 +2,7 @@ package com.meloda.fast.database
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.meloda.fast.api.base.AttachmentClassNameIsEmptyException
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.model.base.BaseVkMessage
@@ -18,25 +19,37 @@ class Converters {
fun fromGeoToString(geo: BaseVkMessage.Geo?): String? {
if (geo == null) return null
val string = Gson().toJson(geo)
return try {
val string = Gson().toJson(geo)
return string
return string
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@TypeConverter
fun fromStringToGeo(string: String?): BaseVkMessage.Geo? {
if (string == null) return null
val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java)
return try {
val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java)
return geo
return geo
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@TypeConverter
fun fromListVkMessageToString(messages: List<VkMessage>?): String? {
if (messages == null) return null
val string = messages.map { fromVkMessageToString(it)!! }.joinToString { CACHE_SEPARATOR }
val string = messages
.mapNotNull(::fromVkMessageToString)
.joinToString(separator = CACHE_SEPARATOR)
return string
}
@@ -46,40 +59,52 @@ class Converters {
if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) {
val messages =
string.split(CACHE_SEPARATOR).map { fromStringToVkMessage(it)!! }
val messages = string
.split(CACHE_SEPARATOR)
.mapNotNull(::fromStringToVkMessage)
return messages
}
val message = fromStringToVkMessage(string)!!
return listOf(message)
val message = fromStringToVkMessage(string)
return message?.let { listOf(it) }
}
@TypeConverter
fun fromVkMessageToString(message: VkMessage?): String? {
if (message == null) return null
return Gson().toJson(message)
return try {
val string = Gson().toJson(message)
return string
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@TypeConverter
fun fromStringToVkMessage(string: String?): VkMessage? {
if (string == null) return null
val message = Gson().fromJson(string, VkMessage::class.java)
return try {
val message = Gson().fromJson(string, VkMessage::class.java)
return message
return message
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@TypeConverter
fun fromListVkAttachmentToString(attachments: List<VkAttachment>?): String? {
if (attachments == null) return null
val string =
attachments.map { fromVkAttachmentToString(it)!! }.joinToString { CACHE_SEPARATOR }
val string = attachments
.mapNotNull(::fromVkAttachmentToString)
.joinToString(separator = CACHE_SEPARATOR)
return string
}
@@ -88,34 +113,48 @@ class Converters {
if (string == null) return null
if (string.contains(CACHE_SEPARATOR)) {
val attachments =
string.split(CACHE_SEPARATOR).map { fromStringToVkAttachment(it)!! }
val attachments = string
.split(CACHE_SEPARATOR)
.mapNotNull(::fromStringToVkAttachment)
return attachments
}
val attachment = fromStringToVkAttachment(string)
val attachment = fromStringToVkAttachment(string)!!
return listOf(attachment)
return attachment?.let { listOf(it) }
}
@TypeConverter
fun fromVkAttachmentToString(attachment: VkAttachment?): String? {
if (attachment == null) return null
val string = Gson().toJson(attachment)
return string
try {
attachment.javaClass.getDeclaredField("className")
} catch (e: NoSuchFieldException) {
throw AttachmentClassNameIsEmptyException(attachment)
}
return try {
val string = Gson().toJson(attachment)
string
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@TypeConverter
fun fromStringToVkAttachment(string: String?): VkAttachment? {
if (string == null) return null
if (string.isNullOrBlank()) return null
val className = JSONObject(string).optString("className")
return try {
val className = JSONObject(string).optString("className")
val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment?
val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment?
return attachment
return attachment
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
@@ -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
import com.meloda.fast.data.longpoll.LongPollApi
import com.meloda.fast.data.account.AccountApi
import com.meloda.fast.data.account.AccountsDao
import com.meloda.fast.data.account.AccountsRepository
import com.meloda.fast.data.audios.AudiosApi
import com.meloda.fast.data.audios.AudiosRepository
import com.meloda.fast.data.auth.AuthApi
import com.meloda.fast.data.auth.AuthRepository
import com.meloda.fast.data.conversations.ConversationsApi
import com.meloda.fast.data.conversations.ConversationsDao
import com.meloda.fast.data.conversations.ConversationsRepository
import com.meloda.fast.data.files.FilesApi
import com.meloda.fast.data.files.FilesRepository
import com.meloda.fast.data.groups.GroupsDao
import com.meloda.fast.data.groups.GroupsRepository
import com.meloda.fast.data.messages.MessagesApi
import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.messages.MessagesRepository
import com.meloda.fast.data.photos.PhotosApi
import com.meloda.fast.data.photos.PhotosRepository
import com.meloda.fast.data.users.UsersApi
import com.meloda.fast.data.users.UsersDao
import com.meloda.fast.data.users.UsersRepository
import com.meloda.fast.data.videos.VideosApi
import com.meloda.fast.data.videos.VideosRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@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)
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
// 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
import androidx.room.Room
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.data.account.AccountsDao
import com.meloda.fast.data.conversations.ConversationsDao
import com.meloda.fast.data.groups.GroupsDao
import com.meloda.fast.data.messages.MessagesDao
import com.meloda.fast.data.users.UsersDao
import com.meloda.fast.database.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@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
import com.meloda.fast.database.AccountsDatabase
import com.meloda.fast.database.CacheDatabase
import org.koin.core.scope.Scope
import org.koin.dsl.module
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.Router
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import com.meloda.fast.screens.captcha.screen.CaptchaScreen
import com.meloda.fast.screens.twofa.screen.TwoFaScreen
import org.koin.core.module.dsl.singleOf
import org.koin.core.scope.Scope
import org.koin.dsl.module
@InstallIn(SingletonComponent::class)
@Module
object NavigationModule {
@Provides
@Singleton
fun getCicerone(): Cicerone<Router> = Cicerone.create()
val navigationModule = module {
single { Cicerone.create() }
single { cicerone().router }
single { cicerone().getNavigatorHolder() }
@Provides
@Singleton
fun getRouter(cicerone: Cicerone<Router>) = cicerone.router
@Provides
@Singleton
fun getNavigationHolder(cicerone: Cicerone<Router>) = cicerone.getNavigatorHolder()
singleOf(::CaptchaScreen)
singleOf(::TwoFaScreen)
}
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.ChuckerInterceptor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.meloda.fast.api.longpoll.LongPollUpdatesParser
import com.meloda.fast.api.network.AuthInterceptor
import com.meloda.fast.api.network.ResultCallFactory
import com.meloda.fast.api.network.VkUrls
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.common.UpdateManager
import com.meloda.fast.data.account.AccountApi
import com.meloda.fast.data.audios.AudiosApi
import com.meloda.fast.data.auth.AuthApi
import com.meloda.fast.data.conversations.ConversationsApi
import com.meloda.fast.data.files.FilesApi
import com.meloda.fast.data.longpoll.LongPollApi
import com.meloda.fast.data.messages.MessagesApi
import com.meloda.fast.data.messages.MessagesRepository
import com.meloda.fast.data.ota.OtaApi
import com.meloda.fast.data.photos.PhotosApi
import com.meloda.fast.data.users.UsersApi
import com.meloda.fast.data.videos.VideosApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.core.module.dsl.singleOf
import org.koin.core.scope.Scope
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object NetworkModule {
/*
val chuckerCollector = ChuckerCollector(
context = this,
// Toggles visibility of the notification
showNotification = true,
// Allows to customize the retention period of collected data
retentionPeriod = RetentionManager.Period.ONE_HOUR
)
// Create the Interceptor
val chuckerInterceptor = ChuckerInterceptor.Builder(context)
// The previously created Collector
.collector(chuckerCollector)
// The max body content length in bytes, after this responses will be truncated.
.maxContentLength(250_000L)
// List of headers to replace with ** in the Chucker UI
.redactHeaders("Auth-Token", "Bearer")
// Read the whole response body even when the client does not consume the response completely.
// This is useful in case of parsing errors or when the response body
// is closed before being read like in Retrofit with Void and Unit types.
.alwaysReadResponseBody(true)
// Use decoder when processing request and response bodies. When multiple decoders are installed they
// are applied in an order they were added.
.addBodyDecoder(decoder)
// Controls Android shortcut creation. Available in SNAPSHOTS versions only at the moment
.createShortcut(true)
.build()
*/
@Singleton
@Provides
fun provideChuckerCollector(): ChuckerCollector =
ChuckerCollector(AppGlobal.Instance)
@Singleton
@Provides
fun provideChuckerInterceptor(
chuckerCollector: ChuckerCollector
): ChuckerInterceptor =
ChuckerInterceptor.Builder(AppGlobal.Instance)
.collector(chuckerCollector)
.build()
@Singleton
@Provides
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
chuckerInterceptor: ChuckerInterceptor
): OkHttpClient =
val networkModule = module {
single { ChuckerCollector(get()) }
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
singleOf(::AuthInterceptor)
single { GsonBuilder().setLenient().create() }
single {
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(authInterceptor)
.addInterceptor(chuckerInterceptor)
.addInterceptor(authInterceptor())
.addInterceptor(
chuckerInterceptor().apply {
redactHeader("Secret-Code")
}
)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(
@@ -99,92 +37,17 @@ val chuckerInterceptor = ChuckerInterceptor.Builder(context)
level = HttpLoggingInterceptor.Level.BODY
}
).build()
@Singleton
@Provides
fun provideGson(): Gson = GsonBuilder()
.setLenient()
.create()
@Singleton
@Provides
fun provideRetrofit(
client: OkHttpClient,
gson: Gson
): Retrofit = Retrofit.Builder()
.baseUrl("${VkUrls.API}/")
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(ResultCallFactory())
.client(client)
.build()
@Provides
@Singleton
fun provideAuthInterceptor(): AuthInterceptor = AuthInterceptor()
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi =
retrofit.create(AuthApi::class.java)
@Provides
@Singleton
fun provideConversationsApi(retrofit: Retrofit): ConversationsApi =
retrofit.create(ConversationsApi::class.java)
@Provides
@Singleton
fun provideUsersApi(retrofit: Retrofit): UsersApi =
retrofit.create(UsersApi::class.java)
@Provides
@Singleton
fun provideMessagesApi(retrofit: Retrofit): MessagesApi =
retrofit.create(MessagesApi::class.java)
@Provides
@Singleton
fun provideLongPollApi(retrofit: Retrofit): LongPollApi =
retrofit.create(LongPollApi::class.java)
@Provides
@Singleton
fun provideLongPollUpdatesParser(messagesRepository: MessagesRepository): LongPollUpdatesParser =
LongPollUpdatesParser(messagesRepository)
@Provides
@Singleton
fun provideAccountApi(retrofit: Retrofit): AccountApi =
retrofit.create(AccountApi::class.java)
@Provides
@Singleton
fun provideOtaApi(retrofit: Retrofit): OtaApi =
retrofit.create(OtaApi::class.java)
@Provides
@Singleton
fun provideUpdateManager(otaApi: OtaApi): UpdateManager =
UpdateManager(otaApi)
@Provides
@Singleton
fun providePhotosApi(retrofit: Retrofit): PhotosApi =
retrofit.create(PhotosApi::class.java)
@Provides
@Singleton
fun provideVideosApi(retrofit: Retrofit): VideosApi =
retrofit.create(VideosApi::class.java)
@Provides
@Singleton
fun provideAudiosApi(retrofit: Retrofit): AudiosApi =
retrofit.create(AudiosApi::class.java)
@Provides
@Singleton
fun provideFilesApi(retrofit: Retrofit): FilesApi =
retrofit.create(FilesApi::class.java)
}
single {
Retrofit.Builder()
.baseUrl("${VkUrls.API}/")
.addConverterFactory(GsonConverterFactory.create(get()))
.addCallAdapterFactory(ResultCallFactory(get()))
.client(get())
.build()
}
}
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.drawable.ColorDrawable
@@ -27,71 +27,85 @@ object ImageLoader {
this.setImageDrawable(null)
}
fun ImageView.loadWithGlide(
url: String? = null,
uri: Uri? = null,
drawableRes: Int? = null,
drawable: Drawable? = null,
placeholderDrawable: Drawable? = null,
placeholderColor: Int? = null,
errorDrawable: Drawable? = placeholderDrawable,
errorColor: Int? = null,
crossFade: Boolean = false,
crossFadeDuration: Int? = null,
asCircle: Boolean = false,
transformations: List<TypeTransformations> = emptyList(),
onLoadedAction: (() -> Unit)? = null,
onFailedAction: (() -> Unit)? = null,
priority: Priority = Priority.NORMAL,
cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL
) {
fun ImageView.loadWithGlide(block: GlideParams.() -> Unit) {
val params = GlideParams()
block.invoke(params)
loadWithGlide(params)
}
fun ImageView.loadWithGlide(params: GlideParams) {
val request = Glide.with(this)
var builder = when {
url != null -> request.load(url)
uri != null -> request.load(uri)
drawableRes != null -> request.load(drawableRes)
params.imageUrl != null -> request.load(params.imageUrl)
params.imageUri != null -> request.load(params.imageUri)
params.drawableRes != null -> request.load(params.drawableRes)
drawable != null -> request.load(drawable)
else -> request.load(null as Drawable?)
}
val transforms = transformations.toMutableList()
if (asCircle) {
val transforms = params.transformations.toMutableList()
if (params.asCircle) {
transforms += TypeTransformations.CircleCrop
}
builder = builder
.apply(TypeTransformations.createRequestOptions(transforms))
.error(
errorDrawable
?: if (errorColor != null) ColorDrawable(errorColor) else null
params.errorDrawable
?: if (params.errorColor != null) {
ColorDrawable(requireNotNull(params.errorColor))
} else null
)
.placeholder(
placeholderDrawable
?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null
params.placeholderDrawable
?: if (params.placeholderColor != null) {
ColorDrawable(requireNotNull(params.placeholderColor))
} else null
)
.addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction))
.diskCacheStrategy(cacheStrategy)
.priority(priority)
.addListener(ImageLoadRequestListener(params.onLoadedAction, params.onFailedAction))
.addListener(ImageLoadDoneListener(params.onDoneAction))
.diskCacheStrategy(params.cacheStrategy)
.priority(params.loadPriority)
if (crossFade || crossFadeDuration != null) {
builder = builder.transition(withCrossFade(crossFadeDuration ?: 200))
if (params.crossFade || params.crossFadeDuration != null) {
builder = builder.transition(withCrossFade(params.crossFadeDuration ?: 200))
}
builder.into(this)
}
}
data class GlideParams(
var imageUrl: String? = null,
var imageUri: Uri? = null,
var drawableRes: Int? = null,
var imageDrawable: Drawable? = null,
var placeholderDrawable: Drawable? = null,
var placeholderColor: Int? = null,
var errorDrawable: Drawable? = placeholderDrawable,
var errorColor: Int? = null,
var crossFade: Boolean = false,
var crossFadeDuration: Int? = null,
var asCircle: Boolean = false,
var transformations: List<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(
private val onLoadedAction: (() -> Unit)?,
private val onFailedAction: (() -> Unit)?
private val onFailedAction: (() -> Unit)?,
) : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
isFirstResource: Boolean,
): Boolean {
onFailedAction?.invoke()
return false
@@ -102,13 +116,36 @@ class ImageLoadRequestListener(
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
isFirstResource: Boolean,
): Boolean {
onLoadedAction?.invoke()
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 {
object CenterCrop : TypeTransformations()
@@ -123,7 +160,7 @@ sealed class TypeTransformations {
val topLeft: Float,
val topRight: Float,
val bottomRight: Float,
val bottomLeft: Float
val bottomLeft: Float,
) : TypeTransformations()
fun toGlideTransform(): Transformation<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
@Parcelize
open class SelectableItem constructor(
@Ignore
val selectableItemId: Int = 0
) : DataItem<Int>(), Parcelable {
open class SelectableItem : Parcelable {
@Ignore
@IgnoredOnParcel
var isSelected: Boolean = false
@Ignore
@IgnoredOnParcel
override val dataItemId = selectableItemId
}
@@ -18,7 +18,7 @@ data class UpdateItem(
val originalName: String,
val fileSize: Int,
val preRelease: Int,
val downloadLink: String
val downloadLink: String,
) : Parcelable {
fun isMandatory(): Boolean = mandatory == 1
@@ -29,6 +29,24 @@ data class UpdateItem(
return Gson().toJson(this)
}
companion object {
val EMPTY = UpdateItem(
id = 0,
versionName = "1.0.0",
versionCode = 2,
mandatory = 1,
changelog = "Some kind of simple changelog",
enabled = 1,
fileName = "bruhmeme.apk",
date = System.currentTimeMillis(),
extension = "",
originalName = "",
fileSize = 0,
preRelease = 0,
downloadLink = "https://c4.kemono.party/data/98/8c/988cf166f1ee9cd318e2407e6bfbabf60bffa53ed229ea0b2434009f1598e039.png?f=JessieGym002b4pt.png"
)
}
}
@Parcelize
@@ -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