diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf381ee8..f3fa953a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,12 +1,17 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -val login: String = gradleLocalProperties(rootDir).getProperty("vkLogin") -val password: String = gradleLocalProperties(rootDir).getProperty("vkPassword") +import com.android.build.gradle.internal.api.BaseVariantOutputImpl val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage") val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint") +val msAppCenterToken: String = + gradleLocalProperties(rootDir).getProperty("msAppCenterAppToken", null) +val otaSecretCode: String = gradleLocalProperties(rootDir).getProperty("otaSecretCode") + +val majorVersion = 1 +val minorVersion = 6 +val patchVersion = 4 + plugins { id("com.android.application") id("kotlin-android") @@ -16,15 +21,23 @@ plugins { } android { - compileSdk = 31 - buildToolsVersion = "31.0.0" + namespace = "com.meloda.fast" + + compileSdk = 32 + + applicationVariants.all { + outputs.all { + (this as BaseVariantOutputImpl).outputFileName = + "${name}-${versionName}-${versionCode}.apk" + } + } defaultConfig { applicationId = "com.meloda.fast" minSdk = 23 - targetSdk = 30 + targetSdk = 32 versionCode = 1 - versionName = "1.0" + versionName = "alpha" javaCompileOptions { annotationProcessorOptions { @@ -35,24 +48,27 @@ android { buildTypes { getByName("debug") { - buildConfigField("String", "vkLogin", login) - buildConfigField("String", "vkPassword", password) - buildConfigField("String", "sdkPackage", sdkPackage) buildConfigField("String", "sdkFingerprint", sdkFingerprint) + + buildConfigField("String", "msAppCenterAppToken", msAppCenterToken) + + buildConfigField("String", "otaSecretCode", otaSecretCode) + + versionNameSuffix = "_${getVersionName()}" } getByName("release") { isMinifyEnabled = false - buildConfigField("String", "vkLogin", login) - buildConfigField("String", "vkPassword", password) - buildConfigField("String", "sdkPackage", sdkPackage) buildConfigField("String", "sdkFingerprint", sdkFingerprint) + buildConfigField("String", "msAppCenterAppToken", msAppCenterToken) + + buildConfigField("String", "otaSecretCode", otaSecretCode) + proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } @@ -62,74 +78,88 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } + kotlinOptions { + freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn") + } + buildFeatures { viewBinding = true } } -kapt { - correctErrorTypes = true +fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion" - //use this shit if you don't want have hilt errors - javacOptions { - option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true") - } -} +val currentTime get() = (System.currentTimeMillis() / 1000).toInt() dependencies { - // Cicerone - Navigation - implementation("com.github.terrakok:cicerone:7.1") + implementation(kotlin("reflect", "1.6.10")) - implementation("androidx.constraintlayout:constraintlayout:2.1.3") + implementation(libs.androidx.core) - implementation("com.github.massoudss:waveformSeekBar:3.1.0") + 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) - implementation("androidx.core:core-splashscreen:1.0.0-beta02") + implementation(libs.androidx.splashScreen) - implementation("androidx.work:work-runtime-ktx:2.7.1") + implementation(libs.androidx.dataStore) - implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation(libs.androidx.appCompat) - implementation("androidx.paging:paging-runtime-ktx:3.1.1") + implementation(libs.androidx.activity) - implementation("androidx.appcompat:appcompat:1.4.1") - implementation("com.google.android.material:material:1.6.0-beta01") - implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01") - implementation("androidx.recyclerview:recyclerview:1.2.1") - implementation("androidx.cardview:cardview:1.0.0") - implementation("androidx.fragment:fragment-ktx:1.4.1") + implementation(libs.androidx.fragment) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") + implementation(libs.androidx.preference) - implementation("androidx.room:room-ktx:2.4.2") - implementation("androidx.room:room-runtime:2.4.2") - kapt("androidx.room:room-compiler:2.4.2") + implementation(libs.androidx.swipeRefreshLayout) - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.1") - implementation("androidx.lifecycle:lifecycle-common-java8:2.4.1") + implementation(libs.androidx.recyclerView) - implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.2") - implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2") - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation(libs.androidx.cardView) - implementation("com.google.dagger:hilt-android:2.39.1") - kapt("com.google.dagger:hilt-android-compiler:2.39.1") + implementation(libs.androidx.constraintLayout) - implementation("com.github.yogacp:android-viewbinding:1.0.4") + implementation(libs.androidx.room) + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) - implementation("io.coil-kt:coil:1.4.0") + implementation(libs.cicerone) - implementation("com.google.code.gson:gson:2.8.8") - implementation("org.jsoup:jsoup:1.14.3") - implementation("ch.acra:acra:4.11.1") + implementation(libs.waveformSeekBar) - implementation("com.github.bumptech.glide:glide:4.13.0") - kapt("com.github.bumptech.glide:compiler:4.13.0") + implementation(libs.glide) + kapt(libs.glide.compiler) + + implementation(libs.kPermissions) + implementation(libs.kPermissions.coroutines) + + implementation(libs.appCenter.analytics) + implementation(libs.appCenter.crashes) + + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + implementation(libs.retrofit) + implementation(libs.retrofit.gson.converter) + + implementation(libs.okhttp3) + implementation(libs.okhttp3.interceptor) + + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + implementation(libs.viewBindingDelegate) + + implementation(libs.google.gson) + + implementation(libs.google.guava) + + implementation(libs.google.material) + + implementation(libs.jsoup) + + implementation(libs.chucker) } \ No newline at end of file diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/33.json b/app/schemas/com.meloda.fast.database.AppDatabase/33.json new file mode 100644 index 00000000..135b8b4d --- /dev/null +++ b/app/schemas/com.meloda.fast.database.AppDatabase/33.json @@ -0,0 +1,582 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "ab075cc511743c47de441d484159b088", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fastToken", + "columnName": "fastToken", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callInProgress", + "columnName": "callInProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPhantom", + "columnName": "isPhantom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConversationMessageId", + "columnName": "lastConversationMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inRead", + "columnName": "inRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outRead", + "columnName": "outRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedUnread", + "columnName": "isMarkedUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "canChangePin", + "columnName": "canChangePin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "majorId", + "columnName": "majorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minorId", + "columnName": "minorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedMessage.id", + "columnName": "pinnedMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.text", + "columnName": "pinnedMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.isOut", + "columnName": "pinnedMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.peerId", + "columnName": "pinnedMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.fromId", + "columnName": "pinnedMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.date", + "columnName": "pinnedMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.randomId", + "columnName": "pinnedMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.action", + "columnName": "pinnedMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMemberId", + "columnName": "pinnedMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionText", + "columnName": "pinnedMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionConversationMessageId", + "columnName": "pinnedMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMessage", + "columnName": "pinnedMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.important", + "columnName": "pinnedMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.forwards", + "columnName": "pinnedMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.attachments", + "columnName": "pinnedMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.replyMessage", + "columnName": "pinnedMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.geo", + "columnName": "pinnedMessage_geo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.id", + "columnName": "lastMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.text", + "columnName": "lastMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.isOut", + "columnName": "lastMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.peerId", + "columnName": "lastMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.fromId", + "columnName": "lastMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.date", + "columnName": "lastMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.randomId", + "columnName": "lastMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.action", + "columnName": "lastMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMemberId", + "columnName": "lastMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionText", + "columnName": "lastMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionConversationMessageId", + "columnName": "lastMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMessage", + "columnName": "lastMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.important", + "columnName": "lastMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.forwards", + "columnName": "lastMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.attachments", + "columnName": "lastMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.replyMessage", + "columnName": "lastMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.geo", + "columnName": "lastMessage_geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isOut", + "columnName": "isOut", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromId", + "columnName": "fromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionMemberId", + "columnName": "actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionText", + "columnName": "actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionConversationMessageId", + "columnName": "actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionMessage", + "columnName": "actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "important", + "columnName": "important", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwards", + "columnName": "forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyMessage", + "columnName": "replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "geo", + "columnName": "geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "online", + "columnName": "online", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeenStatus", + "columnName": "lastSeenStatus", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "screenName", + "columnName": "screenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab075cc511743c47de441d484159b088')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/34.json b/app/schemas/com.meloda.fast.database.AppDatabase/34.json new file mode 100644 index 00000000..52e135b7 --- /dev/null +++ b/app/schemas/com.meloda.fast.database.AppDatabase/34.json @@ -0,0 +1,600 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "2c202b1fce1b5f6c6ab0da756e0590a6", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fastToken", + "columnName": "fastToken", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_updateTime` INTEGER, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_updateTime` INTEGER, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callInProgress", + "columnName": "callInProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPhantom", + "columnName": "isPhantom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConversationMessageId", + "columnName": "lastConversationMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inRead", + "columnName": "inRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outRead", + "columnName": "outRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedUnread", + "columnName": "isMarkedUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "canChangePin", + "columnName": "canChangePin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "majorId", + "columnName": "majorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minorId", + "columnName": "minorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedMessage.id", + "columnName": "pinnedMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.text", + "columnName": "pinnedMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.isOut", + "columnName": "pinnedMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.peerId", + "columnName": "pinnedMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.fromId", + "columnName": "pinnedMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.date", + "columnName": "pinnedMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.randomId", + "columnName": "pinnedMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.action", + "columnName": "pinnedMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMemberId", + "columnName": "pinnedMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionText", + "columnName": "pinnedMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionConversationMessageId", + "columnName": "pinnedMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMessage", + "columnName": "pinnedMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.updateTime", + "columnName": "pinnedMessage_updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.important", + "columnName": "pinnedMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.forwards", + "columnName": "pinnedMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.attachments", + "columnName": "pinnedMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.replyMessage", + "columnName": "pinnedMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.geo", + "columnName": "pinnedMessage_geo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.id", + "columnName": "lastMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.text", + "columnName": "lastMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.isOut", + "columnName": "lastMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.peerId", + "columnName": "lastMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.fromId", + "columnName": "lastMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.date", + "columnName": "lastMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.randomId", + "columnName": "lastMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.action", + "columnName": "lastMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMemberId", + "columnName": "lastMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionText", + "columnName": "lastMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionConversationMessageId", + "columnName": "lastMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMessage", + "columnName": "lastMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.updateTime", + "columnName": "lastMessage_updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.important", + "columnName": "lastMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.forwards", + "columnName": "lastMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.attachments", + "columnName": "lastMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.replyMessage", + "columnName": "lastMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.geo", + "columnName": "lastMessage_geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isOut", + "columnName": "isOut", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromId", + "columnName": "fromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionMemberId", + "columnName": "actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionText", + "columnName": "actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionConversationMessageId", + "columnName": "actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionMessage", + "columnName": "actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updateTime", + "columnName": "updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "important", + "columnName": "important", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwards", + "columnName": "forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyMessage", + "columnName": "replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "geo", + "columnName": "geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "online", + "columnName": "online", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeenStatus", + "columnName": "lastSeenStatus", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "screenName", + "columnName": "screenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2c202b1fce1b5f6c6ab0da756e0590a6')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c427ab88..a388ef3c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,18 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + android:usesCleartextTraffic="true" + tools:replace="android:allowBackup" + tools:ignore="DataExtractionRules"> + @@ -29,7 +41,12 @@ + + diff --git a/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt deleted file mode 100644 index 86bf5af8..00000000 --- a/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.meloda.fast.activity - -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import com.github.terrakok.cicerone.NavigatorHolder -import com.github.terrakok.cicerone.Router -import com.github.terrakok.cicerone.androidx.AppNavigator -import com.github.terrakok.cicerone.androidx.FragmentScreen -import com.meloda.fast.R -import com.meloda.fast.base.BaseActivity -import com.meloda.fast.common.Screens -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class MainActivity : BaseActivity(R.layout.activity_main) { - - private val navigator = object : AppNavigator(this, R.id.root_fragment_container) { - override fun setupFragmentTransaction( - screen: FragmentScreen, - fragmentTransaction: FragmentTransaction, - currentFragment: Fragment?, - nextFragment: Fragment - ) { -// fragmentTransaction.setCustomAnimations( -// R.anim.activity_open_enter, R.anim.activity_close_exit, -// R.anim.activity_close_enter, R.anim.activity_open_exit -// ) - } - } - - @Inject - lateinit var navigatorHolder: NavigatorHolder - - @Inject - lateinit var router: Router - - override fun onResumeFragments() { - navigatorHolder.setNavigator(navigator) - super.onResumeFragments() - } - - override fun onPause() { - navigatorHolder.removeNavigator() - super.onPause() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - router.newRootScreen(Screens.Main()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt index 06d942e6..cee1e1b5 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt @@ -1,24 +1,24 @@ package com.meloda.fast.api enum class ApiEvent(val value: Int) { - MESSAGE_SET_FLAGS(2), - MESSAGE_CLEAR_FLAGS(3), - MESSAGE_NEW(4), - MESSAGE_EDIT(5), - MESSAGE_READ_INCOMING(6), - MESSAGE_READ_OUTGOING(7), - FRIEND_ONLINE(8), - FRIEND_OFFLINE(9), - MESSAGES_DELETED(13), - PIN_UNPIN_CONVERSATION(20), - PRIVATE_TYPING(61), - CHAT_TYPING(62), - ONE_MORE_TYPING(63), - VOICE_RECORDING(64), - PHOTO_UPLOADING(65), - VIDEO_UPLOADING(66), - FILE_UPLOADING(67), - UNREAD_COUNT_UPDATE(80) + MessageSetFlags(2), + MessageClearFlags(3), + MessageNew(4), + MessageEdit(5), + MessageReadIncoming(6), + MessageReadOutgoing(7), + FriendOnline(8), + FriendOffline(9), + MessagesDeleted(13), + PinUnpinConversation(20), + PrivateTyping(61), + ChatTyping(62), + OneMoreTyping(63), + VoiceRecording(64), + PhotoUploading(65), + VideoUploading(66), + FileUploading(67), + UnreadCountUpdate(80) ; companion object { diff --git a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt index 402862de..d693636a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt @@ -1,45 +1,46 @@ 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 object UserConfig { - private const val FAST_TOKEN = "fast_token" - private const val TOKEN = "token" - private const val USER_ID = "user_id" + private const val ARG_CURRENT_USER_ID = "current_user_id" const val FAST_APP_ID = "6964679" + private val preferences get() = AppGlobal.preferences + + var currentUserId: Int = -1 + get() = preferences.getInt(ARG_CURRENT_USER_ID, -1) + set(value) { + field = value + preferences.edit { putInt(ARG_CURRENT_USER_ID, value) } + } + var userId: Int = -1 - get() = AppGlobal.preferences.getInt(USER_ID, -1) - set(value) { - field = value - AppGlobal.preferences.edit().putInt(USER_ID, value).apply() - } - var accessToken: String = "" - get() = AppGlobal.preferences.getString(TOKEN, "") ?: "" - set(value) { - field = value - AppGlobal.preferences.edit().putString(TOKEN, value).apply() - } + var fastToken: String? = "" - var fastToken: String = "" - get() = AppGlobal.preferences.getString(FAST_TOKEN, "") ?: "" - set(value) { - field = value - AppGlobal.preferences.edit().putString(FAST_TOKEN, value).apply() - } + fun parse(account: AppAccount) { + this.userId = account.userId + this.accessToken = account.accessToken + this.fastToken = account.fastToken + } fun clear() { + currentUserId = -1 accessToken = "" fastToken = "" userId = -1 } - fun isLoggedIn() = userId > 0 && accessToken.isNotBlank() + fun isLoggedIn(): Boolean { + return currentUserId > 0 && userId > 0 && accessToken.isNotBlank() + } val vkUser = MutableLiveData(null) diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt index 325148f8..3f8dd237 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt @@ -12,7 +12,7 @@ object VKConstants { const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS" - const val API_VERSION = "5.132" + const val API_VERSION = "5.189" const val LP_VERSION = 10 const val VK_APP_ID = "2274003" @@ -53,22 +53,4 @@ object VKConstants { VkVoiceMessage::class.java, VkWidget::class.java ) - - val separatedFromTextAttachments = listOf>( - VkPhoto::class.java, - VkVideo::class.java, - VkSticker::class.java, - VkStory::class.java, - VkWidget::class.java, - VkGroupCall::class.java, - VkGroupCall::class.java, - VkCurator::class.java, - VkEvent::class.java, - VkGift::class.java, - VkGraffiti::class.java, - VkPoll::class.java, - VkWall::class.java, - VkWallReply::class.java, - VkLink::class.java - ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKException.kt b/app/src/main/kotlin/com/meloda/fast/api/VKException.kt deleted file mode 100644 index 353c8520..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/VKException.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.meloda.fast.api - -import org.json.JSONObject -import java.io.IOException - -open class VKException( - var url: String = "", - var code: Int = -1, - var description: String = "", - var error: String -) : IOException(description) { - - // TODO: 10-Oct-21 remove this - var json: JSONObject? = null - - override fun toString(): String { - return "error: $error; description: $description;" - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt index e357eb5d..0beda05a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt @@ -6,7 +6,9 @@ import android.graphics.drawable.Drawable import android.text.SpannableString import android.text.style.StyleSpan import androidx.core.content.ContextCompat +import com.google.gson.Gson import com.meloda.fast.R +import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage @@ -14,7 +16,10 @@ import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem +import com.meloda.fast.api.network.* +import com.meloda.fast.extensions.orDots +@Suppress("MemberVisibilityCanBePrivate") object VkUtils { fun attachmentToString( @@ -44,12 +49,12 @@ object VkUtils { fun getMessageUser(message: VkMessage, profiles: Map): VkUser? { return (if (!message.isUser()) null - else profiles[message.fromId]).also { message.user.value = it } + else profiles[message.fromId]).also { message.user = it } } fun getMessageGroup(message: VkMessage, groups: Map): VkGroup? { return (if (!message.isGroup()) null - else groups[message.fromId]).also { message.group.value = it } + else groups[message.fromId]).also { message.group = it } } fun getMessageAvatar( @@ -66,9 +71,19 @@ object VkUtils { fun getMessageTitle( message: VkMessage, - messageUser: VkUser?, - messageGroup: VkGroup? + defMessageUser: VkUser? = null, + defMessageGroup: VkGroup? = null, + profiles: Map? = null, + groups: Map? = null ): String? { + val messageUser: VkUser? = + defMessageUser ?: if (profiles == null) null + else profiles[message.fromId] + + val messageGroup: VkGroup? = + defMessageGroup ?: if (groups == null) null + else groups[message.fromId] + return when { message.isUser() -> messageUser?.fullName message.isGroup() -> messageGroup?.name @@ -78,12 +93,12 @@ object VkUtils { fun getConversationUser(conversation: VkConversation, profiles: Map): VkUser? { return (if (!conversation.isUser()) null - else profiles[conversation.id]).also { conversation.user.value = it } + else profiles[conversation.id]).also { conversation.user.postValue(it) } } fun getConversationGroup(conversation: VkConversation, groups: Map): VkGroup? { return (if (!conversation.isGroup()) null - else groups[conversation.id]).also { conversation.group.value = it } + else groups[conversation.id]).also { conversation.group.postValue(it) } } fun getConversationAvatar( @@ -92,7 +107,7 @@ object VkUtils { conversationGroup: VkGroup? ): String? { return when { - conversation.ownerId == VKConstants.FAST_GROUP_ID -> null + conversation.isAccount() -> null conversation.isUser() -> conversationUser?.photo200 conversation.isGroup() -> conversationGroup?.photo200 conversation.isChat() -> conversation.photo200 @@ -100,6 +115,53 @@ object VkUtils { } } + fun getConversationTitle( + context: Context, + conversation: VkConversation, + defConversationUser: VkUser? = null, + defConversationGroup: VkGroup? = null, + profiles: Map? = null, + groups: Map? = null + ): String? { + val conversationUser: VkUser? = + defConversationUser ?: if (profiles == null) null + else getConversationUser(conversation, profiles) + + val conversationGroup: VkGroup? = + defConversationGroup ?: if (groups == null) null + else getConversationGroup(conversation, groups) + + return when { + conversation.isAccount() -> context.getString(R.string.favorites) + conversation.isChat() -> conversation.title + conversation.isUser() -> conversationUser?.fullName + conversation.isGroup() -> conversationGroup?.name + else -> null + } + } + + fun getConversationUserGroup( + conversation: VkConversation, + profiles: Map, + groups: Map + ): Pair { + val user: VkUser? = getConversationUser(conversation, profiles) + val group: VkGroup? = getConversationGroup(conversation, groups) + + return user to group + } + + fun getMessageUserGroup( + message: VkMessage, + profiles: Map, + groups: Map + ): Pair { + val user: VkUser? = getMessageUser(message, profiles) + val group: VkGroup? = getMessageGroup(message, groups) + + return user to group + } + fun prepareMessageText(text: String, forConversations: Boolean? = null): String { return text.apply { if (forConversations == true) replace("\n", "") @@ -231,6 +293,7 @@ object VkUtils { messageUser: VkUser? = null, messageGroup: VkGroup? = null ): SpannableString? { + @Suppress("REDUNDANT_ELSE_IN_WHEN") return when (message.getPreparedAction()) { VkMessage.Action.CHAT_CREATE -> { val text = message.actionText ?: return null @@ -245,12 +308,14 @@ object VkUtils { val spanText = context.getString(R.string.message_action_chat_created, prefix, text) + val startIndex = spanText.indexOf(text, startIndex = prefix.length) + SpannableString(spanText).also { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) it.setSpan( StyleSpan(Typeface.BOLD), - spanText.indexOf(text, startIndex = prefix.length), - text.length, 0 + startIndex, + startIndex + text.length, 0 ) } } @@ -329,7 +394,7 @@ object VkUtils { } else { val prefix = if (message.fromId == UserConfig.userId) youPrefix - else messageUser?.toString() ?: messageGroup?.toString() ?: "..." + else messageUser?.toString() ?: messageGroup?.toString().orDots() val postfix = if (memberId == UserConfig.userId) youPrefix.lowercase() @@ -374,7 +439,7 @@ object VkUtils { } } else { val prefix = if (message.fromId == UserConfig.userId) youPrefix - else messageUser?.toString() ?: messageGroup?.toString() ?: "..." + else messageUser?.toString() ?: messageGroup?.toString().orDots() val postfix = if (memberId == UserConfig.userId) youPrefix.lowercase() @@ -410,6 +475,20 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + val spanText = + context.getString(R.string.message_action_chat_user_joined_by_call, prefix) + + SpannableString(spanText).also { + it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) + } + } VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -520,8 +599,8 @@ object VkUtils { } fun getAttachmentText(context: Context, message: VkMessage): String? { - message.geoType?.let { - return when (it) { + message.geo?.let { + return when (it.type) { "point" -> context.getString(R.string.message_geo_point) else -> context.getString(R.string.message_geo) } @@ -551,14 +630,14 @@ object VkUtils { } fun getAttachmentConversationIcon(context: Context, message: VkMessage): Drawable? { - message.geoType?.let { - return ContextCompat.getDrawable(context, R.drawable.ic_map_marker) - } - - if (message.attachments.isNullOrEmpty()) return null - return message.attachments?.let { attachments -> if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) { + message.geo?.let { + return ContextCompat.getDrawable(context, R.drawable.ic_map_marker) + } + + if (attachments.isEmpty()) return null + getAttachmentTypeByClass(attachments[0])?.let { getAttachmentIconByType( context, @@ -683,4 +762,37 @@ object VkUtils { else -> attachmentType.value } } + + fun getApiError(gson: Gson, errorString: String?): ApiAnswer.Error { + try { + val defaultError = gson.fromJson(errorString, ApiError::class.java) + + val error: ApiError = + when (defaultError.error) { + VkErrorCodes.UserAuthorizationFailed.toString() -> { + val authorizationError = + gson.fromJson(errorString, AuthorizationError::class.java) + + authorizationError + } + VkErrors.NeedValidation -> { + val validationError = + gson.fromJson(errorString, ValidationRequiredError::class.java) + + validationError + } + VkErrors.NeedCaptcha -> { + val captchaRequiredError = + gson.fromJson(errorString, CaptchaRequiredError::class.java) + + captchaRequiredError + } + else -> defaultError + } + + return ApiAnswer.Error(error) + } catch (e: Exception) { + return ApiAnswer.Error(ApiError(throwable = e)) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt index 5161ae6d..677a22bc 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt @@ -1,11 +1,18 @@ package com.meloda.fast.api.base +import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import com.meloda.fast.api.VKException +import okio.IOException -data class ApiError( - @SerializedName("error_code") - val errorCode: Int, - @SerializedName("error_msg") - override var message: String -) : VKException(error = message, code = errorCode) +open class ApiError( + @SerializedName("error", alternate = ["error_code"]) + val error: String? = null, + @SerializedName("error_msg", alternate = ["error_description"]) + open val errorMessage: String? = null, + val throwable: Throwable? = null +) : IOException() { + + override fun toString(): String { + return Gson().toJson(this) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt similarity index 94% rename from app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt rename to app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt index b10257c0..2d117e92 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.api +package com.meloda.fast.api.longpoll import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage diff --git a/app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt rename to app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt index 8889e4d8..f419b3de 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt @@ -1,31 +1,27 @@ -package com.meloda.fast.api +package com.meloda.fast.api.longpoll import android.util.Log import com.google.gson.JsonArray +import com.meloda.fast.api.ApiEvent +import com.meloda.fast.api.VKConstants import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.network.Answer -import com.meloda.fast.api.network.messages.MessagesDataSource +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 kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -@Suppress("UNCHECKED_CAST") -class LongPollUpdatesParser( - private val messagesDataSource: MessagesDataSource -) : CoroutineScope { - - companion object { - private const val TAG = "LongPollUpdatesParser" - } +@Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") +class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) : CoroutineScope { private val job = SupervisorJob() private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.d(TAG, "error: $throwable") + Log.d("LongPollUpdatesParser", "error: $throwable") throwable.printStackTrace() } @@ -36,51 +32,51 @@ class LongPollUpdatesParser( mutableMapOf() fun parseNextUpdate(event: JsonArray) { - val eventType: ApiEvent? = - try { - ApiEvent.parse(event[0].asInt) - } catch (e: Exception) { - null - } + val eventId = event[0].asInt + val eventType: ApiEvent? = ApiEvent.parse(eventId) - if (eventType != null) { - println("$TAG: $eventType: $event") - } else { - println("$TAG: unknown event: $event") + if (eventType == null) { + Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") + return } when (eventType) { - ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) - ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) - ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) - ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event) - ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event) - ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event) - ApiEvent.FRIEND_ONLINE -> parseFriendOnline(eventType, event) - ApiEvent.FRIEND_OFFLINE -> parseFriendOffline(eventType, event) - ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event) -// ApiEvent.PIN_UNPIN_CONVERSATION -> TODO() -// ApiEvent.TYPING -> TODO() -// ApiEvent.VOICE_RECORDING -> TODO() -// ApiEvent.PHOTO_UPLOADING -> TODO() -// ApiEvent.VIDEO_UPLOADING -> TODO() -// ApiEvent.FILE_UPLOADING -> TODO() -// ApiEvent.UNREAD_COUNT_UPDATE -> TODO() + ApiEvent.MessageSetFlags -> parseMessageSetFlags(eventType, event) + ApiEvent.MessageClearFlags -> parseMessageClearFlags(eventType, event) + ApiEvent.MessageNew -> parseMessageNew(eventType, event) + 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.PrivateTyping -> onNewEvent(eventType, event) + ApiEvent.ChatTyping -> onNewEvent(eventType, event) + ApiEvent.OneMoreTyping -> onNewEvent(eventType, event) + ApiEvent.VoiceRecording -> onNewEvent(eventType, event) + ApiEvent.PhotoUploading -> onNewEvent(eventType, event) + ApiEvent.VideoUploading -> onNewEvent(eventType, event) + ApiEvent.FileUploading -> onNewEvent(eventType, event) + ApiEvent.UnreadCountUpdate -> onNewEvent(eventType, event) } } + private fun onNewEvent(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event") + } + private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessageClearFlags(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessageNew(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") - + Log.d("LongPollUpdatesParser", "$eventType: $event") val messageId = event[1].asInt launch { @@ -90,7 +86,7 @@ class LongPollUpdatesParser( messageId ) - listenersMap[ApiEvent.MESSAGE_NEW]?.let { + listenersMap[ApiEvent.MessageNew]?.let { it.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent(newMessageEvent) @@ -100,8 +96,7 @@ class LongPollUpdatesParser( } private fun parseMessageEdit(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") - + Log.d("LongPollUpdatesParser", "$eventType: $event") val messageId = event[1].asInt launch { @@ -111,7 +106,7 @@ class LongPollUpdatesParser( messageId ) - listenersMap[ApiEvent.MESSAGE_EDIT]?.let { + listenersMap[ApiEvent.MessageEdit]?.let { it.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent(editedMessageEvent) @@ -121,11 +116,12 @@ class LongPollUpdatesParser( } private fun parseMessageReadIncoming(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt launch { - listenersMap[ApiEvent.MESSAGE_READ_INCOMING]?.let { listeners -> + listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners -> listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent( @@ -140,11 +136,12 @@ class LongPollUpdatesParser( } private fun parseMessageReadOutgoing(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt launch { - listenersMap[ApiEvent.MESSAGE_READ_OUTGOING]?.let { listeners -> + listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners -> listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent( @@ -159,22 +156,22 @@ class LongPollUpdatesParser( } private fun parseFriendOnline(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseFriendOffline(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private suspend fun loadNormalMessage(eventType: ApiEvent, messageId: Int) = coroutineScope { suspendCoroutine { launch { - val normalMessageResponse = messagesDataSource.getById( + val normalMessageResponse = messagesRepository.getById( MessagesGetByIdRequest( messagesIds = listOf(messageId), extended = true, @@ -182,17 +179,19 @@ class LongPollUpdatesParser( ) ) - if (normalMessageResponse !is Answer.Success) { - (normalMessageResponse as Answer.Error).throwable.let { throw it } + if (!normalMessageResponse.isSuccessful()) { + normalMessageResponse.error.throwable?.run { throw this } } - val messagesResponse = normalMessageResponse.data.response ?: return@launch + val messagesResponse = + (normalMessageResponse as? ApiAnswer.Success)?.data?.response + ?: return@launch val messagesList = messagesResponse.items if (messagesList.isEmpty()) return@launch val normalMessage = messagesList[0].asVkMessage() - messagesDataSource.store(listOf(normalMessage)) + messagesRepository.store(listOf(normalMessage)) val profiles = hashMapOf() messagesResponse.profiles?.forEach { baseUser -> @@ -205,13 +204,13 @@ class LongPollUpdatesParser( } val resumeValue: LongPollEvent? = when (eventType) { - ApiEvent.MESSAGE_NEW -> + ApiEvent.MessageNew -> LongPollEvent.VkMessageNewEvent( normalMessage, profiles, groups ) - ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(normalMessage) + ApiEvent.MessageEdit -> LongPollEvent.VkMessageEditEvent(normalMessage) else -> null } @@ -221,7 +220,7 @@ class LongPollUpdatesParser( } - fun registerListener(eventType: ApiEvent, listener: VkEventCallback) { + private fun registerListener(eventType: ApiEvent, listener: VkEventCallback) { listenersMap.let { map -> map[eventType] = (map[eventType] ?: mutableListOf()).also { it.add(listener) @@ -230,7 +229,7 @@ class LongPollUpdatesParser( } fun onMessageIncomingRead(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_READ_INCOMING, listener) + registerListener(ApiEvent.MessageReadIncoming, listener) } fun onMessageIncomingRead(block: (LongPollEvent.VkMessageReadIncomingEvent) -> Unit) { @@ -238,7 +237,7 @@ class LongPollUpdatesParser( } fun onMessageOutgoingRead(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_READ_OUTGOING, listener) + registerListener(ApiEvent.MessageReadOutgoing, listener) } fun onMessageOutgoingRead(block: (LongPollEvent.VkMessageReadOutgoingEvent) -> Unit) { @@ -246,7 +245,7 @@ class LongPollUpdatesParser( } fun onNewMessage(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_NEW, listener) + registerListener(ApiEvent.MessageNew, listener) } fun onNewMessage(block: (LongPollEvent.VkMessageNewEvent) -> Unit) { @@ -254,7 +253,7 @@ class LongPollUpdatesParser( } fun onMessageEdited(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_EDIT, listener) + registerListener(ApiEvent.MessageEdit, listener) } fun onMessageEdited(block: (LongPollEvent.VkMessageEditEvent) -> Unit) { @@ -266,9 +265,7 @@ class LongPollUpdatesParser( } } -internal inline fun assembleEventCallback( - crossinline block: (R) -> Unit -): VkEventCallback { +internal inline fun assembleEventCallback(crossinline block: (R) -> Unit): VkEventCallback { return object : VkEventCallback { override fun onEvent(event: R) = block.invoke(event) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt index c46bba0d..a5c7118a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt @@ -5,6 +5,7 @@ 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 @@ -25,10 +26,11 @@ data class VkConversation( var outRead: Int, var isMarkedUnread: Boolean, var lastMessageId: Int, - var unreadCount: Int?, + var unreadCount: Int, var membersCount: Int?, - var isPinned: Boolean, var canChangePin: Boolean, + var majorId: Int, + var minorId: Int, @Embedded(prefix = "pinnedMessage_") var pinnedMessage: VkMessage? = null, @@ -49,9 +51,13 @@ data class VkConversation( fun isUser() = type == "user" fun isGroup() = type == "group" - fun isInUnread() = inRead < lastMessageId - fun isOutUnread() = outRead < lastMessageId + fun isInUnread() = inRead - lastMessageId < 0 + fun isOutUnread() = outRead - lastMessageId < 0 fun isUnread() = isInUnread() || isOutUnread() + fun isAccount() = id == UserConfig.userId + + fun isPinned() = majorId > 0 + } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt index 513c5b31..e4b8e8fc 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt @@ -1,12 +1,12 @@ package com.meloda.fast.api.model -import androidx.lifecycle.MutableLiveData import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey 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.model.SelectableItem import com.meloda.fast.util.TimeUtils import kotlinx.parcelize.IgnoredOnParcel @@ -14,7 +14,7 @@ import kotlinx.parcelize.Parcelize @Entity(tableName = "messages") @Parcelize -data class VkMessage( +data class VkMessage constructor( @PrimaryKey(autoGenerate = false) var id: Int, var text: String? = null, @@ -28,21 +28,29 @@ data class VkMessage( val actionText: String? = null, val actionConversationMessageId: Int? = null, val actionMessage: String? = null, - val geoType: String? = null, + + var updateTime: Int? = null, + var important: Boolean = false, var forwards: List? = null, var attachments: List? = null, - var replyMessage: VkMessage? = null + var replyMessage: VkMessage? = null, + + val geo: BaseVkMessage.Geo? = null, ) : SelectableItem(id) { @Ignore @IgnoredOnParcel - val user = MutableLiveData() + var user: VkUser? = null @Ignore @IgnoredOnParcel - val group = MutableLiveData() + var group: VkGroup? = null + + @Ignore + @IgnoredOnParcel + var state: State = State.Sent fun isPeerChat() = peerId > 2_000_000_000 @@ -51,8 +59,11 @@ data class VkMessage( fun isGroup() = fromId < 0 fun isRead(conversation: VkConversation) = - if (isOut) conversation.outRead - id >= 0 - else conversation.inRead - id >= 0 + if (isOut) { + conversation.outRead - id >= 0 + } else { + conversation.inRead - id >= 0 + } fun getPreparedAction(): Action? { if (action == null) return null @@ -61,10 +72,27 @@ data class VkMessage( fun canEdit() = fromId == UserConfig.userId && - (attachments == null || !VKConstants.restrictedToEditAttachments.contains( - attachments!![0].javaClass - )) && - (System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.ONE_DAY_IN_SECONDS) + (attachments == null || + !VKConstants.restrictedToEditAttachments.contains( + requireNotNull(attachments).first().javaClass + )) && + (System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.OneDayInSeconds) + + fun hasAttachments(): Boolean = !attachments.isNullOrEmpty() + + fun hasReply(): Boolean = replyMessage != null + + fun hasForwards(): Boolean = !forwards.isNullOrEmpty() + + fun hasGeo(): Boolean = geo != null + + fun isUpdated(): Boolean = updateTime != null && requireNotNull(updateTime) > 0 + + fun isSending(): Boolean = state == State.Sending + + fun isError(): Boolean = state == State.Error + + fun isSent(): Boolean = state == State.Sent enum class Action(val value: String) { CHAT_CREATE("chat_create"), @@ -78,14 +106,17 @@ data class VkMessage( CHAT_KICK_USER("chat_kick_user"), CHAT_SCREENSHOT("chat_screenshot"), - // TODO: 9/11/2021 catch this shit CHAT_INVITE_USER_BY_CALL("chat_invite_user_by_call"), CHAT_INVITE_USER_BY_CALL_LINK("chat_invite_user_by_call_join_link"), CHAT_STYLE_UPDATE("conversation_style_update"); companion object { - fun parse(value: String) = values().first { it.value == value } + fun parse(value: String?): Action? = values().firstOrNull { it.value == value } } } + enum class State { + Sending, Sent, Error + } + } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt index 2662b547..c8fee250 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt @@ -1,10 +1,15 @@ 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 : Parcelable { +open class VkAttachment : DataItem(), Parcelable { + + @IgnoredOnParcel + override val dataItemId: Int = -1 open fun asString(withAccessKey: Boolean = true) = "" diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt index 19e7f7b1..e1ccb61b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt @@ -1,6 +1,7 @@ package com.meloda.fast.api.model.attachments import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.model.base.attachments.BaseVkFile import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -12,7 +13,8 @@ data class VkFile( val ext: String, val size: Int, val url: String, - val accessKey: String? + val accessKey: String?, + val preview: BaseVkFile.Preview? ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt index d70f29ba..f3061a55 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt @@ -12,7 +12,8 @@ data class VkVideo( val ownerId: Int, val images: List, val firstFrames: List?, - val accessKey: String? + val accessKey: String?, + val title: String ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt index c660472e..68ae06be 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt @@ -12,12 +12,12 @@ data class VkWall( val date: Int, val text: String, val attachments: List?, - val comments: Int, - val likes: Int, - val reposts: Int, - val views: Int, + val comments: Int?, + val likes: Int?, + val reposts: Int?, + val views: Int?, val isFavorite: Boolean, - val accessKey: String + val accessKey: String? ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt index 8c7177f2..96e09aee 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt @@ -37,10 +37,11 @@ data class BaseVkConversation( outRead = out_read, isMarkedUnread = is_marked_unread, lastMessageId = last_message_id, - unreadCount = unread_count, + unreadCount = unread_count ?: 0, membersCount = chat_settings?.members_count, ownerId = chat_settings?.owner_id, - isPinned = sort_id.major_id > 0, + majorId = sort_id.major_id, + minorId = sort_id.minor_id, canChangePin = chat_settings?.acl?.can_change_pin == true ).apply { this.lastMessage = lastMessage diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt index 48d8bf61..46a5b7fa 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt @@ -24,7 +24,8 @@ data class BaseVkMessage( val geo: Geo?, val action: Action?, val ttl: Int, - val reply_message: BaseVkMessage? + val reply_message: BaseVkMessage?, + val update_time: Int? ) : Parcelable { fun asVkMessage() = VkMessage( @@ -40,8 +41,9 @@ data class BaseVkMessage( actionText = action?.text, actionConversationMessageId = action?.conversation_message_id, actionMessage = action?.message, - geoType = geo?.type, - important = important + geo = geo, + important = important, + updateTime = update_time ).also { it.attachments = VkUtils.parseAttachments(attachments) it.forwards = VkUtils.parseForwards(fwd_messages) @@ -55,7 +57,6 @@ data class BaseVkMessage( val place: Place ) : Parcelable { - @Parcelize data class Coordinates(val latitude: Float, val longitude: Float) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt index 8c09507f..edc8b96d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt @@ -27,7 +27,8 @@ data class BaseVkFile( ext = ext, url = url, size = size, - accessKey = access_key + accessKey = access_key, + preview = preview ) @Parcelize diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt index 15c3bcc4..0cce54de 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt @@ -43,7 +43,8 @@ data class BaseVkVideo( ownerId = owner_id, images = image.map { it.asVideoImage() }, firstFrames = first_frame, - accessKey = access_key + accessKey = access_key, + title = title ) @Parcelize diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt index dd831744..e1dee465 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt @@ -12,14 +12,14 @@ data class BaseVkWall( val date: Int, val text: String, val attachments: List?, - val post_source: PostSource, - val comments: Comments, - val likes: Likes, - val reposts: Reposts, - val views: Views, + val post_source: PostSource?, + val comments: Comments?, + val likes: Likes?, + val reposts: Reposts?, + val views: Views?, val is_favorite: Boolean, - val donut: Donut, - val access_key: String, + val donut: Donut?, + val access_key: String?, val short_text_rate: Double ) : Parcelable { @@ -30,10 +30,10 @@ data class BaseVkWall( date = date, text = text, attachments = attachments, - comments = comments.count, - likes = likes.count, - reposts = reposts.count, - views = views.count, + comments = comments?.count, + likes = likes?.count, + reposts = reposts?.count, + views = views?.count, isFavorite = is_favorite, accessKey = access_key ) diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt new file mode 100644 index 00000000..9b760e70 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt @@ -0,0 +1,78 @@ +package com.meloda.fast.api.network + +import com.google.gson.annotations.SerializedName +import com.meloda.fast.api.base.ApiError + +@Suppress("unused") +object VkErrorCodes { + const val UnknownError = 1 + const val AppDisabled = 2 + const val UnknownMethod = 3 + const val InvalidSignature = 4 + const val UserAuthorizationFailed = 5 + const val TooManyRequests = 6 + const val NoRights = 7 + const val BadRequest = 8 + const val TooManySimilarActions = 9 + const val InternalServerError = 10 + const val InTestMode = 11 + const val ExecuteCodeCompileError = 12 + const val ExecuteCodeRuntimeError = 13 + const val CaptchaNeeded = 14 + const val AccessDenied = 15 + const val RequiresRequestsOverHttps = 16 + const val ValidationRequired = 17 + const val UserBannedOrDeleted = 18 + const val ActionProhibited = 20 + const val ActionAllowedOnlyForStandalone = 21 + const val MethodOff = 23 + const val ConfirmationRequired = 24 + const val ParameterIsNotSpecified = 100 + const val IncorrectAppId = 101 + const val OutOfLimits = 103 + const val IncorrectUserId = 113 + const val IncorrectTimestamp = 150 + const val AccessToAlbumDenied = 200 + const val AccessToAudioDenied = 201 + const val AccessToGroupDenied = 203 + const val AlbumIsFull = 300 + const val ActionDenied = 500 + const val PermissionDenied = 600 + const val CannotSendMessageBlackList = 900 + const val CannotSendMessageGroup = 901 + const val InvalidDocId = 1150 + const val InvalidDocTitle = 1152 + const val AccessToDocDenied = 1153 +} + +@Suppress("unused") +object VkErrors { + const val Unknown = "unknown_error" + + const val NeedValidation = "need_validation" + const val NeedCaptcha = "need_captcha" + const val InvalidRequest = "invalid_request" + +} + +class AuthorizationError : ApiError() + +data class ValidationRequiredError( + @SerializedName("validation_type") + val validationType: String, + @SerializedName("validation_sid") + val validationSid: String, + @SerializedName("phone_mask") + val phoneMask: String, + @SerializedName("redirect_uri") + val redirectUri: String, + @SerializedName("validation_resend") + val validationResend: String +) : ApiError() + +data class CaptchaRequiredError( + @SerializedName("captcha_sid") + val captchaSid: String, + @SerializedName("captcha_img") + val captchaImg: String +) : ApiError() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt index 1099a9cd..1c740012 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt @@ -11,16 +11,20 @@ class AuthInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val builder = chain.request().url.newBuilder() - .addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8")) + val url = builder.build().toUrl().toString() - if (!builder.build().toUrl().toString().contains(AccountUrls.SetOnline)) + if (!url.contains("upload.php")) { + builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8")) + } + + if (!url.contains(AccountUrls.SetOnline) && !url.contains("upload.php")) { UserConfig.accessToken.let { if (it.isNotBlank()) builder.addQueryParameter("access_token", URLEncoder.encode(it, "utf-8")) } + } - // TODO: 9/29/2021 crash on timeout return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build()) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt deleted file mode 100644 index fcf53a0d..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.meloda.fast.api.network - -object VkErrorCodes { - const val UNKNOWN_ERROR = 1 - const val APP_DISABLED = 2 - const val UNKNOWN_METHOD = 3 - const val INVALID_SIGNATURE = 4 - const val USER_AUTHORIZATION_FAILED = 5 - const val TOO_MANY_REQUESTS = 6 - const val NO_RIGHTS = 7 - const val BAD_REQUEST = 8 - const val TOO_MANY_SIMILAR_ACTIONS = 9 - const val INTERNAL_SERVER_ERROR = 10 - const val IN_TEST_MODE = 11 - const val EXECUTE_CODE_COMPILE_ERROR = 12 - const val EXECUTE_CODE_RUNTIME_ERROR = 13 - const val CAPTCHA_NEEDED = 14 - const val ACCESS_DENIED = 15 - const val REQUIRES_REQUESTS_OVER_HTTPS = 16 - const val VALIDATION_REQUIRED = 17 - const val USER_BANNED_OR_DELETED = 18 - const val ACTION_PROHIBITED = 20 - const val ACTION_ALLOWED_ONLY_FOR_STANDALONE = 21 - const val METHOD_OFF = 23 - const val CONFIRMATION_REQUIRED = 24 - const val PARAMETER_IS_NOT_SPECIFIED = 100 - const val INCORRECT_APP_ID = 101 - const val OUT_OF_LIMITS = 103 - const val INCORRECT_USER_ID = 113 - const val INCORRECT_TIMESTAMP = 150 - const val ACCESS_TO_ALBUM_DENIED = 200 - const val ACCESS_TO_AUDIO_DENIED = 201 - const val ACCESS_TO_GROUP_DENIED = 203 - const val ALBUM_IS_FULL = 300 - const val ACTION_DENIED = 500 - const val PERMISSION_DENIED = 600 - const val CANNOT_SEND_MESSAGE_BLACK_LIST = 900 - const val CANNOT_SEND_MESSAGE_GROUP = 901 - const val INVALID_DOC_ID = 1150 - const val INVALID_DOC_TITLE = 1152 - const val ACCESS_TO_DOC_DENIED = 1153 -} - -object VkErrors { - const val UNKNOWN = "unknown_error" - - const val NEED_VALIDATION = "need_validation" - const val NEED_CAPTCHA = "need_captcha" - const val INVALID_REQUEST = "invalid_request" - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt index 70c5a758..db06da8d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt @@ -1,15 +1,18 @@ +@file:Suppress("UNCHECKED_CAST") + package com.meloda.fast.api.network -import com.meloda.fast.api.VKException +import com.google.gson.Gson +import com.meloda.fast.api.VkUtils import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.base.ApiResponse import okhttp3.Request -import okio.IOException import okio.Timeout -import org.json.JSONObject import retrofit2.* import java.lang.reflect.ParameterizedType import java.lang.reflect.Type +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract class ResultCallFactory : CallAdapter.Factory() { override fun get( @@ -21,7 +24,7 @@ class ResultCallFactory : CallAdapter.Factory() { if (rawReturnType == Call::class.java) { if (returnType is ParameterizedType) { val callInnerType: Type = getParameterUpperBound(0, returnType) - if (getRawType(callInnerType) == Answer::class.java) { + if (getRawType(callInnerType) == ApiAnswer::class.java) { if (callInnerType is ParameterizedType) { val resultInnerType = getParameterUpperBound(0, callInnerType) return ResultCallAdapter(resultInnerType) @@ -55,16 +58,16 @@ internal abstract class CallDelegate(protected val proxy: Call) : C abstract fun cloneImpl(): Call } -private class ResultCallAdapter(private val type: Type) : CallAdapter>> { +private class ResultCallAdapter(private val type: Type) : CallAdapter>> { override fun responseType() = type - override fun adapt(call: Call): Call> = ResultCall(call) + override fun adapt(call: Call): Call> = ResultCall(call) } -internal class ResultCall(proxy: Call) : CallDelegate>(proxy) { +internal class ResultCall(proxy: Call) : CallDelegate>(proxy) { - override fun enqueueImpl(callback: Callback>) { + override fun enqueueImpl(callback: Callback>) { proxy.enqueue(ResultCallback(this, callback)) } @@ -74,25 +77,34 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) private class ResultCallback( private val proxy: ResultCall, - private val callback: Callback> + private val callback: Callback> ) : Callback { - override fun onResponse(call: Call, response: Response) { - var isVkException = true + val gson = Gson() - val result: Answer = + override fun onResponse(call: Call, response: Response) { + val result: ApiAnswer = if (response.isSuccessful) { val baseBody = response.body() - if (baseBody !is ApiResponse<*>) Answer.Success(baseBody as T) - else { - val body = baseBody as ApiResponse<*> - if (body.error != null) { - Answer.Error(body.error) - } else Answer.Success(body as T) + if (baseBody !is ApiResponse<*>) { + ApiAnswer.Success(baseBody as T) + } else { + val body = baseBody as? ApiResponse<*> + if (body?.error != null) { + VkUtils.getApiError(gson, gson.toJson(body.error)) + } else { + ApiAnswer.Success(body as T) + } } - } else Answer.Error(IOException(response.errorBody()?.string() ?: "")) + } else { + val errorBodyString = response.errorBody()?.string() - if (result is Answer.Error && isVkException) if (checkErrors(call, result)) return + VkUtils.getApiError(gson, errorBodyString) + } + + if (checkErrors(call, result)) { + return + } callback.onResponse(proxy, Response.success(result)) } @@ -100,30 +112,21 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) override fun onFailure(call: Call, error: Throwable) { callback.onResponse( proxy, - Response.success(Answer.Error(throwable = error)) + Response.success(ApiAnswer.Error(ApiError(throwable = error))) ) } - private fun checkErrors(call: Call, result: Answer.Error): Boolean { - if (result.throwable is ApiError) { - onFailure(call, result.throwable) - return true + private fun checkErrors(call: Call, result: ApiAnswer<*>): Boolean { + if (!result.isSuccessful()) { + result.error.throwable?.run { + onFailure(call, this) + return true + } + } else { + return false } - val json = JSONObject(result.throwable.message ?: "{}") - - return if (json.has("error")) { - val error = json.optString("error", "") - val description = json.optString("error_description", "") - - val exception = VKException( - error = error, - description = description, - ).also { it.json = json } - - onFailure(call, exception) - true - } else false + return false } } @@ -132,9 +135,16 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) } } -sealed class Answer { +sealed class ApiAnswer { - data class Success(val data: T) : Answer() - data class Error(val throwable: Throwable) : Answer() + data class Success(val data: T) : ApiAnswer() + data class Error(val error: ApiError) : ApiAnswer() + @OptIn(ExperimentalContracts::class) + fun isSuccessful(): Boolean { + contract { + returns(false) implies (this@ApiAnswer is Error) + } + return this is Success + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt deleted file mode 100644 index 6da02c69..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.meloda.fast.api.network.account - -import javax.inject.Inject - -class AccountDataSource @Inject constructor( - private val repo: AccountRepo -) { - - - suspend fun setOnline(params: AccountSetOnlineRequest) = repo.setOnline(params.map) - - suspend fun setOffline(params: AccountSetOfflineRequest) = repo.setOffline(params.map) - - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt new file mode 100644 index 00000000..dfa5fbf3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.audio + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt new file mode 100644 index 00000000..a0a39180 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt @@ -0,0 +1,20 @@ +package com.meloda.fast.api.network.audio + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AudiosGetUploadServerResponse( + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class AudiosUploadResponse( + val redirect: String, + val server: Int, + val audio: String?, + val hash: String, + val error: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt new file mode 100644 index 00000000..094f32fa --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.audio + +import com.meloda.fast.api.network.VkUrls + +object AudiosUrls { + + const val GetUploadServer = "${VkUrls.API}/audio.getUploadServer" + + const val Save = "${VkUrls.API}/audio.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt deleted file mode 100644 index d17da88e..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.meloda.fast.api.network.auth - -import javax.inject.Inject - -class AuthDataSource @Inject constructor( - private val repo: AuthRepo -) { - - suspend fun auth(params: AuthDirectRequest) = repo.auth(params.map) - - suspend fun sendSms(validationSid: String) = repo.sendSms(validationSid) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt deleted file mode 100644 index 320a9d04..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.api.network.auth - -import com.meloda.fast.api.network.Answer -import retrofit2.http.GET -import retrofit2.http.Query -import retrofit2.http.QueryMap - -interface AuthRepo { - - @GET(AuthUrls.DirectAuth) - suspend fun auth(@QueryMap param: Map): Answer - - @GET(AuthUrls.SendSms) - suspend fun sendSms(@Query("sid") validationSid: String): Answer - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt deleted file mode 100644 index 838a5a8f..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.meloda.fast.api.network.conversations - -import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.database.dao.ConversationsDao -import javax.inject.Inject - -class ConversationsDataSource @Inject constructor( - private val repo: ConversationsRepo, - private val dao: ConversationsDao -) { - - suspend fun get(params: ConversationsGetRequest) = repo.get(params.map) - - suspend fun delete(params: ConversationsDeleteRequest) = repo.delete(params.map) - - suspend fun pin(params: ConversationsPinRequest) = repo.pin(params.map) - - suspend fun unpin(params: ConversationsUnpinRequest) = repo.unpin(params.map) - - suspend fun store(conversations: List) = dao.insert(conversations) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt deleted file mode 100644 index cb007e99..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.meloda.fast.api.network.conversations - -import com.meloda.fast.api.base.ApiResponse -import com.meloda.fast.api.network.Answer -import retrofit2.http.FieldMap -import retrofit2.http.FormUrlEncoded -import retrofit2.http.POST - -interface ConversationsRepo { - - @FormUrlEncoded - @POST(ConversationsUrls.Get) - suspend fun get(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Delete) - suspend fun delete(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Pin) - suspend fun pin(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Unpin) - suspend fun unpin(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.ReorderPinned) - suspend fun reorderPinned(@FieldMap params: Map): Answer> - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt new file mode 100644 index 00000000..a94949af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.files + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt new file mode 100644 index 00000000..abef98ae --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.api.network.files + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import com.meloda.fast.api.model.base.attachments.BaseVkFile +import com.meloda.fast.api.model.base.attachments.BaseVkVoiceMessage +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FilesGetMessagesUploadServerResponse( + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class FilesUploadFileResponse(val file: String?, val error: String?) : Parcelable + +@Parcelize +data class FilesSaveFileResponse( + val type: String, + @SerializedName("doc") + val file: BaseVkFile?, + @SerializedName("audio_message") + val voiceMessage: BaseVkVoiceMessage? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt new file mode 100644 index 00000000..1282e234 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.files + +import com.meloda.fast.api.network.VkUrls + +object FilesUrls { + + const val GetMessagesUploadServer = "${VkUrls.API}/docs.getMessagesUploadServer" + + const val Save = "${VkUrls.API}/docs.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt index d44cfe91..8b4aa48d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt @@ -9,7 +9,8 @@ data class LongPollGetUpdatesRequest( val key: String, val ts: Int, val wait: Int, - val mode: Int + val mode: Int, + val version: Int ) : Parcelable { val map @@ -18,7 +19,8 @@ data class LongPollGetUpdatesRequest( "key" to key, "ts" to ts.toString(), "wait" to wait.toString(), - "mode" to mode.toString() + "mode" to mode.toString(), + "version" to version.toString() ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt deleted file mode 100644 index 15955175..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.meloda.fast.api.network.messages - -import com.meloda.fast.api.model.VkMessage -import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.database.dao.MessagesDao -import javax.inject.Inject - -class MessagesDataSource @Inject constructor( - private val messagesRepo: MessagesRepo, - private val messagesDao: MessagesDao, - private val longPollRepo: LongPollRepo -) { - - suspend fun store(messages: List) = messagesDao.insert(messages) - - suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) - - suspend fun getHistory(params: MessagesGetHistoryRequest) = - messagesRepo.getHistory(params.map) - - suspend fun send(params: MessagesSendRequest) = - messagesRepo.send(params.map) - - suspend fun markAsImportant(params: MessagesMarkAsImportantRequest) = - messagesRepo.markAsImportant(params.map) - - suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) = - messagesRepo.getLongPollServer(params.map) - - suspend fun pin(params: MessagesPinMessageRequest) = - messagesRepo.pin(params.map) - - suspend fun unpin(params: MessagesUnPinMessageRequest) = - messagesRepo.unpin(params.map) - - suspend fun delete(params: MessagesDeleteRequest) = - messagesRepo.delete(params.map) - - suspend fun edit(params: MessagesEditRequest) = - messagesRepo.edit(params.map) - - suspend fun getLongPollUpdates( - serverUrl: String, - params: LongPollGetUpdatesRequest - ) = longPollRepo.getResponse(serverUrl, params.map) - - suspend fun getById(params: MessagesGetByIdRequest) = - messagesRepo.getById(params.map) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt index 4801203c..5e619b27 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt @@ -41,7 +41,8 @@ data class MessagesSendRequest( val stickerId: Int? = null, val disableMentions: Boolean? = null, val dontParseLinks: Boolean? = null, - val silent: Boolean? = null + val silent: Boolean? = null, + val attachments: List? = null ) : Parcelable { val map @@ -57,6 +58,11 @@ data class MessagesSendRequest( disableMentions?.let { this["disable_mentions"] = it.intString } dontParseLinks?.let { this["dont_parse_links"] = it.intString } silent?.let { this["silent"] = it.toString() } + attachments?.let { + this["attachment"] = it.joinToString(separator = ",") { attachment -> + attachment.asString(true) + } + } } } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt index bb75b0eb..66c3acd2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt @@ -14,5 +14,6 @@ object MessagesUrls { const val Delete = "${VkUrls.API}/messages.delete" const val Edit = "${VkUrls.API}/messages.edit" const val GetById = "${VkUrls.API}/messages.getById" + const val MarkAsRead = "${VkUrls.API}/messages.markAsRead" } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt new file mode 100644 index 00000000..fd0c6961 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt @@ -0,0 +1,8 @@ +package com.meloda.fast.api.network.ota + +import android.os.Parcelable +import com.meloda.fast.model.UpdateItem +import kotlinx.parcelize.Parcelize + +@Parcelize +data class OtaGetLatestReleaseResponse(val release: UpdateItem?) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt new file mode 100644 index 00000000..473d0ea9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.api.network.ota + +object OtaUrls { + + const val GetActualUrl = + "https://raw.githubusercontent.com/melod1n/ota-server/master/ngrok_url.json" +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt new file mode 100644 index 00000000..05b897e5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.photos + +import com.meloda.fast.api.network.VkUrls + +object PhotoUrls { + + const val GetMessagesUploadServer = "${VkUrls.API}/photos.getMessagesUploadServer" + + const val SaveMessagePhoto = "${VkUrls.API}/photos.saveMessagesPhoto" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt new file mode 100644 index 00000000..8e457ff0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt @@ -0,0 +1,16 @@ +package com.meloda.fast.api.network.photos + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PhotosSaveMessagePhotoRequest( + val photo: String, val server: Int, val hash: String +) : Parcelable { + val map + get() = mapOf( + "photo" to photo, + "server" to server.toString(), + "hash" to hash + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt new file mode 100644 index 00000000..c9a31973 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.api.network.photos + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PhotosGetMessagesUploadServerResponse( + @SerializedName("album_id") + val albumId: Int, + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class PhotosUploadPhotoResponse( + val server: Int, val photo: String, val hash: String +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt deleted file mode 100644 index 86a7b88a..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.api.network.users - -import com.meloda.fast.api.model.VkUser -import com.meloda.fast.database.dao.UsersDao -import javax.inject.Inject - -class UsersDataSource @Inject constructor( - private val repo: UsersRepo, - private val dao: UsersDao -) { - - suspend fun getById(params: UsersGetRequest) = repo.getById(params.map) - - suspend fun storeUsers(users: List) = dao.insert(users) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt new file mode 100644 index 00000000..1f196662 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.videos + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt new file mode 100644 index 00000000..86264318 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.api.network.videos + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VideosSaveResponse( + @SerializedName("access_key") + val accessKey: String, + val description: String, + @SerializedName("owner_id") + val ownerId: Int, + val title: String, + @SerializedName("upload_url") + val uploadUrl: String, + @SerializedName("video_id") + val videoId: Int +) : Parcelable { + +} + +@Parcelize +data class VideosUploadResponse( + @SerializedName("video_hash") + val hash: String?, + val size: Int, + @SerializedName("direct_link") + val directLink: String, + @SerializedName("owner_id") + val ownerId: Int, + @SerializedName("video_id") + val videoId: Int, + val error: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt new file mode 100644 index 00000000..c2cc9308 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.api.network.videos + +import com.meloda.fast.api.network.VkUrls + +object VideosUrls { + + const val Save = "${VkUrls.API}/video.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt index 879bbeeb..867e7485 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt @@ -1,7 +1,10 @@ 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 { @@ -9,4 +12,36 @@ abstract class BaseFragment : Fragment { constructor(@LayoutRes resId: Int) : super(resId) + protected var shouldNavBarShown: Boolean = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (arguments == null) arguments = Bundle() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + (requireActivity() as? MainActivity)?.run { + toggleNavBarVisibility(shouldNavBarShown) + } + } + + val activityRouter + get() = run { + if (requireActivity() is MainActivity) { + (requireActivity() as MainActivity).router + } else { + null + } + } + + fun requireActivityRouter() = requireNotNull(activityRouter) + + fun startProgress() = toggleProgress(true) + fun stopProgress() = toggleProgress(false) + + protected open fun toggleProgress(isProgressing: Boolean) {} + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt deleted file mode 100644 index 40ddc1f0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.meloda.fast.base - -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.annotation.LayoutRes -import androidx.lifecycle.lifecycleScope -import com.meloda.fast.R -import com.meloda.fast.activity.MainActivity -import com.meloda.fast.api.UserConfig -import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.IllegalTokenEvent -import com.meloda.fast.base.viewmodel.VkEvent -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach - -abstract class BaseViewModelFragment : BaseFragment { - - constructor() : super() - - constructor(@LayoutRes resId: Int) : super(resId) - - protected abstract val viewModel: VM - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - viewModel.tasksEvent.onEach { onEvent(it) }.collect() - } - } - - protected open fun onEvent(event: VkEvent) { - if (event is IllegalTokenEvent) { - Toast.makeText( - requireContext(), R.string.authorization_failed, Toast.LENGTH_LONG - ).show() - - UserConfig.clear() - requireActivity().finishAffinity() - requireActivity().startActivity(Intent(requireContext(), MainActivity::class.java)) - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt b/app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt rename to app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt index f0dcf582..f2ab5f99 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt @@ -1,12 +1,14 @@ package com.meloda.fast.base import android.content.Context +import android.graphics.drawable.Drawable import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat -abstract class ResourceManager(protected val context: Context) { +abstract class ResourceProvider(protected val context: Context) { protected fun getString(@StringRes resId: Int): String { return context.getString(resId) @@ -17,4 +19,8 @@ abstract class ResourceManager(protected val context: Context) { return ContextCompat.getColor(context, resId) } + protected fun getDrawable(@DrawableRes resId: Int): Drawable? { + return ContextCompat.getDrawable(context, resId) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt index 4455cb34..0aea22ec 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt @@ -1,25 +1,26 @@ package com.meloda.fast.base.adapter -import android.annotation.SuppressLint import android.content.Context +import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.AdapterView +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.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlin.properties.Delegates @Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST") -@SuppressLint("NotifyDataSetChanged") abstract class BaseAdapter, VH : BaseHolder> constructor( var context: Context, diffUtil: DiffUtil.ItemCallback, preAddedValues: List = emptyList(), -) : ListAdapter(diffUtil) { +) : ListAdapter(diffUtil), Filterable { + + private var valuesFilter: ValuesFilter? = null protected val adapterScope = CoroutineScope(Dispatchers.Default) private val cleanList = mutableListOf() @@ -29,13 +30,19 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( var itemClickListener: ((position: Int) -> Unit)? = null var itemLongClickListener: ((position: Int) -> Boolean)? = null + private val listForSave = mutableListOf() + + var isSearching: Boolean by Delegates.observable(false) { _, _, _ -> + updateSearchingState() + } + init { cleanList.addAll(preAddedValues) addAll(preAddedValues) } fun cloneCurrentList(): MutableList { - return ArrayList(currentList) + return currentList.toMutableList() } open fun destroy() {} @@ -142,6 +149,11 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( return currentList.indexOf(item) } + fun searchIndexOf(item: T): Int? { + val index = indexOf(item) + return if (index == -1) null else index + } + val indices get() = currentList.indices operator fun get(position: Int): T { @@ -161,9 +173,8 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( fun isEmpty() = currentList.isEmpty() fun isNotEmpty() = currentList.isNotEmpty() - @SuppressLint("NotifyDataSetChanged") fun refreshList() { - notifyDataSetChanged() + notifyItemRangeChanged(0, itemCount) } fun updateCleanList(list: List?) { @@ -201,4 +212,86 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( } val lastPosition get() = currentList.lastIndex + + private fun updateSearchingState() { + Log.d("BaseAdapter", "updateSearchingState: $isSearching") + + cleanList.clear() + + if (isSearching) { + listForSave.clear() + listForSave += cloneCurrentList() + } else { + setItems(listForSave, commitCallback = { + listForSave.clear() + }) + } + } + + open fun filter(query: String) { + if (cleanList.isEmpty()) { + cleanList.addAll(listForSave) + } + + val newList = mutableListOf() + + setItems(emptyList(), commitCallback = { + if (query.isEmpty()) { + newList.addAll(cleanList) + } else { + for (item in cleanList) { + if (onQueryItem(item, query)) { + newList.add(item) + } + } + } + + setItems(newList) + }) + } + + open fun onQueryItem(item: T, query: String): Boolean { + return false + } + + override fun getFilter(): Filter { + if (valuesFilter == null) { + valuesFilter = ValuesFilter() + } + + return requireNotNull(valuesFilter) + } + + private inner class ValuesFilter : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + val results = FilterResults() + + if (isEmpty()) return results + + if (!constraint.isNullOrEmpty()) { + val filteredList = mutableListOf() + for (item in listForSave) { + if (onQueryItem(item, constraint.toString())) { + filteredList.add(item) + } + } + results.count = filteredList.size + results.values = filteredList + } else { + results.count = listForSave.size + results.values = listForSave + } + + return results + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + val items = results.values as? List + setItems(items) + } + } + + override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { + super.onCurrentListChanged(previousList, currentList) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt deleted file mode 100644 index 7b1630b1..00000000 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.meloda.fast.base.adapter - -abstract class BaseItem \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt index af7860d1..df2f0d63 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt @@ -2,7 +2,6 @@ package com.meloda.fast.base.adapter import android.view.View import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding abstract class BaseHolder(v: View) : RecyclerView.ViewHolder(v) { @@ -12,6 +11,4 @@ abstract class BaseHolder(v: View) : RecyclerView.ViewHolder(v) { open fun bind(position: Int, payloads: MutableList?) {} -} - -abstract class BindingHolder(protected val binding: B) : BaseHolder(binding.root) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt index 8ce65c7e..7388207c 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt @@ -2,15 +2,19 @@ package com.meloda.fast.base.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.meloda.fast.api.VKException import com.meloda.fast.api.base.ApiError -import com.meloda.fast.api.network.Answer -import com.meloda.fast.api.network.VkErrorCodes -import com.meloda.fast.api.network.VkErrors +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 +@Suppress("MemberVisibilityCanBePrivate") abstract class BaseViewModel : ViewModel() { var unknownErrorDefaultText: String = "" @@ -18,19 +22,47 @@ abstract class BaseViewModel : ViewModel() { protected val tasksEventChannel = Channel() val tasksEvent = tasksEventChannel.receiveAsFlow() + protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + viewModelScope.launch { onException(throwable) } + } + + fun launch(block: suspend CoroutineScope.() -> Unit): Job { + return viewModelScope.launch(exceptionHandler, block = block) + } + + protected suspend fun makeSuspendJob( + job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {}, + onStart: (suspend () -> Unit)? = null, + onEnd: (suspend () -> Unit)? = null, + onError: (suspend (Throwable) -> Unit)? = null + ): ApiAnswer { + onStart?.invoke() ?: onStart() + val response = job() + + when (response) { + is ApiAnswer.Success -> onAnswer(response.data) + is ApiAnswer.Error -> { + onError?.invoke(response.error) ?: checkErrors(response.error) + } + } + + onEnd?.invoke() + + return response + } + protected fun makeJob( - job: suspend () -> Answer, + job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {}, onStart: (suspend () -> Unit)? = null, onEnd: (suspend () -> Unit)? = null, onError: (suspend (Throwable) -> Unit)? = null - ) = viewModelScope.launch { + ): Job = viewModelScope.launch { onStart?.invoke() ?: onStart() when (val response = job()) { - is Answer.Success -> onAnswer(response.data) - is Answer.Error -> { - checkErrors(response.throwable) - onError?.invoke(response.throwable) ?: onError(response.throwable) + is ApiAnswer.Success -> onAnswer(response.data) + is ApiAnswer.Error -> { + onError?.invoke(response.error) ?: checkErrors(response.error) } } }.also { @@ -41,6 +73,10 @@ abstract class BaseViewModel : ViewModel() { } } + protected open suspend fun onException(throwable: Throwable) { + checkErrors(throwable) + } + protected suspend fun onStart() { sendEvent(StartProgressEvent) } @@ -49,37 +85,24 @@ abstract class BaseViewModel : ViewModel() { sendEvent(StopProgressEvent) } - protected suspend fun onError(throwable: Throwable) { - sendEvent(ErrorEvent(throwable.message ?: unknownErrorDefaultText)) - } - protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event) - private suspend fun checkErrors(throwable: Throwable) { + protected suspend fun checkErrors(throwable: Throwable) { when (throwable) { - is ApiError -> { - when (throwable.errorCode) { - VkErrorCodes.USER_AUTHORIZATION_FAILED -> { - sendEvent(IllegalTokenEvent) - } - } + is AuthorizationError -> { + sendEvent(AuthorizationErrorEvent) } - is VKException -> { - when (throwable.error) { - VkErrors.NEED_CAPTCHA -> { - val json = throwable.json ?: return - sendEvent( - CaptchaEvent( - sid = json.optString("captcha_sid"), - image = json.optString("captcha_img") - ) - ) - } - VkErrors.NEED_VALIDATION -> { - val json = throwable.json ?: return - sendEvent(ValidationEvent(sid = json.optString("validation_sid"))) - } - } + is ValidationRequiredError -> { + sendEvent(ValidationRequiredEvent(throwable.validationSid)) + } + is CaptchaRequiredError -> { + sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg)) + } + is ApiError -> { + sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText)) + } + else -> { + sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText)) } } } diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt new file mode 100644 index 00000000..de979b7b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt @@ -0,0 +1,34 @@ +package com.meloda.fast.base.viewmodel + +import android.os.Bundle +import android.view.View +import androidx.annotation.LayoutRes +import androidx.lifecycle.lifecycleScope +import com.meloda.fast.base.BaseFragment +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +abstract class BaseViewModelFragment : BaseFragment { + + constructor() : super() + + constructor(@LayoutRes resId: Int) : super(resId) + + protected abstract val viewModel: VM + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + subscribeToViewModel(viewModel) + } + + protected open fun onEvent(event: VkEvent) { + ViewModelUtils.parseEvent(this, event) + } + + protected fun subscribeToViewModel(viewModel: T) { + lifecycleScope.launch { + viewModel.tasksEvent.collect { onEvent(it) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt index 71b2bad2..b958c069 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt @@ -1,22 +1,17 @@ package com.meloda.fast.base.viewmodel -data class ShowDialogInfoEvent( - val title: String? = null, - val message: String, - val positiveBtn: String? = null, - val negativeBtn: String? = null -) : VkEvent() - -data class ErrorEvent(val errorText: String) : VkEvent() - -object IllegalTokenEvent : VkEvent() -data class CaptchaEvent(val sid: String, val image: String) : VkEvent() -data class ValidationEvent(val sid: String) : VkEvent() - -object StartProgressEvent : VkEvent() -object StopProgressEvent : VkEvent() - abstract class VkEvent +abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent() +abstract class VkProgressEvent : VkEvent() + +open class ErrorTextEvent(override val errorText: String) : VkErrorEvent() + +object AuthorizationErrorEvent : VkErrorEvent() +data class CaptchaRequiredEvent(val sid: String, val image: String) : VkErrorEvent() +data class ValidationRequiredEvent(val sid: String) : VkErrorEvent() + +object StartProgressEvent : VkProgressEvent() +object StopProgressEvent : VkProgressEvent() interface VkEventCallback { fun onEvent(event: T) diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt new file mode 100644 index 00000000..3bf4c2cc --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt @@ -0,0 +1,49 @@ +package com.meloda.fast.base.viewmodel + +import android.content.Intent +import android.widget.Toast +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 + +object ViewModelUtils { + + @Suppress("MemberVisibilityCanBePrivate") + fun parseEvent(activity: FragmentActivity, event: VkEvent) { + when (event) { + is AuthorizationErrorEvent -> { + Toast.makeText( + activity, R.string.authorization_failed, Toast.LENGTH_LONG + ).show() + + UserConfig.clear() + activity.finishAffinity() + activity.startActivity(Intent(activity, MainActivity::class.java)) + } + + is VkErrorEvent -> { + event.errorText?.run { + activity.showErrorDialog(this) + } + } + } + } + + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt b/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt new file mode 100644 index 00000000..62d545a7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.common + +object AppConstants { + + const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt index e334c772..5b821eb1 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt @@ -1,6 +1,7 @@ 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 @@ -12,10 +13,9 @@ import android.view.inputmethod.InputMethodManager import androidx.core.content.pm.PackageInfoCompat import androidx.preference.PreferenceManager import androidx.room.Room -import com.meloda.fast.BuildConfig import com.meloda.fast.database.AppDatabase import dagger.hilt.android.HiltAndroidApp -import org.acra.ACRA +import kotlin.math.roundToInt import kotlin.math.sqrt @HiltAndroidApp @@ -26,6 +26,7 @@ class AppGlobal : Application() { lateinit var inputMethodManager: InputMethodManager lateinit var connectivityManager: ConnectivityManager lateinit var clipboardManager: ClipboardManager + lateinit var downloadManager: DownloadManager lateinit var preferences: SharedPreferences lateinit var resources: Resources @@ -37,11 +38,13 @@ class AppGlobal : Application() { lateinit var packageManager: PackageManager var versionName = "" - var versionCode = 0L + var versionCode = 0 var screenWidth = 0 var screenHeight = 0 + var screenWidth80 = 0 + val Instance get() = instance } @@ -49,19 +52,15 @@ class AppGlobal : Application() { super.onCreate() instance = this - if (!BuildConfig.DEBUG) { - ACRA.init(this) - } - appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache") - .fallbackToDestructiveMigration() +// .fallbackToDestructiveMigration() .build() preferences = PreferenceManager.getDefaultSharedPreferences(this) val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES) versionName = info.versionName - versionCode = PackageInfoCompat.getLongVersionCode(info) + versionCode = PackageInfoCompat.getLongVersionCode(info).toInt() Companion.resources = resources Companion.packageName = packageName @@ -70,6 +69,8 @@ class AppGlobal : Application() { screenWidth = resources.displayMetrics.widthPixels screenHeight = resources.displayMetrics.heightPixels + screenWidth80 = (screenWidth * 0.8).roundToInt() + val density = resources.displayMetrics.density val densityDpi = resources.displayMetrics.densityDpi val densityScaled = resources.displayMetrics.scaledDensity @@ -82,11 +83,12 @@ class AppGlobal : Application() { Log.i( "Fast::DeviceInfo", - "width: $screenWidth; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" + "width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" ) inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt index c4039eb1..4f0bcc26 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.Job object AppSettings { - val keyIsMultilineEnabled = booleanPreferencesKey("isMultilineEnabled") + val keyUseNavigationDrawer = booleanPreferencesKey("use_nav_drawer") } diff --git a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt index 3060bb55..f2411903 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt @@ -1,17 +1,50 @@ package com.meloda.fast.common -import android.os.Bundle 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.model.UpdateItem 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.updates.UpdatesFragment @Suppress("FunctionName") object Screens { fun Main() = FragmentScreen { MainFragment() } - fun Login() = FragmentScreen { LoginFragment() } + + fun Login( + getFastToken: Boolean = false + ) = FragmentScreen { + LoginFragment.newInstance(getFastToken) + } + fun Conversations() = FragmentScreen { ConversationsFragment() } - fun MessagesHistory(bundle: Bundle) = - FragmentScreen { MessagesHistoryFragment.newInstance(bundle) } + + fun MessagesHistory( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) } + + fun ForwardedMessages( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) = FragmentScreen { + ForwardedMessagesFragment.newInstance( + conversation, messages, profiles, groups + ) + } + + fun Updates(updateItem: UpdateItem? = null) = + FragmentScreen { UpdatesFragment.newInstance(updateItem) } + + fun Settings() = FragmentScreen { SettingsRootFragment() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt b/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt deleted file mode 100644 index a1a914e0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.meloda.fast.common - -import android.content.Context -import android.content.IntentFilter -import com.meloda.fast.receiver.MinuteReceiver -import java.util.* - -object TimeManager { - - var currentHour = 0 - var currentMinute = 0 - var currentSecond = 0 - - private val onHourChangeListeners: ArrayList = ArrayList() - private val onMinuteChangeListeners: ArrayList = ArrayList() - private val onSecondChangeListeners: ArrayList = ArrayList() - private val onTimeChangeListeners: ArrayList = ArrayList() - - fun init(context: Context) { - context.registerReceiver(MinuteReceiver(), IntentFilter("android.intent.action.TIME_TICK")) - - addOnMinuteChangeListener(minuteChangeListener) - } - - private var minuteChangeListener = object : OnMinuteChangeListener { - override fun onMinuteChange(currentMinute: Int) { - TimeManager.currentMinute = currentMinute - } - } - - fun destroy() { - removeOnMinuteChangeListener(minuteChangeListener) - } - - fun broadcastMinute() { - for (onMinuteChangeListener in onMinuteChangeListeners) { - onMinuteChangeListener.onMinuteChange(0) - } - } - - val isMorning = currentHour in 7..11 - - val isAfternoon = currentHour in 12..16 - - val isEvening = currentHour in 17..22 - - val isNight = currentHour == 23 || currentHour < 6 && currentHour > -1 - - fun addOnHourChangeListener(onHourChangeListeners: OnHourChangeListener) { - TimeManager.onHourChangeListeners.add(onHourChangeListeners) - } - - fun removeOnHourChangeListener(onHourChangeListener: OnHourChangeListener?) { - onHourChangeListeners.remove(onHourChangeListener) - } - - fun addOnMinuteChangeListener(onMinuteChangeListener: OnMinuteChangeListener) { - onMinuteChangeListeners.add(onMinuteChangeListener) - } - - fun removeOnMinuteChangeListener(onMinuteChangeListener: OnMinuteChangeListener?) { - onMinuteChangeListeners.remove(onMinuteChangeListener) - } - - fun addOnSecondChangeListener(onSecondChangeListener: OnSecondChangeListener) { - onSecondChangeListeners.add(onSecondChangeListener) - } - - fun removeOnSecondChangeListener(onSecondChangeListener: OnSecondChangeListener?) { - onSecondChangeListeners.remove(onSecondChangeListener) - } - - fun addOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener) { - onTimeChangeListeners.add(onTimeChangeListener) - } - - fun removeOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener?) { - onTimeChangeListeners.remove(onTimeChangeListener) - } - - interface OnHourChangeListener { - fun onHourChange(currentHour: Int) - } - - interface OnMinuteChangeListener { - fun onMinuteChange(currentMinute: Int) - } - - interface OnSecondChangeListener { - fun onSecondChange(currentSecond: Int) - } - - interface OnTimeChangeListener { - fun onHourChange(currentHour: Int) - fun onMinuteChange(currentMinute: Int) - fun onSecondChange(currentSecond: Int) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt new file mode 100644 index 00000000..930aab68 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt @@ -0,0 +1,100 @@ +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.launch +import kotlinx.coroutines.withContext +import java.net.URLEncoder +import kotlin.coroutines.CoroutineContext + +class UpdateManager(private val repo: OtaApi) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + companion object { + val newUpdate = MutableLiveData(null) + val updateError = MutableLiveData(null) + + var otaBaseUrl: String? = null + private set + } + + private var listener: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null + + private fun getActualUrl() = launch { + val job: suspend () -> ApiAnswer = { repo.getActualUrl() } + + when (val jobResponse = job()) { + is ApiAnswer.Success -> { + val item = jobResponse.data + otaBaseUrl = item.url + + getLatestRelease() + } + is ApiAnswer.Error -> { + otaBaseUrl = null + val throwable = jobResponse.error.throwable + listener?.invoke(null, throwable) + + withContext(Dispatchers.Main) { + updateError.setIfNotEquals(throwable) + } + } + } + } + + private fun getLatestRelease() = launch { + val url = "$otaBaseUrl/releases-latest" + + val job: suspend () -> ApiAnswer> = { + repo.getLatestRelease(url = url, secretCode = getOtaSecret()) + } + + withContext(Dispatchers.Main) { + when (val jobResponse = job()) { + is ApiAnswer.Success -> { + val response = jobResponse.data.response ?: return@withContext + val latestRelease = response.release + + 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) + } + } + + is ApiAnswer.Error -> { + val throwable = jobResponse.error.throwable + updateError.setIfNotEquals(throwable) + listener?.invoke(null, throwable) + } + } + } + } + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt similarity index 59% rename from app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt index 8abb02b7..bbd182e2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt @@ -1,17 +1,18 @@ -package com.meloda.fast.api.network.account +package com.meloda.fast.data.account import com.meloda.fast.api.base.ApiResponse -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.account.AccountUrls import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.QueryMap -interface AccountRepo { +interface AccountApi { @GET(AccountUrls.SetOnline) - suspend fun setOnline(@QueryMap params: Map): Answer> + suspend fun setOnline(@QueryMap params: Map): ApiAnswer> @POST(AccountUrls.SetOffline) - suspend fun setOffline(@QueryMap params: Map): Answer> + suspend fun setOffline(@QueryMap params: Map): ApiAnswer> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt new file mode 100644 index 00000000..ed138f4e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.data.account + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.meloda.fast.model.AppAccount + +@Dao +interface AccountsDao { + + @Query("SELECT * FROM accounts") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(values: List) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt new file mode 100644 index 00000000..cf427fab --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.data.account + +import com.meloda.fast.api.network.account.AccountSetOfflineRequest +import com.meloda.fast.api.network.account.AccountSetOnlineRequest + +class AccountsRepository( + private val accountApi: AccountApi, + private val accountsDao: AccountsDao +) { + + suspend fun setOnline(params: AccountSetOnlineRequest) = accountApi.setOnline(params.map) + + suspend fun setOffline(params: AccountSetOfflineRequest) = accountApi.setOffline(params.map) + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt new file mode 100644 index 00000000..1a3a33d3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt @@ -0,0 +1,28 @@ +package com.meloda.fast.data.audios + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.model.base.attachments.BaseVkAudio +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.audio.AudiosGetUploadServerResponse +import com.meloda.fast.api.network.audio.AudiosUploadResponse +import com.meloda.fast.api.network.audio.AudiosUrls +import okhttp3.MultipartBody +import retrofit2.http.* + +interface AudiosApi { + + @POST(AudiosUrls.GetUploadServer) + suspend fun getUploadServer(): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(AudiosUrls.Save) + suspend fun save(@FieldMap map: Map): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt new file mode 100644 index 00000000..5a6a145e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.data.audios + +import okhttp3.MultipartBody + +class AudiosRepository( + private val audiosApi: AudiosApi +) { + + suspend fun getUploadServer() = audiosApi.getUploadServer() + + suspend fun upload(url: String, file: MultipartBody.Part) = audiosApi.upload(url, file) + + suspend fun save(server: Int, audio: String, hash: String) = audiosApi.save( + mapOf( + "server" to server.toString(), + "audio" to audio, + "hash" to hash + ) + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt new file mode 100644 index 00000000..feb0bf98 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt @@ -0,0 +1,19 @@ +package com.meloda.fast.data.auth + +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.auth.AuthDirectResponse +import com.meloda.fast.api.network.auth.AuthUrls +import com.meloda.fast.api.network.auth.SendSmsResponse +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.QueryMap + +interface AuthApi { + + @GET(AuthUrls.DirectAuth) + suspend fun auth(@QueryMap param: Map): ApiAnswer + + @GET(AuthUrls.SendSms) + suspend fun sendSms(@Query("sid") validationSid: String): ApiAnswer + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt new file mode 100644 index 00000000..9042d9e5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.data.auth + +import com.meloda.fast.api.network.auth.AuthDirectRequest + +class AuthRepository( + private val authApi: AuthApi +) { + + suspend fun auth(params: AuthDirectRequest) = authApi.auth(params.map) + + suspend fun sendSms(validationSid: String) = authApi.sendSms(validationSid) + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt new file mode 100644 index 00000000..02b939e9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.conversations + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.conversations.ConversationsGetResponse +import com.meloda.fast.api.network.conversations.ConversationsUrls +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface ConversationsApi { + + @FormUrlEncoded + @POST(ConversationsUrls.Get) + suspend fun get(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Delete) + suspend fun delete(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Pin) + suspend fun pin(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Unpin) + suspend fun unpin(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.ReorderPinned) + suspend fun reorderPinned(@FieldMap params: Map): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt similarity index 76% rename from app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt index 218dcd15..57a1aa5e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.conversations import androidx.room.Dao import androidx.room.Insert @@ -15,6 +15,4 @@ interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(values: List) - suspend fun insert(values: Array) = insert(values.toList()) - } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt new file mode 100644 index 00000000..c9161d2e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.data.conversations + +import com.meloda.fast.api.model.VkConversation +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, + private val conversationsDao: ConversationsDao +) { + + suspend fun get(params: ConversationsGetRequest) = conversationsApi.get(params.map) + + suspend fun delete(params: ConversationsDeleteRequest) = conversationsApi.delete(params.map) + + suspend fun pin(params: ConversationsPinRequest) = conversationsApi.pin(params.map) + + suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map) + + suspend fun store(conversations: List) = conversationsDao.insert(conversations) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt b/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt new file mode 100644 index 00000000..73cdad7e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.files + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.files.FilesGetMessagesUploadServerResponse +import com.meloda.fast.api.network.files.FilesSaveFileResponse +import com.meloda.fast.api.network.files.FilesUploadFileResponse +import com.meloda.fast.api.network.files.FilesUrls +import okhttp3.MultipartBody +import retrofit2.http.* + +interface FilesApi { + + @FormUrlEncoded + @POST(FilesUrls.GetMessagesUploadServer) + suspend fun getUploadServer( + @FieldMap map: Map + ): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(FilesUrls.Save) + suspend fun save( + @FieldMap map: Map + ): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt new file mode 100644 index 00000000..ec894800 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt @@ -0,0 +1,30 @@ +package com.meloda.fast.data.files + +import com.google.gson.annotations.SerializedName +import okhttp3.MultipartBody + +class FilesRepository( + private val filesApi: FilesApi +) { + + enum class FileType(val value: String) { + @SerializedName("doc") + File("doc"), + + @SerializedName("audio_message") + VoiceMessage("audio_message") + } + + suspend fun getMessagesUploadServer(peerId: Int, type: FileType) = + filesApi.getUploadServer( + mapOf( + "peer_id" to peerId.toString(), + "type" to type.value + ) + ) + + suspend fun uploadFile(url: String, file: MultipartBody.Part) = filesApi.upload(url, file) + + suspend fun saveMessageFile(file: String) = filesApi.save(mapOf("file" to file)) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt similarity index 93% rename from app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt index 963c7b22..87d7ae6e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.groups import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt new file mode 100644 index 00000000..50ba5c0d --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt @@ -0,0 +1,6 @@ +package com.meloda.fast.data.groups + +class GroupsRepository( + private val groupsDao: GroupsDao +) { +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt similarity index 63% rename from app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt index 14416913..a2ab6688 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt @@ -1,17 +1,17 @@ -package com.meloda.fast.api.network.longpoll +package com.meloda.fast.data.longpoll import com.google.gson.JsonObject -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer import retrofit2.http.GET import retrofit2.http.QueryMap import retrofit2.http.Url -interface LongPollRepo { +interface LongPollApi { @GET suspend fun getResponse( @Url serverUrl: String, @QueryMap params: Map - ): Answer + ): ApiAnswer } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt similarity index 50% rename from app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt index 3d61be52..e8faab88 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt @@ -1,49 +1,56 @@ -package com.meloda.fast.api.network.messages +package com.meloda.fast.data.messages import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.model.base.BaseVkLongPoll import com.meloda.fast.api.model.base.BaseVkMessage -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.messages.MessagesGetByIdResponse +import com.meloda.fast.api.network.messages.MessagesGetHistoryResponse +import com.meloda.fast.api.network.messages.MessagesUrls import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST -interface MessagesRepo { +interface MessagesApi { @FormUrlEncoded @POST(MessagesUrls.GetHistory) - suspend fun getHistory(@FieldMap params: Map): Answer> + suspend fun getHistory(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Send) - suspend fun send(@FieldMap params: Map): Answer> + suspend fun send(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.MarkAsImportant) - suspend fun markAsImportant(@FieldMap params: Map): Answer>> + suspend fun markAsImportant(@FieldMap params: Map): ApiAnswer>> @FormUrlEncoded @POST(MessagesUrls.GetLongPollServer) - suspend fun getLongPollServer(@FieldMap params: Map): Answer> + suspend fun getLongPollServer(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Pin) - suspend fun pin(@FieldMap params: Map): Answer> + suspend fun pin(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Unpin) - suspend fun unpin(@FieldMap params: Map): Answer> + suspend fun unpin(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Delete) - suspend fun delete(@FieldMap params: Map): Answer> + suspend fun delete(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Edit) - suspend fun edit(@FieldMap params: Map): Answer> + suspend fun edit(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.GetById) - suspend fun getById(@FieldMap params: Map): Answer> + suspend fun getById(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(MessagesUrls.MarkAsRead) + suspend fun markAsRead(@FieldMap params: Map): ApiAnswer> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt similarity index 94% rename from app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt index 98fc7f94..0953ae5e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.messages import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt new file mode 100644 index 00000000..f3d9c653 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt @@ -0,0 +1,65 @@ +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.data.longpoll.LongPollApi +import com.meloda.fast.api.network.messages.* + +class MessagesRepository( + private val messagesApi: MessagesApi, + private val messagesDao: MessagesDao, + private val longPollApi: LongPollApi +) { + + suspend fun store(messages: List) = messagesDao.insert(messages) + + suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) + + suspend fun getHistory(params: MessagesGetHistoryRequest) = + messagesApi.getHistory(params.map) + + suspend fun send(params: MessagesSendRequest) = + messagesApi.send(params.map) + + suspend fun markAsImportant(params: MessagesMarkAsImportantRequest) = + messagesApi.markAsImportant(params.map) + + suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) = + messagesApi.getLongPollServer(params.map) + + suspend fun pin(params: MessagesPinMessageRequest) = + messagesApi.pin(params.map) + + suspend fun unpin(params: MessagesUnPinMessageRequest) = + messagesApi.unpin(params.map) + + suspend fun delete(params: MessagesDeleteRequest) = + messagesApi.delete(params.map) + + suspend fun edit(params: MessagesEditRequest) = + messagesApi.edit(params.map) + + suspend fun getLongPollUpdates( + serverUrl: String, + params: LongPollGetUpdatesRequest + ) = longPollApi.getResponse(serverUrl, params.map) + + suspend fun getById(params: MessagesGetByIdRequest) = + messagesApi.getById(params.map) + + suspend fun markAsRead( + peerId: Int, + messagesIds: List? = null, + startMessageId: Int? = null + ) = messagesApi.markAsRead( + mutableMapOf("peer_id" to peerId.toString()).apply { + messagesIds?.let { + this["message_ids"] = messagesIds.joinToString { it.toString() } + } + startMessageId?.let { + this["start_message_id"] = it.toString() + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt b/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt new file mode 100644 index 00000000..68ea9737 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.data.ota + +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.api.network.ota.OtaUrls +import com.meloda.fast.model.UpdateActualUrl +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query +import retrofit2.http.Url + +interface OtaApi { + + @GET(OtaUrls.GetActualUrl) + suspend fun getActualUrl(): ApiAnswer + + @GET + suspend fun getLatestRelease( + @Url url: String, + @Query("productId") productId: Int = 28, + @Query("branchId") branchId: Int = 10, + @Header("Secret-Code") secretCode: String + ): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt new file mode 100644 index 00000000..863388e0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.photos + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.model.base.attachments.BaseVkPhoto +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.photos.PhotoUrls +import com.meloda.fast.api.network.photos.PhotosGetMessagesUploadServerResponse +import com.meloda.fast.api.network.photos.PhotosUploadPhotoResponse +import okhttp3.MultipartBody +import retrofit2.http.* + +interface PhotosApi { + + @FormUrlEncoded + @POST(PhotoUrls.GetMessagesUploadServer) + suspend fun getUploadServer( + @FieldMap map: Map + ): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part photo: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(PhotoUrls.SaveMessagePhoto) + suspend fun save( + @FieldMap map: Map + ): ApiAnswer>> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt new file mode 100644 index 00000000..e143c7a6 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.data.photos + +import com.meloda.fast.api.network.photos.PhotosSaveMessagePhotoRequest +import okhttp3.MultipartBody + +class PhotosRepository( + private val photosApi: PhotosApi +) { + + suspend fun getMessagesUploadServer(peerId: Int) = + photosApi.getUploadServer(mapOf("peer_id" to peerId.toString())) + + suspend fun uploadPhoto(url: String, photo: MultipartBody.Part) = photosApi.upload(url, photo) + + suspend fun saveMessagePhoto(body: PhotosSaveMessagePhotoRequest) = + photosApi.save(body.map) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt similarity index 61% rename from app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt index 229e13c1..7574536a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt @@ -1,18 +1,19 @@ -package com.meloda.fast.api.network.users +package com.meloda.fast.data.users import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.model.base.BaseVkUser -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.users.UsersUrls import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST -interface UsersRepo { +interface UsersApi { @FormUrlEncoded @POST(UsersUrls.GetById) suspend fun getById( @FieldMap params: Map? - ): Answer>> + ): ApiAnswer>> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt similarity index 93% rename from app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt index 0d7801b1..3d74af1d 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.users import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt new file mode 100644 index 00000000..f41f0b17 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.data.users + +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.network.users.UsersGetRequest + +class UsersRepository( + private val usersApi: UsersApi, + private val usersDao: UsersDao +) { + + suspend fun getById(params: UsersGetRequest) = usersApi.getById(params.map) + + suspend fun storeUsers(users: List) { + usersDao.insert(users) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt new file mode 100644 index 00000000..1b244480 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.data.videos + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.videos.VideosSaveResponse +import com.meloda.fast.api.network.videos.VideosUploadResponse +import com.meloda.fast.api.network.videos.VideosUrls +import okhttp3.MultipartBody +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Url + +interface VideosApi { + + @POST(VideosUrls.Save) + suspend fun save(): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt new file mode 100644 index 00000000..0b9f2866 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt @@ -0,0 +1,13 @@ +package com.meloda.fast.data.videos + +import okhttp3.MultipartBody + +class VideosRepository( + private val videosApi: VideosApi +) { + + suspend fun save() = videosApi.save() + + suspend fun upload(url: String, file: MultipartBody.Part) = videosApi.upload(url, file) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt index d3db872b..a2640d66 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt @@ -1,5 +1,6 @@ package com.meloda.fast.database +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -7,27 +8,34 @@ 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.database.dao.ConversationsDao -import com.meloda.fast.database.dao.GroupsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao +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.model.AppAccount @Database( entities = [ + AppAccount::class, VkConversation::class, VkMessage::class, VkUser::class, VkGroup::class ], - version = 28, - exportSchema = false, + version = 34, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 33, to = 34) + ] ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { - abstract fun conversationsDao(): ConversationsDao - abstract fun messagesDao(): MessagesDao - abstract fun usersDao(): UsersDao - abstract fun groupsDao(): GroupsDao + abstract val accountsDao: AccountsDao + abstract val conversationsDao: ConversationsDao + abstract val messagesDao: MessagesDao + abstract val usersDao: UsersDao + abstract val groupsDao: GroupsDao } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt index 400d3633..73472b37 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.gson.Gson import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.base.BaseVkMessage import org.json.JSONObject @Suppress("UnnecessaryVariable") @@ -13,6 +14,24 @@ class Converters { private const val CACHE_SEPARATOR = "fastkruta228355" } + @TypeConverter + fun fromGeoToString(geo: BaseVkMessage.Geo?): String? { + if (geo == null) return null + + val string = Gson().toJson(geo) + + return string + } + + @TypeConverter + fun fromStringToGeo(string: String?): BaseVkMessage.Geo? { + if (string == null) return null + + val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java) + + return geo + } + @TypeConverter fun fromListVkMessageToString(messages: List?): String? { if (messages == null) return null @@ -49,7 +68,9 @@ class Converters { fun fromStringToVkMessage(string: String?): VkMessage? { if (string == null) return null - return Gson().fromJson(string, VkMessage::class.java) + val message = Gson().fromJson(string, VkMessage::class.java) + + return message } @TypeConverter @@ -82,7 +103,9 @@ class Converters { fun fromVkAttachmentToString(attachment: VkAttachment?): String? { if (attachment == null) return null - return Gson().toJson(attachment) + val string = Gson().toJson(attachment) + + return string } @TypeConverter @@ -91,6 +114,8 @@ class Converters { val className = JSONObject(string).optString("className") - return Gson().fromJson(string, Class.forName(className)) as VkAttachment? + val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment? + + return attachment } } diff --git a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt new file mode 100644 index 00000000..890f763f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt @@ -0,0 +1,103 @@ +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) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt index 8b3900d9..af8962e8 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt @@ -1,11 +1,12 @@ package com.meloda.fast.di 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 com.meloda.fast.database.dao.ConversationsDao -import com.meloda.fast.database.dao.GroupsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,22 +24,27 @@ object DatabaseModule { @Provides @Singleton - fun provideUsersDao(appDatabase: AppDatabase): UsersDao = - appDatabase.usersDao() + fun provideAccountsDao(appDatabase: AppDatabase): AccountsDao = + appDatabase.accountsDao @Provides @Singleton fun provideConversationsDao(appDatabase: AppDatabase): ConversationsDao = - appDatabase.conversationsDao() + appDatabase.conversationsDao @Provides @Singleton fun provideMessagesDao(appDatabase: AppDatabase): MessagesDao = - appDatabase.messagesDao() + appDatabase.messagesDao + + @Provides + @Singleton + fun provideUsersDao(appDatabase: AppDatabase): UsersDao = + appDatabase.usersDao @Provides @Singleton fun provideGroupsDao(appDatabase: AppDatabase): GroupsDao = - appDatabase.groupsDao() + appDatabase.groupsDao } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt index f94afeb4..ecb81be5 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt @@ -1,24 +1,27 @@ 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.LongPollUpdatesParser +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.account.AccountDataSource -import com.meloda.fast.api.network.account.AccountRepo -import com.meloda.fast.api.network.auth.AuthDataSource -import com.meloda.fast.api.network.auth.AuthRepo -import com.meloda.fast.api.network.conversations.ConversationsDataSource -import com.meloda.fast.api.network.conversations.ConversationsRepo -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.api.network.messages.MessagesDataSource -import com.meloda.fast.api.network.messages.MessagesRepo -import com.meloda.fast.api.network.users.UsersDataSource -import com.meloda.fast.api.network.users.UsersRepo -import com.meloda.fast.database.dao.ConversationsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao +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 @@ -34,18 +37,68 @@ import javax.inject.Singleton @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 provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(20, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - }).build() + fun provideChuckerCollector(): ChuckerCollector = + ChuckerCollector(AppGlobal.Instance) + + @Singleton + @Provides + fun provideChuckerInterceptor( + chuckerCollector: ChuckerCollector + ): ChuckerInterceptor = + ChuckerInterceptor.Builder(AppGlobal.Instance) + .collector(chuckerCollector) + .build() + + @Singleton + @Provides + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + chuckerInterceptor: ChuckerInterceptor + ): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor(chuckerInterceptor) + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + ).build() @Singleton @Provides @@ -59,7 +112,7 @@ object NetworkModule { client: OkHttpClient, gson: Gson ): Retrofit = Retrofit.Builder() - .baseUrl("https://api.vk.com/") + .baseUrl("${VkUrls.API}/") .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(ResultCallFactory()) .client(client) @@ -71,73 +124,67 @@ object NetworkModule { @Provides @Singleton - fun provideAuthRepo(retrofit: Retrofit): AuthRepo = - retrofit.create(AuthRepo::class.java) + fun provideAuthApi(retrofit: Retrofit): AuthApi = + retrofit.create(AuthApi::class.java) @Provides @Singleton - fun provideConversationsRepo(retrofit: Retrofit): ConversationsRepo = - retrofit.create(ConversationsRepo::class.java) + fun provideConversationsApi(retrofit: Retrofit): ConversationsApi = + retrofit.create(ConversationsApi::class.java) @Provides @Singleton - fun provideUsersRepo(retrofit: Retrofit): UsersRepo = - retrofit.create(UsersRepo::class.java) + fun provideUsersApi(retrofit: Retrofit): UsersApi = + retrofit.create(UsersApi::class.java) @Provides @Singleton - fun provideMessagesRepo(retrofit: Retrofit): MessagesRepo = - retrofit.create(MessagesRepo::class.java) + fun provideMessagesApi(retrofit: Retrofit): MessagesApi = + retrofit.create(MessagesApi::class.java) @Provides @Singleton - fun provideLongPollRepo(retrofit: Retrofit): LongPollRepo = - retrofit.create(LongPollRepo::class.java) + fun provideLongPollApi(retrofit: Retrofit): LongPollApi = + retrofit.create(LongPollApi::class.java) @Provides @Singleton - fun provideAuthDataSource( - repo: AuthRepo - ): AuthDataSource = AuthDataSource(repo) + fun provideLongPollUpdatesParser(messagesRepository: MessagesRepository): LongPollUpdatesParser = + LongPollUpdatesParser(messagesRepository) @Provides @Singleton - fun provideUsersDataSource( - repo: UsersRepo, - dao: UsersDao - ): UsersDataSource = UsersDataSource(repo, dao) + fun provideAccountApi(retrofit: Retrofit): AccountApi = + retrofit.create(AccountApi::class.java) @Provides @Singleton - fun provideConversationsDataSource( - repo: ConversationsRepo, - dao: ConversationsDao - ): ConversationsDataSource = ConversationsDataSource(repo, dao) + fun provideOtaApi(retrofit: Retrofit): OtaApi = + retrofit.create(OtaApi::class.java) @Provides @Singleton - fun provideMessagesDataSource( - messagesRepo: MessagesRepo, - messagesDao: MessagesDao, - longPollRepo: LongPollRepo - ): MessagesDataSource = MessagesDataSource( - messagesRepo = messagesRepo, - messagesDao = messagesDao, - longPollRepo = longPollRepo - ) + fun provideUpdateManager(otaApi: OtaApi): UpdateManager = + UpdateManager(otaApi) @Provides @Singleton - fun provideLongPollUpdatesParser(messagesDataSource: MessagesDataSource): LongPollUpdatesParser = - LongPollUpdatesParser(messagesDataSource) + fun providePhotosApi(retrofit: Retrofit): PhotosApi = + retrofit.create(PhotosApi::class.java) @Provides @Singleton - fun provideAccountRepo(retrofit: Retrofit): AccountRepo = - retrofit.create(AccountRepo::class.java) + fun provideVideosApi(retrofit: Retrofit): VideosApi = + retrofit.create(VideosApi::class.java) @Provides @Singleton - fun provideAccountDataSource(repo: AccountRepo): AccountDataSource = - AccountDataSource(repo) + fun provideAudiosApi(retrofit: Retrofit): AudiosApi = + retrofit.create(AudiosApi::class.java) + + @Provides + @Singleton + fun provideFilesApi(retrofit: Retrofit): FilesApi = + retrofit.create(FilesApi::class.java) + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt index 3a5ebdbc..1f2eb77b 100644 --- a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt +++ b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt @@ -2,17 +2,27 @@ package com.meloda.fast.extensions import android.animation.ValueAnimator import android.content.res.Resources -import android.os.Build +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.annotation.StyleRes +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 @@ -52,6 +62,11 @@ fun ValueAnimator.startWithIntValues(from: Int, to: Int) { start() } +fun ValueAnimator.startWithFloatValues(from: Float, to: Float) { + setFloatValues(from, to) + start() +} + fun View.setMarginsPx( @Px leftMargin: Int? = null, @Px topMargin: Int? = null, @@ -84,4 +99,87 @@ fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) @JvmOverloads fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse -} \ No newline at end of file +} + +fun View.showKeyboard(flags: Int = 0) { + AppGlobal.inputMethodManager.showSoftInput(this, flags) +} + +fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) { + AppGlobal.inputMethodManager.hideSoftInputFromWindow( + focusedView?.windowToken ?: this.windowToken, flags + ) +} + +fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) { + menu.forEach { item -> + item.icon?.setTint(colorToTint) + } +} + +fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem { + val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate( + LayoutInflater.from(context), null, false + ) + + val avatarMenuItem = menu.add("Profile") + avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + avatarMenuItem.actionView = avatarMenuItemBinding.root + + val imageView = avatarMenuItemBinding.avatar + + when { + urlToLoad != null -> { + imageView.loadWithGlide( + url = urlToLoad, + transformations = ImageLoader.userAvatarTransformations + ) + } + drawable != null -> { + imageView.loadWithGlide( + drawable = drawable, + transformations = ImageLoader.userAvatarTransformations + ) + } + } + + return avatarMenuItem +} + +fun MutableLiveData.notifyObservers() { + this.value = this.value +} + +fun MutableLiveData.setIfNotEquals(item: T) { + if (this.value != item) this.value = item +} + +fun MutableLiveData.requireValue(): T { + return this.value!! +} + +val EditText.trimmedText: String get() = text.toString().trim() + +val MediaType.mimeType: String get() = "${type()}/${subtype()}" + +fun EditText.selectLast() { + setSelection(text.length) +} + +fun T?.requireNotNull(): T { + return requireNotNull(this) +} + + +fun String?.orDots(count: Int = 3): String { + return this ?: ("." * count) +} + +private operator fun String.times(count: Int): String { + val builder = StringBuilder() + for (i in 0 until count) { + builder.append(this) + } + + return builder.toString() +} diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt b/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt index 5ad156f9..04cf5275 100644 --- a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt +++ b/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt @@ -1,7 +1,6 @@ package com.meloda.fast.extensions import android.graphics.Bitmap -import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri @@ -33,10 +32,13 @@ object ImageLoader { uri: Uri? = null, drawableRes: Int? = null, drawable: Drawable? = null, - placeholderDrawable: Drawable = ColorDrawable(Color.TRANSPARENT), - errorDrawable: Drawable = placeholderDrawable, + placeholderDrawable: Drawable? = null, + placeholderColor: Int? = null, + errorDrawable: Drawable? = placeholderDrawable, + errorColor: Int? = null, crossFade: Boolean = false, - crossFadeDuration: Int = 200, + crossFadeDuration: Int? = null, + asCircle: Boolean = false, transformations: List = emptyList(), onLoadedAction: (() -> Unit)? = null, onFailedAction: (() -> Unit)? = null, @@ -53,16 +55,27 @@ object ImageLoader { else -> request.load(null as Drawable?) } + val transforms = transformations.toMutableList() + if (asCircle) { + transforms += TypeTransformations.CircleCrop + } + builder = builder - .apply(TypeTransformations.createRequestOptions(transformations)) - .error(errorDrawable) - .placeholder(placeholderDrawable) + .apply(TypeTransformations.createRequestOptions(transforms)) + .error( + errorDrawable + ?: if (errorColor != null) ColorDrawable(errorColor) else null + ) + .placeholder( + placeholderDrawable + ?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null + ) .addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction)) .diskCacheStrategy(cacheStrategy) .priority(priority) - if (crossFade) { - builder = builder.transition(withCrossFade(crossFadeDuration)) + if (crossFade || crossFadeDuration != null) { + builder = builder.transition(withCrossFade(crossFadeDuration ?: 200)) } builder.into(this) diff --git a/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt b/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt new file mode 100644 index 00000000..ca3bd121 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.model + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +@Entity(tableName = "accounts") +@Parcelize +data class AppAccount( + @PrimaryKey(autoGenerate = false) + val userId: Int, + val accessToken: String, + val fastToken: String? +) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt index f381fe69..965aa08c 100644 --- a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt +++ b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt @@ -1,6 +1,6 @@ package com.meloda.fast.model -sealed class DataItem { +abstract class DataItem { abstract val dataItemId: IdType object Header : DataItem() { diff --git a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt new file mode 100644 index 00000000..208b2b9a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.model + +import android.os.Parcelable +import com.google.gson.Gson +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UpdateItem( + val id: Int, + val versionName: String, + val versionCode: Int, + val mandatory: Int, + val changelog: String?, + val enabled: Int, + val fileName: String, + val date: Long, + val extension: String, + val originalName: String, + val fileSize: Int, + val preRelease: Int, + val downloadLink: String +) : Parcelable { + + fun isMandatory(): Boolean = mandatory == 1 + fun isEnabled(): Boolean = enabled == 1 + fun isPreRelease(): Boolean = preRelease == 1 + + override fun toString(): String { + return Gson().toJson(this) + } + +} + +@Parcelize +data class UpdateActualUrl(val url: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt new file mode 100644 index 00000000..5a80ffda --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class DownloadManagerReceiver : BroadcastReceiver() { + + var onReceiveAction: (() -> Unit)? = null + + override fun onReceive(context: Context, intent: Intent) { + onReceiveAction?.invoke() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt deleted file mode 100644 index fe60a687..00000000 --- a/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.meloda.fast.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.meloda.fast.common.TimeManager - -class MinuteReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context?, intent: Intent?) { - TimeManager.broadcastMinute() - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt index 24803dac..5ac71de2 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt @@ -3,40 +3,54 @@ package com.meloda.fast.screens.conversations import android.content.Context import android.content.res.ColorStateList import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import android.text.SpannableString import android.text.TextUtils import android.text.style.ForegroundColorSpan import android.view.ViewGroup -import androidx.core.content.ContextCompat import androidx.core.util.ObjectsCompat import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.recyclerview.widget.DiffUtil import com.meloda.fast.R import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkUser import com.meloda.fast.base.adapter.BaseAdapter -import com.meloda.fast.base.adapter.BindingHolder +import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemConversationBinding -import com.meloda.fast.extensions.ImageLoader +import com.meloda.fast.extensions.* import com.meloda.fast.extensions.ImageLoader.clear import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.extensions.visible import com.meloda.fast.util.TimeUtils class ConversationsAdapter constructor( context: Context, - private val resourceManager: ConversationsResourceManager, + private val resourceManager: ConversationsResourceProvider, var isMultilineEnabled: Boolean = true, val profiles: HashMap = hashMapOf(), val groups: HashMap = hashMapOf(), -) : BaseAdapter(context, Comparator) { +) : BaseAdapter(context, comparator) { + + companion object { + private val comparator = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VkConversation, + newItem: VkConversation + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: VkConversation, + newItem: VkConversation + ): Boolean { + return ObjectsCompat.equals(oldItem, newItem) + } + } + } var pinnedCount = 0 @@ -48,47 +62,73 @@ class ConversationsAdapter constructor( } inner class ItemHolder( - binding: ItemConversationBinding, - private val resourceManager: ConversationsResourceManager - ) : BindingHolder(binding) { - - init { - binding.title.ellipsize = TextUtils.TruncateAt.END - binding.message.ellipsize = TextUtils.TruncateAt.END - } + private val binding: ItemConversationBinding, + private val resourceManager: ConversationsResourceProvider + ) : BaseHolder(binding.root) { override fun bind(position: Int) { val conversation = getItem(position) - binding.service.isVisible = conversation.isPhantom || conversation.callInProgress - binding.callIcon.isVisible = conversation.callInProgress - binding.phantomIcon.isVisible = conversation.isPhantom + if (conversation.isAccount()) { + binding.service.gone() + binding.callIcon.gone() + binding.phantomIcon.gone() + } else { + binding.service.toggleVisibility(conversation.isPhantom || conversation.callInProgress) + binding.callIcon.toggleVisibility(conversation.callInProgress) + binding.phantomIcon.toggleVisibility(conversation.isPhantom) + } val maxLines = if (isMultilineEnabled) 2 else 1 binding.title.maxLines = maxLines binding.message.maxLines = maxLines - val message = if (conversation.lastMessage != null) conversation.lastMessage!! - else { - binding.title.text = conversation.title - val text = context.getString( - if (conversation.isPhantom) R.string.messages_self_destructed - else R.string.no_messages - ) + val message = + if (conversation.lastMessage != null) requireNotNull(conversation.lastMessage) + else { + binding.title.text = conversation.title + val text = context.getString( + if (conversation.isPhantom) R.string.messages_self_destructed + else R.string.no_messages + ) - val span = SpannableString(text) - span.setSpan(ForegroundColorSpan(resourceManager.colorOutline), 0, text.length, 0) + val span = SpannableString(text) + span.setSpan( + ForegroundColorSpan(resourceManager.colorOutline), + 0, + text.length, + 0 + ) - binding.message.text = span - return - } + binding.message.text = span + return + } - val conversationUser = VkUtils.getConversationUser(conversation, profiles) - val conversationGroup = VkUtils.getConversationGroup(conversation, groups) + val conversationUserGroup = + VkUtils.getConversationUserGroup(conversation, profiles, groups) + val messageUserGroup = VkUtils.getMessageUserGroup(message, profiles, groups) - val messageUser = VkUtils.getMessageUser(message, profiles) - val messageGroup = VkUtils.getMessageGroup(message, groups) + val conversationUser = conversationUserGroup.first + val conversationGroup = conversationUserGroup.second + + val messageUser = messageUserGroup.first + val messageGroup = messageUserGroup.second + + val title = VkUtils.getConversationTitle( + context = context, + conversation = conversation, + defConversationUser = conversationUser, + defConversationGroup = conversationGroup + ) + + binding.title.text = title.orDots() + + binding.online.toggleVisibility( + !conversation.isAccount() && conversationUser?.online == true + ) + + binding.pin.toggleVisibility(conversation.isPinned()) val avatar = VkUtils.getConversationAvatar( conversation = conversation, @@ -99,17 +139,18 @@ class ConversationsAdapter constructor( binding.avatar.toggleVisibility(avatar != null) if (avatar == null) { + binding.avatar.clear() binding.avatarPlaceholder.visible() - if (conversation.ownerId == VKConstants.FAST_GROUP_ID) { + if (conversation.isAccount()) { binding.placeholderBack.loadWithGlide( drawable = ColorDrawable(resourceManager.icLauncherColor), transformations = ImageLoader.userAvatarTransformations ) binding.placeholder.imageTintList = ColorStateList.valueOf(resourceManager.colorOnPrimary) - binding.placeholder.setImageResource(R.drawable.ic_fast_logo) - binding.placeholder.setPadding(18) + binding.placeholder.setImageResource(R.drawable.ic_round_bookmark_border_24) + binding.placeholder.setPadding(36) } else { binding.placeholderBack.loadWithGlide( drawable = ColorDrawable(resourceManager.colorOnUserAvatarAction), @@ -119,7 +160,6 @@ class ConversationsAdapter constructor( ColorStateList.valueOf(resourceManager.colorUserAvatarAction) binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut) binding.placeholder.setPadding(0) - binding.avatar.clear() } } else { binding.avatar.loadWithGlide( @@ -129,9 +169,6 @@ class ConversationsAdapter constructor( ) } - binding.online.toggleVisibility(conversationUser?.online == true) - binding.pin.toggleVisibility(conversation.isPinned) - val actionMessage = VkUtils.getActionConversationText( context = context, message = message, @@ -142,17 +179,17 @@ class ConversationsAdapter constructor( messageGroup = messageGroup ) - val attachmentIcon = - if (message.text == null) null - else if (!message.forwards.isNullOrEmpty()) ContextCompat.getDrawable( - context, - if (message.forwards?.size == 1) R.drawable.ic_attachment_forwarded_message - else R.drawable.ic_attachment_forwarded_messages - ) - else VkUtils.getAttachmentConversationIcon( - context = context, - message = message - ) + val attachmentIcon: Drawable? = when { + message.text == null -> null + !message.forwards.isNullOrEmpty() -> { + if (message.forwards?.size == 1) { + resourceManager.iconForwardedMessage + } else { + resourceManager.iconForwardedMessages + } + } + else -> VkUtils.getAttachmentConversationIcon(context, message) + } binding.textAttachment.toggleVisibility(attachmentIcon != null) binding.textAttachment.setImageDrawable(attachmentIcon) @@ -189,7 +226,6 @@ class ConversationsAdapter constructor( if ((!conversation.isChat() && !message.isOut) || conversation.id == UserConfig.userId) prefix = "" -// if (conversation.isChat() || message.isOut) { val spanText = "$prefix$coloredMessage$messageText" val spanMessage = SpannableString(spanText) @@ -201,31 +237,31 @@ class ConversationsAdapter constructor( binding.message.text = spanMessage - binding.title.text = - getItem(position).title ?: conversationUser?.toString() ?: conversationGroup?.name - ?: "..." - binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L) - binding.container.background = if (conversation.isUnread()) ContextCompat.getDrawable( - context, - R.drawable.ic_message_unread - ) else null + val showUnreadBackgroundCondition = + (message.isOut && conversation.isOutUnread()) || + (!message.isOut && conversation.isInUnread()) + + binding.container.background = + if (showUnreadBackgroundCondition) resourceManager.conversationUnreadBackground + else null binding.onlineBorder.setImageDrawable( ColorDrawable( - ContextCompat.getColor( - context, - if (conversation.isUnread()) R.color.colorBackgroundVariant - else R.color.colorBackground - ) + if (showUnreadBackgroundCondition) resourceManager.colorBackgroundVariant + else resourceManager.colorBackground ) ) - binding.counter.isVisible = conversation.isInUnread() - if (conversation.isInUnread()) { - conversation.unreadCount?.let { - val count = if (it > 999) "${it / 1000}K" else it.toString() + binding.counter.toggleVisibility( + !message.isOut && conversation.isInUnread() + ) + if (binding.counter.isVisible) { + if (conversation.unreadCount > 0) { + val count = + if (conversation.unreadCount > 999) "${conversation.unreadCount / 1000}K" + else conversation.unreadCount.toString() binding.counter.text = count } } else { @@ -249,27 +285,19 @@ class ConversationsAdapter constructor( fun searchConversationIndex(conversationId: Int): Int? { for (i in indices) { val conversation = getItem(i) - if (conversation.id == conversationId) return i } return null } - companion object { - private val Comparator = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ): Boolean { - return oldItem.id == newItem.id - } + override fun onQueryItem(item: VkConversation, query: String): Boolean { + val userGroup = VkUtils.getConversationUserGroup(item, profiles, groups) + val title = VkUtils.getConversationTitle(context, item, userGroup.first, userGroup.second) - override fun areContentsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ) = ObjectsCompat.equals(oldItem, newItem) - } + return title.orEmpty().contains(query, ignoreCase = true) || + item.lastMessage?.text.orEmpty().contains(query, ignoreCase = true) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt index 42a9cbea..6fbb34f4 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt @@ -2,34 +2,47 @@ package com.meloda.fast.screens.conversations import android.os.Bundle import android.view.Gravity +import android.view.MenuItem import android.view.View import android.viewbinding.library.fragment.viewBinding +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.addCallback +import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.datastore.preferences.core.edit +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.meloda.fast.R import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.base.BaseViewModelFragment -import com.meloda.fast.base.viewmodel.StartProgressEvent -import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.AppGlobal -import com.meloda.fast.common.AppSettings -import com.meloda.fast.common.dataStore +import com.meloda.fast.common.Screens import com.meloda.fast.databinding.FragmentConversationsBinding import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.extensions.addAvatarMenuItem import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.tintMenuItemIcons import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.screens.messages.MessagesHistoryFragment +import com.meloda.fast.screens.main.MainActivity +import com.meloda.fast.screens.main.MainFragment +import com.meloda.fast.screens.settings.SettingsPrefsFragment import com.meloda.fast.util.AndroidUtils +import com.meloda.fast.util.NotificationsUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @AndroidEntryPoint @@ -42,7 +55,7 @@ class ConversationsFragment : private val adapter: ConversationsAdapter by lazy { ConversationsAdapter( requireContext(), - ConversationsResourceManager(requireContext()) + ConversationsResourceProvider(requireContext()) ).also { it.itemClickListener = this::onItemClick it.itemLongClickListener = this::onItemLongClick @@ -53,56 +66,149 @@ class ConversationsFragment : get() = PopupMenu( requireContext(), - binding.avatar, - Gravity.BOTTOM + binding.toolbar, + Gravity.BOTTOM or Gravity.END ).apply { + menu.add("Settings") menu.add(getString(R.string.log_out)) setOnMenuItemClickListener { item -> - if (item.title == getString(R.string.log_out)) { - showLogOutDialog() - return@setOnMenuItemClickListener true + return@setOnMenuItemClickListener when (item.title) { + getString(R.string.log_out) -> { + showLogOutDialog() + true + } + "Settings" -> { + requireActivityRouter().navigateTo(Screens.Settings()) + true + } + else -> false } - - false } } + private var toggle: ActionBarDrawerToggle? = null + + private val useNavDrawer: Boolean get() = (requireActivity() as MainActivity).useNavDrawer + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + adapter.isMultilineEnabled = + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefMultiline, true) + prepareViews() binding.recyclerView.adapter = adapter - lifecycleScope.launch { - requireContext().dataStore.data.map { - adapter.isMultilineEnabled = it[AppSettings.keyIsMultilineEnabled] ?: true - adapter.refreshList() - }.collect() - } - binding.createChat.setOnClickListener {} - UserConfig.vkUser.observe(viewLifecycleOwner) { user -> - user?.run { binding.avatar.loadWithGlide(url = this.photo200, crossFade = true) } + binding.toolbar.tintMenuItemIcons( + ContextCompat.getColor( + requireContext(), + R.color.colorPrimary + ) + ) + + val searchMenuItem = binding.toolbar.menu.findItem(R.id.search) + val actionView = searchMenuItem.actionView as SearchView + + searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { + if (!adapter.isSearching) + adapter.isSearching = true + return true + } + + override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { + if (adapter.isSearching) + adapter.isSearching = false + return true + } + + }) + + actionView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + Toast.makeText(requireContext(), "API Search: $query", Toast.LENGTH_SHORT).show() + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + adapter.filter.filter(newText) + return false + } + + }) + + requireActivity().onBackPressedDispatcher.addCallback(this) { + if (searchMenuItem.isActionViewExpanded) { + searchMenuItem.collapseActionView() + } else { + isEnabled = false + requireActivity().onBackPressed() + } } - binding.avatar.setOnClickListener { avatarPopupMenu.show() } + val avatarMenuItem = binding.toolbar.addAvatarMenuItem() + syncAvatarMenuItem(avatarMenuItem) - binding.avatar.setOnLongClickListener { - lifecycleScope.launch { - requireContext().dataStore.edit { settings -> - val isMultilineEnabled = settings[AppSettings.keyIsMultilineEnabled] ?: true - settings[AppSettings.keyIsMultilineEnabled] = !isMultilineEnabled + UserConfig.vkUser.observe(viewLifecycleOwner) { user -> + user?.run { + avatarMenuItem.actionView?.findViewById(R.id.avatar) + ?.loadWithGlide( + url = this.photo200, crossFade = true, asCircle = true + ) - adapter.isMultilineEnabled = !isMultilineEnabled - adapter.refreshList() - } + val header = (requireActivity() as MainActivity).binding.drawer.getHeaderView(0) + header.findViewById(R.id.name).text = user.fullName + header.findViewById(R.id.avatar).loadWithGlide( + url = this.photo200, crossFade = true, asCircle = true + ) } - true + } + + avatarMenuItem.actionView?.run { + setOnClickListener { avatarPopupMenu.show() } } viewModel.loadProfileUser() viewModel.loadConversations() + + syncToolbarToggle() + + binding.createChat.gone() + + setFragmentResultListener(SettingsPrefsFragment.KeyChangeMultiline) { _, bundle -> + val enabled = bundle.getBoolean(SettingsPrefsFragment.ArgEnabled) + + if (adapter.isMultilineEnabled != enabled) { + adapter.isMultilineEnabled = enabled + adapter.refreshList() + } + } + } + + private fun syncAvatarMenuItem(item: MenuItem) { + item.isVisible = !useNavDrawer + } + + private fun syncToolbarToggle() { + (requireActivity() as MainActivity).let { activity -> + if (useNavDrawer) { + toggle = ActionBarDrawerToggle( + activity, activity.binding.drawerLayout, + binding.toolbar, R.string.app_name, R.string.app_name + ).apply { + isDrawerSlideAnimationEnabled = false + activity.binding.drawerLayout.addDrawerListener(this) + syncState() + } + } else { + toggle?.let { toggle -> + activity.binding.drawerLayout.removeDrawerListener(toggle) + } + } + } } private fun showLogOutDialog() { @@ -121,6 +227,10 @@ class ConversationsFragment : lifecycleScope.launch(Dispatchers.Default) { UserConfig.clear() AppGlobal.appDatabase.clearAllTables() + setFragmentResult( + MainFragment.KeyStartServices, + bundleOf("enable" to false) + ) viewModel.openRootScreen() } @@ -132,13 +242,9 @@ class ConversationsFragment : override fun onEvent(event: VkEvent) { super.onEvent(event) when (event) { - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() - is ConversationsLoadedEvent -> refreshConversations(event) is ConversationsDeleteEvent -> deleteConversation(event.peerId) - // TODO: 10-Oct-21 remove this and sort conversations list is ConversationsPinEvent -> { adapter.pinnedCount++ viewModel.loadConversations() @@ -150,17 +256,18 @@ class ConversationsFragment : is MessagesNewEvent -> onMessageNew(event) is MessagesEditEvent -> onMessageEdit(event) + is MessagesReadEvent -> onMessageRead(event) } } - private fun onProgressStarted() { - binding.progressBar.toggleVisibility(adapter.isEmpty()) - binding.refreshLayout.isRefreshing = adapter.isNotEmpty() - } - - private fun onProgressStopped() { - binding.progressBar.gone() - binding.refreshLayout.isRefreshing = false + override fun toggleProgress(isProgressing: Boolean) { + view?.run { + findViewById(R.id.progress_bar).toggleVisibility( + if (isProgressing) adapter.isEmpty() else false + ) + findViewById(R.id.refresh_layout).isRefreshing = + if (isProgressing) adapter.isNotEmpty() else false + } } private fun prepareViews() { @@ -186,7 +293,7 @@ class ConversationsFragment : setColorSchemeColors( AndroidUtils.getThemeAttrColor( requireContext(), - R.attr.colorAccent + R.attr.colorPrimary ) ) setOnRefreshListener { viewModel.loadConversations() } @@ -197,7 +304,16 @@ class ConversationsFragment : adapter.profiles += event.profiles adapter.groups += event.groups - val pinnedConversations = event.conversations.filter { it.isPinned } + if (event.avatars != null) { + event.avatars.forEach { avatar -> + Glide.with(requireContext()) + .load(avatar) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .preload(200, 200) + } + } + + val pinnedConversations = event.conversations.filter { it.isPinned() } adapter.pinnedCount = pinnedConversations.count() fillRecyclerView(event.conversations) @@ -218,13 +334,7 @@ class ConversationsFragment : if (conversation.isGroup()) adapter.groups[conversation.id] else null - viewModel.openMessagesHistoryScreen( - bundleOf( - MessagesHistoryFragment.ARG_USER to user, - MessagesHistoryFragment.ARG_GROUP to group, - MessagesHistoryFragment.ARG_CONVERSATION to conversation - ) - ) + viewModel.openMessagesHistoryScreen(conversation, user, group) } private fun onItemLongClick(position: Int): Boolean { @@ -237,17 +347,17 @@ class ConversationsFragment : var canPinOneMoreDialog = true if (adapter.itemCount > 4) { - val firstFiveDialogs = adapter.currentList.subList(0, 5) - var pinnedCount = 0 + val pinnedConversations = adapter.cloneCurrentList().filter { it.majorId > 0 } - firstFiveDialogs.forEach { if (it.isPinned) pinnedCount++ } - if (pinnedCount == 5 && position > 4) { + if (pinnedConversations.size == 5 && position > 4) { canPinOneMoreDialog = false } } + val read = "Mark as read" + val pin = getString( - if (conversation.isPinned) R.string.conversation_context_action_unpin + if (conversation.isPinned()) R.string.conversation_context_action_unpin else R.string.conversation_context_action_pin ) @@ -255,6 +365,12 @@ class ConversationsFragment : val params = mutableListOf() + conversation.lastMessage?.run { + if (!this.isRead(conversation) && !isOut) { + params += read + } + } + if (canPinOneMoreDialog) params += pin params += delete @@ -264,6 +380,7 @@ class ConversationsFragment : MaterialAlertDialogBuilder(requireContext()) .setItems(arrayParams) { _, which -> when (params[which]) { + read -> viewModel.readConversation(conversation) pin -> showPinConversationDialog(conversation) delete -> showDeleteConversationDialog(conversation.id) } @@ -276,7 +393,7 @@ class ConversationsFragment : .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteConversation(conversationId) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -285,7 +402,7 @@ class ConversationsFragment : } private fun showPinConversationDialog(conversation: VkConversation) { - val isPinned = conversation.isPinned + val isPinned = conversation.isPinned() MaterialAlertDialogBuilder(requireContext()) .setTitle( if (isPinned) R.string.confirm_unpin_conversation @@ -300,7 +417,7 @@ class ConversationsFragment : pin = !isPinned ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -312,24 +429,50 @@ class ConversationsFragment : val conversationIndex = adapter.searchConversationIndex(message.peerId) if (conversationIndex == null) { // диалога нет в списке - + // pizdets } else { val conversation = adapter[conversationIndex] - conversation.run { - lastMessage = message - lastMessageId = message.id + val newConversation = conversation.copy( + lastMessage = message, + lastMessageId = message.id, lastConversationMessageId = -1 + ) + if (!message.isOut) { + newConversation.unreadCount += 1 } - if (conversation.isPinned) { - adapter[conversationIndex] = conversation +// if (!message.isOut) { +// NotificationsUtils.showSimpleNotification( +// requireContext(), +// VkUtils.getConversationTitle( +// requireContext(), conversation, profiles = event.profiles, +// groups = event.groups +// ) ?: "...", +// "${ +// VkUtils.getMessageTitle( +// message, +// profiles = event.profiles, +// groups = event.groups +// ) ?: "..." +// }: ${message.text}", +// customNotificationId = message.id, +// showWhen = true, +// timeStampWhen = message.date * 1000L +// ) +// } + + if (conversation.isPinned()) { + adapter[conversationIndex] = newConversation return } - adapter.removeConversation(message.peerId) ?: return - val toPosition = adapter.pinnedCount + val newList = adapter.cloneCurrentList() + newList.removeAt(conversationIndex) - adapter.add(conversation, toPosition) + val toPosition = adapter.pinnedCount + newList.add(toPosition, newConversation) + + adapter.submitList(newList) } } @@ -341,8 +484,25 @@ class ConversationsFragment : } else { val conversation = adapter[conversationIndex] - conversation.lastMessage = message - adapter[conversationIndex] = conversation + adapter[conversationIndex] = conversation.copy( + lastMessage = message, + lastMessageId = message.id, + lastConversationMessageId = -1 + ) } } + + private fun onMessageRead(event: MessagesReadEvent) { + val conversationIndex = adapter.searchConversationIndex(event.peerId) ?: return + + val newConversation = adapter[conversationIndex].copy() + + if (event.isOut) { + newConversation.outRead = event.messageId + } else { + newConversation.inRead = event.messageId + } + + adapter[conversationIndex] = newConversation + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt deleted file mode 100644 index c561a4c4..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.meloda.fast.screens.conversations - -import android.content.Context -import com.meloda.fast.R -import com.meloda.fast.base.ResourceManager -import com.meloda.fast.extensions.TypeTransformations - -class ConversationsResourceManager(context: Context) : ResourceManager(context) { - - val colorOutline = getColor(R.color.colorOutline) - val colorOnPrimary = getColor(R.color.colorOnPrimary) - val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) - val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) - - val icLauncherColor = getColor(R.color.a1_500) - - val youPrefix = getString(R.string.you_message_prefix) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt new file mode 100644 index 00000000..875f426a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.screens.conversations + +import android.content.Context +import com.meloda.fast.R +import com.meloda.fast.base.ResourceProvider + +class ConversationsResourceProvider(context: Context) : ResourceProvider(context) { + + val colorOutline = getColor(R.color.colorOutline) + val colorOnPrimary = getColor(R.color.colorOnPrimary) + val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) + val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) + val colorBackground = getColor(R.color.colorBackground) + val colorBackgroundVariant = getColor(R.color.colorBackgroundVariant) + + val icLauncherColor = getColor(R.color.a1_500) + + val youPrefix = getString(R.string.you_message_prefix) + + val conversationUnreadBackground get() = getDrawable(R.drawable.ic_message_unread) + + val iconForwardedMessages = getDrawable(R.drawable.ic_attachment_forwarded_messages) + val iconForwardedMessage = getDrawable(R.drawable.ic_attachment_forwarded_message) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt index 75dc08d9..733d61de 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt @@ -1,22 +1,24 @@ package com.meloda.fast.screens.conversations -import android.os.Bundle import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router -import com.meloda.fast.api.LongPollEvent -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.longpoll.LongPollEvent +import com.meloda.fast.api.longpoll.LongPollUpdatesParser 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.network.conversations.* -import com.meloda.fast.api.network.users.UsersDataSource import com.meloda.fast.api.network.users.UsersGetRequest import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens +import com.meloda.fast.data.conversations.ConversationsRepository +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.users.UsersRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,16 +26,13 @@ import javax.inject.Inject @HiltViewModel class ConversationsViewModel @Inject constructor( - private val conversations: ConversationsDataSource, - private val users: UsersDataSource, + private val conversationsRepository: ConversationsRepository, + private val usersRepository: UsersRepository, updatesParser: LongPollUpdatesParser, - private val router: Router + private val router: Router, + private val messagesRepository: MessagesRepository ) : BaseViewModel() { - companion object { - private const val TAG = "ConversationsViewModel" - } - init { updatesParser.onNewMessage { viewModelScope.launch { handleNewMessage(it) } @@ -42,15 +41,23 @@ class ConversationsViewModel @Inject constructor( updatesParser.onMessageEdited { viewModelScope.launch { handleEditedMessage(it) } } + + updatesParser.onMessageIncomingRead { + viewModelScope.launch { handleReadIncomingMessage(it) } + } + + updatesParser.onMessageOutgoingRead { + viewModelScope.launch { handleReadOutgoingMessage(it) } + } } fun loadConversations( offset: Int? = null ) = viewModelScope.launch(Dispatchers.Default) { makeJob({ - conversations.get( + conversationsRepository.get( ConversationsGetRequest( - count = 30, + count = 100, extended = true, offset = offset, fields = VKConstants.ALL_FIELDS @@ -69,18 +76,29 @@ class ConversationsViewModel @Inject constructor( baseGroup.asVkGroup().let { group -> groups[group.id] = group } } + val conversations = response.items.map { items -> + items.conversation.asVkConversation( + items.lastMessage?.asVkMessage() + ) + } + + val avatars = conversations.mapNotNull { conversation -> + VkUtils.getConversationAvatar( + conversation, + if (conversation.isUser()) profiles[conversation.id] else null, + if (conversation.isGroup()) groups[conversation.id] else null + ) + } + sendEvent( ConversationsLoadedEvent( count = response.count, offset = offset, unreadCount = response.unreadCount ?: 0, - conversations = response.items.map { items -> - items.conversation.asVkConversation( - items.lastMessage?.asVkMessage() - ) - }, + conversations = conversations, profiles = profiles, - groups = groups + groups = groups, + avatars = avatars ) ) } @@ -89,11 +107,11 @@ class ConversationsViewModel @Inject constructor( } fun loadProfileUser() = viewModelScope.launch { - makeJob({ users.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) }, + makeJob({ usersRepository.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) }, onAnswer = { it.response?.let { r -> val users = r.map { u -> u.asVkUser() } - this@ConversationsViewModel.users.storeUsers(users) + this@ConversationsViewModel.usersRepository.storeUsers(users) UserConfig.vkUser.value = users[0] } @@ -102,7 +120,7 @@ class ConversationsViewModel @Inject constructor( fun deleteConversation(peerId: Int) = viewModelScope.launch { makeJob({ - conversations.delete( + conversationsRepository.delete( ConversationsDeleteRequest(peerId) ) }, onAnswer = { sendEvent(ConversationsDeleteEvent(peerId)) }) @@ -114,12 +132,12 @@ class ConversationsViewModel @Inject constructor( ) = viewModelScope.launch { if (pin) { makeJob( - { conversations.pin(ConversationsPinRequest(peerId)) }, + { conversationsRepository.pin(ConversationsPinRequest(peerId)) }, onAnswer = { sendEvent(ConversationsPinEvent(peerId)) } ) } else { makeJob( - { conversations.unpin(ConversationsUnpinRequest(peerId)) }, + { conversationsRepository.unpin(ConversationsUnpinRequest(peerId)) }, onAnswer = { sendEvent(ConversationsUnpinEvent(peerId)) } ) } @@ -139,13 +157,33 @@ class ConversationsViewModel @Inject constructor( sendEvent(MessagesEditEvent(event.message)) } - fun openRootScreen() { - router.exit() - router.newRootScreen(Screens.Main()) + private suspend fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) { + sendEvent(MessagesReadEvent(false, event.peerId, event.messageId)) } - fun openMessagesHistoryScreen(bundle: Bundle) { - router.navigateTo(Screens.MessagesHistory(bundle)) + private suspend fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) { + sendEvent(MessagesReadEvent(true, event.peerId, event.messageId)) + } + + fun openRootScreen() { + router.replaceScreen(Screens.Main()) + } + + fun openMessagesHistoryScreen( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ) { + router.navigateTo(Screens.MessagesHistory(conversation, user, group)) + } + + fun readConversation(conversation: VkConversation) { + makeJob( + { messagesRepository.markAsRead(conversation.id, startMessageId = conversation.lastMessageId) }, + onAnswer = { + sendEvent(MessagesReadEvent(false, conversation.id, conversation.lastMessageId)) + } + ) } } @@ -155,7 +193,8 @@ data class ConversationsLoadedEvent( val unreadCount: Int?, val conversations: List, val profiles: HashMap, - val groups: HashMap + val groups: HashMap, + val avatars: List? = null ) : VkEvent() data class ConversationsDeleteEvent(val peerId: Int) : VkEvent() @@ -170,4 +209,6 @@ data class MessagesNewEvent( val groups: HashMap ) : VkEvent() -data class MessagesEditEvent(val message: VkMessage) : VkEvent() \ No newline at end of file +data class MessagesEditEvent(val message: VkMessage) : VkEvent() + +data class MessagesReadEvent(val isOut: Boolean, val peerId: Int, val messageId: Int) : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt index 14169b8e..0e42eb3c 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt @@ -1,6 +1,7 @@ package com.meloda.fast.screens.login import android.annotation.SuppressLint +import android.content.Intent import android.graphics.Bitmap import android.graphics.Typeface import android.os.Bundle @@ -13,25 +14,29 @@ import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.activity.addCallback +import androidx.core.content.edit +import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import coil.load -import coil.transform.RoundedCornersTransformation -import com.google.android.material.snackbar.Snackbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.meloda.fast.BuildConfig import com.meloda.fast.R import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants -import com.meloda.fast.base.BaseViewModelFragment import com.meloda.fast.base.viewmodel.* +import com.meloda.fast.common.AppGlobal import com.meloda.fast.databinding.DialogCaptchaBinding +import com.meloda.fast.databinding.DialogFastLoginBinding import com.meloda.fast.databinding.DialogValidationBinding import com.meloda.fast.databinding.FragmentLoginBinding -import com.meloda.fast.util.KeyboardUtils +import com.meloda.fast.extensions.* +import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.screens.main.MainActivity +import com.meloda.fast.screens.settings.SettingsPrefsFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -43,6 +48,19 @@ import kotlin.concurrent.schedule @AndroidEntryPoint class LoginFragment : BaseViewModelFragment(R.layout.fragment_login) { + companion object { + private const val ArgGetFastToken = "get_fast_token" + + fun newInstance(getFastToken: Boolean = false): LoginFragment { + val fragment = LoginFragment() + fragment.arguments = bundleOf( + ArgGetFastToken to getFastToken + ) + + return fragment + } + } + override val viewModel: LoginViewModel by viewModels() private val binding: FragmentLoginBinding by viewBinding() @@ -54,9 +72,12 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo private var captchaInputLayout: TextInputLayout? = null private var validationInputLayout: TextInputLayout? = null + private var isGetFastToken: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.unknownErrorDefaultText = getString(R.string.unknown_error_occurred) + isGetFastToken = requireArguments().getBoolean(ArgGetFastToken, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -65,35 +86,61 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo prepareViews() binding.loginInput.clearFocus() + + binding.useCrashReporter.isChecked = + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) + binding.useCrashReporter.setOnCheckedChangeListener { _, isChecked -> + AppGlobal.preferences.edit { + putBoolean(SettingsPrefsFragment.PrefEnableReporter, isChecked) + requireActivity().finishAffinity() + startActivity(Intent(requireContext(), MainActivity::class.java)) + } + } + + requireActivity().onBackPressedDispatcher.addCallback { + if (getView() == null) { + isEnabled = false + return@addCallback + } + + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + isEnabled = false + } + } } override fun onEvent(event: VkEvent) { super.onEvent(event) when (event) { - is ErrorEvent -> showErrorSnackbar(event.errorText) - is CaptchaEvent -> showCaptchaDialog(event.sid, event.image) - is ValidationEvent -> showValidationRequired(event.sid) - is SuccessAuth -> launchWebView() + StartProgressEvent -> onProgressStarted() + StopProgressEvent -> onProgressStopped() - is CodeSent -> showValidationDialog() - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() + is CaptchaRequiredEvent -> showCaptchaDialog(event.sid, event.image) + is ValidationRequiredEvent -> showValidationRequired(event.sid) + + LoginSuccessAuth -> { + viewModel.initUserConfig() + viewModel.openPrimaryScreen() + } + LoginCodeSent -> showValidationDialog() } } private fun onProgressStarted() { - binding.loginContainer.isVisible = false - binding.passwordContainer.isVisible = false - binding.auth.isVisible = false - binding.progress.isVisible = true + binding.loginContainer.gone() + binding.passwordContainer.gone() + binding.auth.gone() + binding.progressBar.visible() } private fun onProgressStopped() { - binding.loginContainer.isVisible = true - binding.passwordContainer.isVisible = true - binding.auth.isVisible = true - binding.progress.isVisible = false + binding.loginContainer.visible() + binding.passwordContainer.visible() + binding.auth.visible() + binding.progressBar.gone() } private fun prepareViews() { @@ -111,12 +158,20 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo clearCache(true) webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String, favicon: Bitmap?) { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + if (getView() == null) return + binding.webViewProgressBar.visible() + binding.webView.gone() + super.onPageStarted(view, url, favicon) parseAuthUrl(url) } - override fun onPageFinished(view: WebView, url: String?) { + override fun onPageFinished(view: WebView, url: String) { + if (getView() == null) return + binding.webViewProgressBar.gone() + binding.webView.visible() + super.onPageFinished(view, url) } } @@ -130,24 +185,25 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo } private fun launchWebView() { - binding.webView.isVisible = true - binding.webView.loadUrl( - "https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" + - "access_token=${UserConfig.accessToken}&" + - "sdk_package=com.meloda.fast.activity&" + - "sdk_fingerprint=AA88DSADAS8DG8FSA8&" + - "display=page&" + - "revoke=1&" + - "scope=136297695&" + - "redirect_uri=${ - URLEncoder.encode( - "https://oauth.vk.com/blank.html", - Charsets.UTF_8.toString() - ) - }&" + - "response_type=token&" + - "v=${VKConstants.API_VERSION}" - ) + binding.webViewContainer.visible() + + val urlToLoad = "https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" + + "access_token=${UserConfig.accessToken}&" + + "sdk_package=${BuildConfig.sdkPackage}&" + + "sdk_fingerprint=${BuildConfig.sdkFingerprint}&" + + "display=page&" + + "revoke=1&" + + "scope=${VKConstants.Auth.SCOPE.replace("messages,", "")}&" + + "redirect_uri=${ + URLEncoder.encode( + "https://oauth.vk.com/blank.html", + "utf-8" + ) + }&" + + "response_type=token&" + + "v=${VKConstants.API_VERSION}" + + binding.webView.loadUrl(urlToLoad) } private fun parseAuthUrl(url: String) { @@ -165,9 +221,20 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo return } - val token = authData.first + val fastToken = authData.first - UserConfig.fastToken = token + if (isGetFastToken) { + val userId = UserConfig.userId + val accessToken = UserConfig.accessToken + + UserConfig.fastToken = fastToken + + viewModel.saveAccount(userId, accessToken, fastToken) + } else { + val account = requireNotNull(viewModel.currentAccount) + viewModel.currentAccount = account.copy(fastToken = fastToken) + viewModel.initUserConfig() + } viewModel.openPrimaryScreen() } @@ -213,7 +280,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo return@edit if (event.action == EditorInfo.IME_ACTION_GO || (event.action == KeyEvent.ACTION_DOWN && (event.keyCode == KeyEvent.KEYCODE_ENTER || event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) ) { - KeyboardUtils.hideKeyboardFrom(binding.passwordInput) + binding.passwordInput.hideKeyboard() binding.auth.performClick() true } else false @@ -223,13 +290,42 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo private fun prepareAuthButton() { binding.auth.setOnClickListener { validateDataAndAuth() } binding.auth.setOnLongClickListener { - validateDataAndAuth(BuildConfig.vkLogin to BuildConfig.vkPassword) + showFastLoginAlert() true } } + private fun showFastLoginAlert() { + val dialogFastLoginBinding = DialogFastLoginBinding.inflate(layoutInflater, null, false) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.fast_login_title) + .setView(dialogFastLoginBinding.root) + .setPositiveButton(R.string.ok) { _, _ -> + val text = dialogFastLoginBinding.fastLoginText.trimmedText + if (text.isEmpty()) return@setPositiveButton + + val split = text.split(";") + try { + val login = split[0] + val password = split[1] + + binding.loginInput.setText(login) + binding.loginInput.selectLast() + + binding.passwordInput.setText(password) + binding.passwordInput.selectLast() + + validateDataAndAuth(login to password) + } catch (ignored: Exception) { + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun validateDataAndAuth(data: Pair? = null) { - if (binding.progress.isVisible) return + if (binding.progressBar.isVisible) return val loginString = data?.first ?: binding.loginInput.text.toString().trim() val passwordString = data?.second ?: binding.passwordInput.text.toString().trim() @@ -238,7 +334,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo lastLogin = loginString lastPassword = passwordString - KeyboardUtils.hideKeyboardFrom(requireView().findFocus()) + requireView().findFocus()?.hideKeyboard() viewModel.login( login = loginString, @@ -305,12 +401,14 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val captchaBinding = DialogCaptchaBinding.inflate(layoutInflater, null, false) captchaInputLayout = captchaBinding.captchaLayout - captchaBinding.image.load(captchaImage) { - crossfade(100) - transformations(RoundedCornersTransformation(4f)) - } + captchaBinding.image.loadWithGlide( + url = captchaImage, + crossFade = true + ) + captchaBinding.image.shapeAppearanceModel = + captchaBinding.image.shapeAppearanceModel.withCornerSize(16.dpToPx().toFloat()) - val builder = AlertDialog.Builder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireContext()) .setView(captchaBinding.root) .setCancelable(false) .setTitle(R.string.input_captcha) @@ -342,7 +440,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val validationBinding = DialogValidationBinding.inflate(layoutInflater, null, false) validationInputLayout = validationBinding.codeLayout - val builder = AlertDialog.Builder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireContext()) .setView(validationBinding.root) .setCancelable(false) .setTitle(R.string.input_validation_code) @@ -350,7 +448,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val dialog = builder.show() validationBinding.ok.setOnClickListener { - val validationCode = validationBinding.codeInput.text.toString().trim() + val validationCode = validationBinding.codeInput.trimmedText if (!validateInputData( loginString = null, @@ -374,15 +472,4 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo Toast.makeText(requireContext(), R.string.validation_required, Toast.LENGTH_LONG).show() viewModel.sendSms(validationSid) } - - private fun showErrorSnackbar(errorDescription: String) { - val snackbar = Snackbar.make( - requireView(), - getString(R.string.error, errorDescription), - Snackbar.LENGTH_LONG - ) - - snackbar.animationMode = Snackbar.ANIMATION_MODE_FADE - snackbar.show() - } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt index 53a55fbb..c0ad4819 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt @@ -4,26 +4,26 @@ import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants -import com.meloda.fast.api.VKException -import com.meloda.fast.api.network.auth.AuthDataSource import com.meloda.fast.api.network.auth.AuthDirectRequest import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.ErrorEvent +import com.meloda.fast.base.viewmodel.ErrorTextEvent import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.data.auth.AuthRepository +import com.meloda.fast.model.AppAccount import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val dataSource: AuthDataSource, - private val router: Router + private val authRepository: AuthRepository, + private val router: Router, + private val accounts: AccountsDao ) : BaseViewModel() { - companion object { - private const val TAG = "LoginViewModel" - } + var currentAccount: AppAccount? = null fun login( login: String, @@ -33,7 +33,7 @@ class LoginViewModel @Inject constructor( ) = viewModelScope.launch { makeJob( { - dataSource.auth( + authRepository.auth( AuthDirectRequest( grantType = VKConstants.Auth.GrantType.PASSWORD, clientId = VKConstants.VK_APP_ID, @@ -50,55 +50,47 @@ class LoginViewModel @Inject constructor( }, onAnswer = { if (it.userId == null || it.accessToken == null) { - sendEvent(ErrorEvent(unknownErrorDefaultText)) + sendEvent(ErrorTextEvent(unknownErrorDefaultText)) return@makeJob } - UserConfig.userId = it.userId - UserConfig.accessToken = it.accessToken - - sendEvent(SuccessAuth()) - - // TODO: 19-Oct-21 do somewhen -// makeJob({ -// dataSource.authWithApp( -// AuthWithAppRequest( -// accessToken = it.accessToken -// ) -// ) -// }, onAnswer = { kindaAnswer -> -// println("$TAG: AppAuthResponse: $kindaAnswer") -// } -// ) - - - }, - onError = { - if (it !is VKException) { - onError(it) - return@makeJob + currentAccount = AppAccount( + userId = it.userId, + accessToken = it.accessToken, + fastToken = null + ).also { account -> + UserConfig.currentUserId = account.userId + UserConfig.userId = account.userId + UserConfig.accessToken = account.accessToken } - // TODO: 9/27/2021 use `delay` parameter - twoFaCode?.let { sendEvent(CodeSent) } + sendEvent(LoginSuccessAuth) } ) } fun sendSms(validationSid: String) = viewModelScope.launch { - makeJob({ dataSource.sendSms(validationSid) }, - onAnswer = { sendEvent(CodeSent) } + makeJob({ authRepository.sendSms(validationSid) }, + onAnswer = { sendEvent(LoginCodeSent) } ) } fun openPrimaryScreen() { - router.navigateTo(Screens.Conversations()) + router.replaceScreen(Screens.Main()) } + fun initUserConfig() = viewModelScope.launch { + val account = requireNotNull(currentAccount) + UserConfig.fastToken = account.fastToken + + accounts.insert(listOf(account)) + } + + fun saveAccount(userId: Int, accessToken: String, fastToken: String?) = viewModelScope.launch { + val account = AppAccount(userId, accessToken, fastToken) + accounts.insert(listOf(account)) + } } -object CodeSent : VkEvent() - -data class SuccessAuth( - val haveAuthorized: Boolean = true -) : VkEvent() \ No newline at end of file +object LoginCodeSent : VkEvent() +object LoginSuccessAuth : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt new file mode 100644 index 00000000..381b6376 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt @@ -0,0 +1,350 @@ +package com.meloda.fast.screens.main + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.viewbinding.library.activity.viewBinding +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.size +import androidx.datastore.preferences.core.edit +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.lifecycleScope +import com.github.terrakok.cicerone.NavigatorHolder +import com.github.terrakok.cicerone.Router +import com.github.terrakok.cicerone.androidx.AppNavigator +import com.github.terrakok.cicerone.androidx.FragmentScreen +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.longpoll.LongPollUpdatesParser +import com.meloda.fast.base.BaseActivity +import com.meloda.fast.common.* +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.databinding.ActivityMainBinding +import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.screens.settings.SettingsPrefsFragment +import com.meloda.fast.service.LongPollService +import com.meloda.fast.service.OnlineService +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.analytics.Analytics +import com.microsoft.appcenter.crashes.Crashes +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +@AndroidEntryPoint +class MainActivity : BaseActivity(R.layout.activity_main) { + + private val navigator = object : AppNavigator(this, R.id.root_fragment_container) { + override fun setupFragmentTransaction( + screen: FragmentScreen, + fragmentTransaction: FragmentTransaction, + currentFragment: Fragment?, + nextFragment: Fragment + ) { + } + } + + @Inject + lateinit var navigatorHolder: NavigatorHolder + + @Inject + lateinit var router: Router + + @Inject + lateinit var updateManager: UpdateManager + + @Inject + lateinit var accountsDao: AccountsDao + + @Inject + lateinit var updatesParser: LongPollUpdatesParser + + val binding: ActivityMainBinding by viewBinding() + + var useNavDrawer: Boolean by Delegates.observable(false) { _, _, _ -> + syncNavigationMode() + } + + override fun onResumeFragments() { + navigatorHolder.setNavigator(navigator) + super.onResumeFragments() + } + + override fun onPause() { + navigatorHolder.removeNavigator() + super.onPause() + } + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + createNotificationChannels() + + AppCenter.configure(application, BuildConfig.msAppCenterAppToken) + + if (!BuildConfig.DEBUG) { + AppCenter.start(Analytics::class.java) + } + + AppCenter.start(Crashes::class.java) + Crashes.setEnabled( + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) + ) + + binding.navigationBar.gone() + + lifecycleScope.launch { + dataStore.data.map { data -> + useNavDrawer = data[AppSettings.keyUseNavigationDrawer] ?: false + }.collect() + } + + if (UserConfig.currentUserId == -1) { + openMainScreen() + } else { + initUserConfig() + } + + updateManager.checkUpdates { item, _ -> + if (item != null) { + router.navigateTo(Screens.Updates(item)) + } + } + + binding.drawer.getHeaderView(0).setOnLongClickListener { + lifecycleScope.launch { + dataStore.edit { settings -> + val useNavDrawer = settings[AppSettings.keyUseNavigationDrawer] ?: false + settings[AppSettings.keyUseNavigationDrawer] = !useNavDrawer + + finish() + startActivity(Intent(this@MainActivity, MainActivity::class.java)) + } + } + true + } + + syncNavigationMode() + binding.navigationBar.selectedItemId = R.id.messages + + supportFragmentManager.setFragmentResultListener( + MainFragment.KeyStartServices, + this + ) { _, result -> + val enable = result.getBoolean("enable", true) + if (enable) { + startServices() + } else { + stopServices() + } + } + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val dialogsName = "Dialogs" + val dialogsDescriptionText = "Channel for dialogs notifications" + val dialogsImportance = NotificationManager.IMPORTANCE_MAX + val dialogsChannel = NotificationChannel("simple_notifications", dialogsName, dialogsImportance).apply { + description = dialogsDescriptionText + } + + val longPollName = "Long Polling" + val longPollDescriptionText = "Channel for long polling service (temporary)" + val longPollImportance = NotificationManager.IMPORTANCE_NONE + val longPollChannel = NotificationChannel("long_polling", longPollName, longPollImportance).apply { + description = longPollDescriptionText + } + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dialogsChannel) + notificationManager.createNotificationChannel(longPollChannel) + } + } + + override fun onResume() { + super.onResume() + + Crashes.getLastSessionCrashReport().thenAccept { report -> + if (report != null) { + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefShowCrashAlert, + true + ) + ) { + val stackTrace = report.stackTrace + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.app_crash_occurred) + .setMessage("Stacktrace: $stackTrace") + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.copy) { _, _ -> + AppGlobal.clipboardManager.setPrimaryClip( + ClipData.newPlainText( + "Fast_Crash_Report", + stackTrace + ) + ) + Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() + } + .setNeutralButton(R.string.share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, stackTrace) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Share stacktrace") + try { + startActivity(shareIntent) + } catch (e: Exception) { + e.printStackTrace() + + runOnUiThread { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage("Can't share") + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + .show() + } + } + } + + if (AppGlobal.preferences.getBoolean(LongPollService.KeyLongPollWasDestroyed, false)) { + AppGlobal.preferences.edit { + putBoolean(LongPollService.KeyLongPollWasDestroyed, false) + } + + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefShowDestroyedLongPollAlert, + false + ) + ) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage("Long poll was destroyed.") + .setPositiveButton("Restart this shit") { _, _ -> + startServices() + } + .setCancelable(false) + .show() + } else { + startServices() + } + } + } + + private fun startServices() { + ContextCompat.startForegroundService(this, Intent(this, LongPollService::class.java)) + startService(Intent(this, OnlineService::class.java)) + } + + private fun stopServices() { + stopService(Intent(this, LongPollService::class.java)) + stopService(Intent(this, OnlineService::class.java)) + } + + private fun addTestMenuItem() { + val test = binding.navigationBar.menu.add("Test") + test.setIcon(R.drawable.ic_round_settings_24) + test.setOnMenuItemClickListener { + if (binding.navigationBar.menu.size < 5) { + addClearMenuItem() + } else { + binding.navigationBar.menu.clear() + addTestMenuItem() + } + + true + } + } + + private fun addClearMenuItem() { + binding.navigationBar.menu.add("Test").run { + setIcon(R.drawable.ic_round_settings_24) + setOnMenuItemClickListener { + binding.navigationBar.menu.clear() + addTestMenuItem() + true + } + } + } + + private fun initUserConfig() { + if (UserConfig.currentUserId == -1) return + + lifecycleScope.launch { + val accounts = accountsDao.getAll() + + Log.d("MainActivity", "initUserConfig: accounts: $accounts") + if (accounts.isNotEmpty()) { + val currentAccount = accounts.find { it.userId == UserConfig.currentUserId } + if (currentAccount != null) { + UserConfig.parse(currentAccount) + } + + openMainScreen() + } else { + openMainScreen() + } + } + } + + private fun openMainScreen() { + router.newRootScreen(Screens.Main()) + } + + private fun syncNavigationMode() { +// binding.navigationBar.toggleVisibility(!useNavDrawer) + binding.drawerLayout.setDrawerLockMode( + if (useNavDrawer) DrawerLayout.LOCK_MODE_UNLOCKED + else DrawerLayout.LOCK_MODE_LOCKED_CLOSED + ) + } + + fun toggleNavBarVisibility(isVisible: Boolean, smooth: Boolean = false) { + if (true) { + binding.navigationBar.gone() + return + } + + if (useNavDrawer) { + binding.navigationBar.gone() + } else { + if (smooth) { + binding.navigationBar.toggleVisibility(isVisible) + } else { + binding.navigationBar.toggleVisibility(isVisible) + } + } + } + + override fun onDestroy() { + super.onDestroy() + stopServices() + updatesParser.clearListeners() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt index 727f80d5..e2dcec48 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt @@ -4,13 +4,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels -import com.meloda.fast.base.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.VkEvent import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainFragment : BaseViewModelFragment() { + companion object { + const val KeyStartServices = "start_services" + } + override val viewModel: MainViewModel by viewModels() override fun onCreateView( @@ -24,6 +31,22 @@ class MainFragment : BaseViewModelFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.checkSession(requireContext()) + viewModel.checkSession() + } + + override fun onEvent(event: VkEvent) { + super.onEvent(event) + + when (event) { + StartServicesEvent -> { + setFragmentResult(KeyStartServices, bundleOf("enable" to true)) + } + StopServicesEvent -> { + setFragmentResult(KeyStartServices, bundleOf("enable" to false)) + } + is SetNavBarVisibilityEvent -> { + (requireActivity() as MainActivity).toggleNavBarVisibility(event.isVisible) + } + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt index 96fd48ac..05e62829 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt @@ -1,30 +1,59 @@ package com.meloda.fast.screens.main -import android.content.Context -import android.content.Intent +import android.util.Log +import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router +import com.github.terrakok.cicerone.Screen import com.meloda.fast.api.UserConfig import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens -import com.meloda.fast.service.MessagesUpdateService -import com.meloda.fast.service.OnlineService import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor(private val router: Router) : BaseViewModel() { - fun checkSession(context: Context) { - if (UserConfig.isLoggedIn()) { - router.navigateTo(Screens.Conversations()) + fun checkSession() = viewModelScope.launch { + val currentUserId = UserConfig.currentUserId + val userId = UserConfig.userId + val accessToken = UserConfig.accessToken + val fastToken = UserConfig.fastToken - context.run { - startService(Intent(this, MessagesUpdateService::class.java)) - startService(Intent(this, OnlineService::class.java)) + viewModelScope.launch { + sendEvent(SetNavBarVisibilityEvent(UserConfig.isLoggedIn())) + } + + Log.d( + "MainViewModel", + "checkSession: currentUserId: $currentUserId; userId: $userId; accessToken: $accessToken; fastToken: $fastToken" + ) + + when { +// fastToken == null -> { +// sendEvent(StopServicesEvent) +// openScreen(Screens.Login(true)) +// } + UserConfig.isLoggedIn() -> { + sendEvent(StartServicesEvent) + openScreen(Screens.Conversations()) + } + else -> { + sendEvent(StopServicesEvent) + openScreen(Screens.Login()) } - } else { - router.navigateTo(Screens.Login()) } } -} \ No newline at end of file + private fun openScreen(screen: Screen) { + router.replaceScreen(screen) + } + +} + +data class SetNavBarVisibilityEvent(val isVisible: Boolean) : VkEvent() + +object StartServicesEvent : VkEvent() + +object StopServicesEvent : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt index a12ece1c..33e919a0 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt @@ -6,8 +6,11 @@ import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.util.Log import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.Space +import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.LinearLayoutCompat import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat @@ -21,6 +24,7 @@ import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.databinding.* import com.meloda.fast.extensions.* import com.meloda.fast.extensions.ImageLoader.clear @@ -30,11 +34,11 @@ import java.text.SimpleDateFormat import java.util.* import kotlin.math.roundToInt -// TODO: 9/29/2021 use recyclerview for viewing attachments class AttachmentInflater constructor( private val context: Context, private val container: LinearLayoutCompat, - private val textContainer: LinearLayoutCompat, + private val replyContainer: FrameLayout, + private val timeReadContainer: View, private val message: VkMessage, private val profiles: Map, private val groups: Map @@ -52,24 +56,66 @@ class AttachmentInflater constructor( R.color.colorSecondary ) + private val timeReadBackground = ContextCompat.getDrawable( + context, + R.drawable.time_read_indicator_on_attachments_background + ) + private var photoClickListener: ((url: String) -> Unit)? = null + private var replyClickListener: ((replyMessage: VkMessage) -> Unit)? = null + private var forwardsClickListener: ((forwards: List) -> Unit)? = null private val displayMetrics get() = Resources.getSystem().displayMetrics - fun setPhotoClickListener(unit: ((url: String) -> Unit)?): AttachmentInflater { - this.photoClickListener = unit + fun withPhotoClickListener(block: ((url: String) -> Unit)?): AttachmentInflater { + this.photoClickListener = block + return this + } + + fun withReplyClickListener(block: ((replyMessage: VkMessage) -> Unit)?): AttachmentInflater { + this.replyClickListener = block + return this + } + + fun withForwardsClickListener(block: ((forwards: List) -> Unit)?): AttachmentInflater { + this.forwardsClickListener = block return this } fun inflate() { container.removeAllViews() + replyContainer.removeAllViews() - if (textContainer.childCount > 1) { - textContainer.removeViews(1, textContainer.childCount - 1) + replyContainer.toggleVisibility(message.hasReply()) + container.toggleVisibility( + !message.attachments.isNullOrEmpty() + || message.hasForwards() + || message.hasGeo() + ) + + timeReadContainer.run { + updateLayoutParams { + val margin = (if (container.isVisible) 6 else 2).dpToPx() + updateMarginsRelative(end = margin, bottom = margin) + } + + background = if (container.isVisible) timeReadBackground else null + } + + if (message.hasReply()) { + reply(requireNotNull(message.replyMessage)) + } + + if (message.hasForwards()) { + forwards(requireNotNull(message.forwards)) + } + + if (message.hasGeo()) { + geo(requireNotNull(message.geo)) } if (message.attachments.isNullOrEmpty()) return - attachments = message.attachments!! + attachments = requireNotNull(message.attachments) if (attachments.size == 1) { when (val attachment = attachments[0]) { @@ -109,13 +155,64 @@ class AttachmentInflater constructor( is VkFile -> file(attachment) is VkLink -> link(attachment) - else -> Log.e( - "Attachment inflater", - "Unknown attachment type: ${attachment.javaClass.name}" - ) + else -> unknown(attachment) } } + } + private fun unknown(attachment: VkAttachment) { + val attachmentType = attachment.javaClass.name + Log.e( + "Attachment inflater", + "Unknown attachment type: $attachmentType" + ) + + val textView = AppCompatTextView(context) + textView.text = attachmentType + + container.addView(textView) + } + + private fun reply(replyMessage: VkMessage) { + val binding = ItemMessageAttachmentReplyBinding.inflate(inflater, replyContainer, true) + binding.root.setOnClickListener { replyClickListener?.invoke(replyMessage) } + + val attachmentText = VkUtils.getAttachmentText( + context = context, + message = replyMessage + ) + + val forwardsMessage = if (replyMessage.text == null) VkUtils.getForwardsText( + context = context, + message = replyMessage + ) else null + + val messageText = attachmentText ?: forwardsMessage ?: (replyMessage.text.orDots()).run { + VkUtils.prepareMessageText(this) + } + + binding.text.text = messageText + + val replyUserGroup = VkUtils.getMessageUserGroup(replyMessage, profiles, groups) + + val fromUser: VkUser? = replyUserGroup.first + val fromGroup: VkGroup? = replyUserGroup.second + + val title = VkUtils.getMessageTitle(replyMessage, fromUser, fromGroup) + binding.title.text = title.orDots() + } + + private fun forwards(forwards: List) { + val binding = ItemMessageAttachmentForwardsBinding.inflate(inflater, container, true) + + binding.root.setOnClickListener { forwardsClickListener?.invoke(forwards) } + } + + private fun geo(geo: BaseVkMessage.Geo) { + val binding = ItemMessageAttachmentGeoBinding.inflate(inflater, container, true) + + binding.location.text = geo.place.title + binding.location.toggleVisibilityIfHasContent() } private fun photo(photo: VkPhoto) { @@ -140,7 +237,7 @@ class AttachmentInflater constructor( val binding = ItemMessageAttachmentPhotoBinding.inflate(inflater, container, true) - val cornersRadius = 8.dpToPx().toFloat() + val cornersRadius = 17.dpToPx().toFloat() binding.border.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius) @@ -159,10 +256,8 @@ class AttachmentInflater constructor( binding.image.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius * 0.8F) - if (photoClickListener != null) { - setOnClickListener { photoClickListener?.invoke(size.url) } - } else { - setOnClickListener(null) + setOnClickListener { + photo.getMaxSize()?.let { size -> photoClickListener?.invoke(size.url) } } loadWithGlide( @@ -194,7 +289,7 @@ class AttachmentInflater constructor( } val ratio = "${size.width}:${size.height}" - val cornersRadius = 8.dpToPx().toFloat() + val cornersRadius = 17.dpToPx().toFloat() binding.border.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius) @@ -243,7 +338,9 @@ class AttachmentInflater constructor( } private fun link(link: VkLink) { - val binding = ItemMessageAttachmentLinkBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentLinkBinding.inflate( + inflater, container, true + ) binding.title.text = link.title binding.title.toggleVisibility(!link.title.isNullOrBlank()) @@ -283,7 +380,7 @@ class AttachmentInflater constructor( } private fun wall(wall: VkWall) { - val binding = ItemMessageAttachmentWallPostBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentWallPostBinding.inflate(inflater, container, true) val group = if (wall.fromId > 0) null else groups[wall.fromId] val user = if (wall.fromId < 0) null else profiles[wall.fromId] @@ -300,11 +397,11 @@ class AttachmentInflater constructor( else -> null } - val title = when { + val title = (when { group == null && user != null -> user.fullName user == null && group != null -> group.name - else -> "..." - } + else -> null + }).orDots() binding.postTitle.text = context.getString(postTitleRes) binding.postTitle.gone() @@ -326,7 +423,7 @@ class AttachmentInflater constructor( } private fun voice(voiceMessage: VkVoiceMessage) { - val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, container, true) if (message.isOut) { val padding = 6.dpToPx() @@ -349,7 +446,7 @@ class AttachmentInflater constructor( } private fun call(call: VkCall) { - val binding = ItemMessageAttachmentCallBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentCallBinding.inflate(inflater, container, true) if (message.isOut) binding.root.updatePadding( diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt new file mode 100644 index 00000000..c9ca0d64 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt @@ -0,0 +1,231 @@ +package com.meloda.fast.screens.messages + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import com.google.android.material.shape.ShapeAppearanceModel +import com.meloda.fast.R +import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.base.adapter.BaseAdapter +import com.meloda.fast.base.adapter.BaseHolder +import com.meloda.fast.databinding.ItemUploadedAttachmentAudioBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentFileBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentPhotoBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentVideoBinding +import com.meloda.fast.extensions.ImageLoader.clear +import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.extensions.dpToPx +import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.extensions.visible + +class AttachmentsAdapter( + context: Context, + preAddedValues: List, + private var onRemoveClickedListener: ((position: Int) -> Unit)? = null, +) : BaseAdapter( + context, comparator, preAddedValues +) { + + private companion object { + + private const val TypePhoto = 1 + private const val TypeVideo = 2 + private const val TypeAudio = 3 + private const val TypeFile = 4 + + private val comparator = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: VkAttachment, newItem: VkAttachment): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: VkAttachment, newItem: VkAttachment): Boolean { + return false + } + } + } + + private val colorPrimaryVariant = ContextCompat.getColor(context, R.color.colorPrimaryVariant) + + open inner class Holder(v: View) : BaseHolder(v) + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is VkPhoto -> TypePhoto + is VkVideo -> TypeVideo + is VkAudio -> TypeAudio + is VkFile -> TypeFile + else -> super.getItemViewType(position) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return when (viewType) { + TypePhoto -> PhotoViewHolder( + ItemUploadedAttachmentPhotoBinding.inflate(inflater, parent, false) + ) + TypeVideo -> VideoViewHolder( + ItemUploadedAttachmentVideoBinding.inflate(inflater, parent, false) + ) + TypeAudio -> AudioViewHolder( + ItemUploadedAttachmentAudioBinding.inflate(inflater, parent, false) + ) + TypeFile -> FileViewHolder( + ItemUploadedAttachmentFileBinding.inflate(inflater, parent, false) + ) + else -> Holder(View(context)) + } + } + + inner class PhotoViewHolder( + private val binding: ItemUploadedAttachmentPhotoBinding + ) : Holder(binding.root) { + + init { + binding.image.shapeAppearanceModel = + binding.image.shapeAppearanceModel.withCornerSize(18.dpToPx().toFloat()) + } + + override fun bind(position: Int) { + val photo = getItem(position) as VkPhoto + + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = photo.getSizeOrSmaller(VkPhoto.SIZE_TYPE_807)?.url, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { binding.progressBar.gone() } + ) + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + } + + inner class VideoViewHolder( + private val binding: ItemUploadedAttachmentVideoBinding + ) : Holder(binding.root) { + init { + val cornerSizedShapeAppearanceModel = ShapeAppearanceModel().withCornerSize( + 18.dpToPx().toFloat() + ) + + binding.image.shapeAppearanceModel = cornerSizedShapeAppearanceModel + binding.coloredBackground.shapeAppearanceModel = cornerSizedShapeAppearanceModel + } + + override fun bind(position: Int) { + val video = getItem(position) as VkVideo + + binding.title.text = video.title + + val previewSrc = video.imageForWidthAtLeast(300) + binding.image.toggleVisibility(previewSrc != null) + binding.coloredBackground.toggleVisibility(previewSrc == null) + binding.videoIcon.toggleVisibility(previewSrc == null) + + if (previewSrc != null) { + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = previewSrc.url, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { showPlaceholder() } + ) + } else { + binding.progressBar.gone() + binding.image.clear() + } + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + + private fun showPlaceholder() { + binding.coloredBackground.visible() + binding.videoIcon.visible() + binding.image.clear() + binding.image.gone() + binding.progressBar.gone() + } + } + + inner class AudioViewHolder( + private val binding: ItemUploadedAttachmentAudioBinding + ) : Holder(binding.root) { + init { + binding.coloredBackground.shapeAppearanceModel = + binding.coloredBackground.shapeAppearanceModel.withCornerSize(18.dpToPx().toFloat()) + } + + override fun bind(position: Int) { + val audio = getItem(position) as VkAudio + + binding.title.text = audio.title + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + } + + inner class FileViewHolder( + private val binding: ItemUploadedAttachmentFileBinding + ) : Holder(binding.root) { + + init { + val cornerSizedShapeAppearanceModel = ShapeAppearanceModel().withCornerSize( + 18.dpToPx().toFloat() + ) + + binding.image.shapeAppearanceModel = cornerSizedShapeAppearanceModel + binding.coloredBackground.shapeAppearanceModel = cornerSizedShapeAppearanceModel + } + + override fun bind(position: Int) { + val file = getItem(position) as VkFile + + binding.title.text = file.title + + val previewSrc = file.preview?.photo?.sizes?.get(0) + binding.image.toggleVisibility(previewSrc != null) + binding.coloredBackground.toggleVisibility(previewSrc == null) + binding.fileIcon.toggleVisibility(previewSrc == null) + + if (previewSrc != null) { + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = previewSrc.src, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { showPlaceholder() } + ) + } else { + binding.progressBar.gone() + binding.image.clear() + } + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + + private fun showPlaceholder() { + binding.coloredBackground.visible() + binding.fileIcon.visible() + binding.image.clear() + binding.image.gone() + binding.progressBar.gone() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt new file mode 100644 index 00000000..5362b612 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt @@ -0,0 +1,98 @@ +package com.meloda.fast.screens.messages + +import android.os.Bundle +import android.view.View +import android.viewbinding.library.fragment.viewBinding +import androidx.core.os.bundleOf +import com.meloda.fast.R +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.base.BaseFragment +import com.meloda.fast.common.Screens +import com.meloda.fast.databinding.FragmentForwardedMessagesBinding + +class ForwardedMessagesFragment : BaseFragment(R.layout.fragment_forwarded_messages) { + + companion object { + private const val ArgConversation = "conversation" + private const val ArgMessages = "messages" + private const val ArgProfiles = "profiles" + private const val ArgGroups = "groups" + + fun newInstance( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ): ForwardedMessagesFragment { + val fragment = ForwardedMessagesFragment() + fragment.arguments = bundleOf( + ArgConversation to conversation, + ArgMessages to messages, + ArgProfiles to profiles, + ArgGroups to groups + ) + + return fragment + } + } + + private val binding: FragmentForwardedMessagesBinding by viewBinding() + + private var conversation: VkConversation? = null + private var messages: List = emptyList() + private var profiles: HashMap = hashMapOf() + private var groups: HashMap = hashMapOf() + + private val adapter: MessagesHistoryAdapter by lazy { + MessagesHistoryAdapter( + this, requireNotNull(conversation), profiles, groups + ) + } + + @Suppress("UNCHECKED_CAST") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requireArguments().run { + conversation = getParcelable(ArgConversation) + messages = getParcelableArrayList(ArgMessages) ?: emptyList() + + profiles = getSerializable(ArgProfiles) as? HashMap ?: hashMapOf() + groups = getSerializable(ArgGroups) as? HashMap ?: hashMapOf() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + fillRecyclerView() + } + + private fun fillRecyclerView() { + adapter.setItems(messages) + binding.recyclerView.adapter = adapter + } + + fun scrollToMessage(messageId: Int) { + adapter.searchMessageIndex(messageId)?.let { index -> + binding.recyclerView.scrollToPosition(index) + } + } + + fun openForwardsScreen( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) { + requireActivityRouter().navigateTo( + Screens.ForwardedMessages(conversation, messages, profiles, groups) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt index f58ac1af..77f063f3 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt @@ -2,16 +2,17 @@ package com.meloda.fast.screens.messages import android.annotation.SuppressLint import android.content.Context +import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.util.Log import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.util.ObjectsCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import coil.load import com.meloda.fast.R import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation @@ -24,6 +25,7 @@ import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemMessageInBinding import com.meloda.fast.databinding.ItemMessageOutBinding import com.meloda.fast.databinding.ItemMessageServiceBinding +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import com.meloda.fast.extensions.dpToPx import com.meloda.fast.model.DataItem @@ -31,8 +33,35 @@ class MessagesHistoryAdapter constructor( context: Context, val conversation: VkConversation, val profiles: HashMap = hashMapOf(), - val groups: HashMap = hashMapOf() -) : BaseAdapter, MessagesHistoryAdapter.BasicHolder>(context, Comparator) { + val groups: HashMap = hashMapOf(), +) : BaseAdapter, MessagesHistoryAdapter.BasicHolder>( + context, + Comparator +) { + + constructor( + fragment: MessagesHistoryFragment, + conversation: VkConversation, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf(), + ) : this(fragment.requireContext(), conversation, profiles, groups) { + this.messagesHistoryFragment = fragment + } + + constructor( + fragment: ForwardedMessagesFragment, + conversation: VkConversation, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) : this(fragment.requireContext(), conversation, profiles, groups) { + this.isForwards = true + this.forwardedMessagesFragment = fragment + } + + private var isForwards: Boolean = false + + private var messagesHistoryFragment: MessagesHistoryFragment? = null + private var forwardedMessagesFragment: ForwardedMessagesFragment? = null var avatarLongClickListener: ((position: Int) -> Unit)? = null @@ -86,14 +115,14 @@ class MessagesHistoryAdapter constructor( if (holder is Header || holder is Footer) { Log.d( "MessagesHistoryAdapter", - "onBindViewHolder: index $position, holder is ${holder.javaClass.simpleName}. Skip" + "onBindViewHolder: index $position, holder: ${holder.javaClass.simpleName}. Skip" ) return } Log.d( "MessagesHistoryAdapter", - "onBindViewHolder: index $position, holder is ${holder.javaClass.simpleName}. Bind" + "onBindViewHolder: index $position, holder: ${holder.javaClass.simpleName}. Bind" ) initListeners(holder.itemView, position) @@ -121,7 +150,7 @@ class MessagesHistoryAdapter constructor( private val binding: ItemMessageInBinding ) : BasicHolder(binding.root) { - override fun bind(position: Int) { + override fun bind(position: Int, payloads: MutableList?) { val message = getItem(position) as VkMessage val prevMessage = getVkMessage(getOrNull(position - 1)) @@ -129,6 +158,7 @@ class MessagesHistoryAdapter constructor( MessagesPreparator( context = context, + payloads = payloads, root = binding.root, @@ -143,19 +173,38 @@ class MessagesHistoryAdapter constructor( bubble = binding.bubble, text = binding.text, spacer = binding.spacer, - unread = binding.unread, + messageState = binding.messageState, + time = binding.time, - textContainer = binding.textContainer, + replyContainer = binding.replyContainer, attachmentContainer = binding.attachmentContainer, - attachmentSpacer = binding.attachmentSpacer, + timeReadContainer = binding.timeReadContainer, profiles = profiles, - groups = groups - ).setPhotoClickListener { - Toast.makeText(context, "Photo url: $it", Toast.LENGTH_LONG).show() - }.prepare() + groups = groups, - binding.avatar.setOnLongClickListener() { + isForwards = isForwards + ) + .withPhotoClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(it)).run { + context.startActivity(this) + } + } + .withReplyClickListener { + messagesHistoryFragment?.scrollToMessage(it.id) + forwardedMessagesFragment?.scrollToMessage(it.id) + } + .withForwardsClickListener { messages -> + messagesHistoryFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + forwardedMessagesFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + } + .prepare() + + binding.avatar.setOnLongClickListener { avatarLongClickListener?.invoke(position) true } @@ -166,12 +215,13 @@ class MessagesHistoryAdapter constructor( private val binding: ItemMessageOutBinding ) : BasicHolder(binding.root) { - override fun bind(position: Int) { + override fun bind(position: Int, payloads: MutableList?) { val message = getItem(position) as VkMessage val prevMessage = getVkMessage(getOrNull(position - 1)) MessagesPreparator( context = context, + payloads = payloads, root = binding.root, conversation = conversation, message = message, @@ -180,15 +230,36 @@ class MessagesHistoryAdapter constructor( bubble = binding.bubble, text = binding.text, spacer = binding.spacer, - unread = binding.unread, + messageState = binding.messageState, + time = binding.time, - textContainer = binding.textContainer, + timeReadContainer = binding.timeReadContainer, + replyContainer = binding.replyContainer, attachmentContainer = binding.attachmentContainer, - attachmentSpacer = binding.attachmentSpacer, profiles = profiles, - groups = groups - ).prepare() + groups = groups, + + isForwards = isForwards + ) + .withPhotoClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(it)).run { + context.startActivity(this) + } + } + .withReplyClickListener { + messagesHistoryFragment?.scrollToMessage(it.id) + forwardedMessagesFragment?.scrollToMessage(it.id) + } + .withForwardsClickListener { messages -> + messagesHistoryFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + forwardedMessagesFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + } + .prepare() } } @@ -239,14 +310,56 @@ class MessagesHistoryAdapter constructor( size.height ) - binding.photo.load(size.url) { - crossfade(150) - fallback(ColorDrawable(Color.LTGRAY)) + binding.photo.loadWithGlide( + url = size.url, + crossFade = true, + placeholderDrawable = ColorDrawable(Color.LTGRAY) + ) + + binding.photo.setOnClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(size.url)).run { + context.startActivity(this) + } } } } } + fun containsUnreadMessages(isOutgoingMessages: Boolean = false): Boolean { + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.isOut == isOutgoingMessages && !item.isRead(conversation)) { + return true + } + } + return false + } + + fun containsRandomId(randomId: Int): Boolean { + if (randomId == 0) return false + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.randomId == randomId) return true + } + + return false + } + + fun containsId(id: Int): Boolean { + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.id == id) return true + } + + return false + } + fun getVkMessage(item: DataItem<*>?): VkMessage? { if (item == null) return null if (item is VkMessage) return item @@ -287,16 +400,19 @@ class MessagesHistoryAdapter constructor( return if (oldItem is VkMessage && newItem is VkMessage) { oldItem.id == newItem.id } else { - oldItem is DataItem.Footer && newItem is DataItem.Footer - || oldItem is DataItem.Header && newItem is DataItem.Header + oldItem is DataItem.Footer && newItem is DataItem.Footer || + oldItem is DataItem.Header && newItem is DataItem.Header || + ObjectsCompat.equals(oldItem, newItem) } } - @SuppressLint("DiffUtilEquals") override fun areContentsTheSame( oldItem: DataItem, newItem: DataItem - ): Boolean = oldItem == newItem + ): Boolean { + + return ObjectsCompat.equals(oldItem, newItem) && ((oldItem is VkMessage && newItem is VkMessage) && oldItem.state == newItem.state) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt index 0d27f113..e72cffe1 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt @@ -1,53 +1,61 @@ package com.meloda.fast.screens.messages -import android.animation.ValueAnimator -import android.content.res.ColorStateList -import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.os.Bundle -import android.text.TextUtils +import android.os.Environment +import android.provider.OpenableColumns +import android.util.Log import android.view.View -import android.view.animation.LinearInterpolator import android.viewbinding.library.fragment.viewBinding +import android.widget.ImageView import android.widget.Toast -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu +import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.core.view.setPadding -import androidx.core.view.updateLayoutParams import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import coil.load +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.common.net.MediaType import com.meloda.fast.R import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils 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.base.BaseViewModelFragment -import com.meloda.fast.base.viewmodel.StartProgressEvent -import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.Screens +import com.meloda.fast.data.files.FilesRepository import com.meloda.fast.databinding.DialogMessageDeleteBinding import com.meloda.fast.databinding.FragmentMessagesHistoryBinding import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.clear import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.screens.conversations.MessagesNewEvent +import com.meloda.fast.screens.settings.SettingsPrefsFragment import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.TimeUtils +import com.meloda.fast.view.SpaceItemDecoration import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File import java.text.SimpleDateFormat import java.util.* import kotlin.concurrent.schedule -import kotlin.math.roundToInt - +import kotlin.math.abs +import kotlin.random.Random @AndroidEntryPoint class MessagesHistoryFragment : @@ -60,9 +68,17 @@ class MessagesHistoryFragment : private const val ATTACHMENT_PANEL_ANIMATION_DURATION = 150L - fun newInstance(bundle: Bundle): MessagesHistoryFragment { + fun newInstance( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ): MessagesHistoryFragment { val fragment = MessagesHistoryFragment() - fragment.arguments = bundle + fragment.arguments = bundleOf( + ARG_CONVERSATION to conversation, + ARG_USER to user, + ARG_GROUP to group + ) return fragment } @@ -71,7 +87,39 @@ class MessagesHistoryFragment : override val viewModel: MessagesHistoryViewModel by viewModels() private val binding: FragmentMessagesHistoryBinding by viewBinding() - private val action = MutableLiveData() + private var pickFile: Boolean = false + + private val attachmentsToLoad = mutableListOf() + + private val getContent = + registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uriList: List? -> + if (uriList.isNullOrEmpty()) { + return@registerForActivityResult + } + + if (uriList.size > 10) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Select no more than 10 files") + .setPositiveButton(R.string.ok, null) + .show() + return@registerForActivityResult + } + + viewLifecycleOwner.lifecycleScope.launch { + val uploadFlow = flow { + uriList.forEach { uri -> + processFileFromStorage(uri) + emit(null) + } + } + + uploadFlow.collect() + } + } + + + private val actionState = MutableLiveData() private enum class Action { RECORD, SEND, EDIT, DELETE @@ -90,19 +138,35 @@ class MessagesHistoryFragment : } private val adapter: MessagesHistoryAdapter by lazy { - MessagesHistoryAdapter(requireContext(), conversation).also { + MessagesHistoryAdapter(this, conversation).also { it.itemClickListener = this::onItemClick it.avatarLongClickListener = this::onAvatarLongClickListener } } + private val attachmentsAdapter: AttachmentsAdapter by lazy { + AttachmentsAdapter( + requireContext(), + emptyList(), + onRemoveClickedListener = { position -> + removeAttachment(attachmentsAdapter[position]) + } + ) + } + private var timestampTimer: Timer? = null private lateinit var attachmentController: AttachmentPanelController + init { + shouldNavBarShown = false + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + attachmentController = AttachmentPanelController().init() val title = when { @@ -112,12 +176,7 @@ class MessagesHistoryFragment : else -> null } - binding.back.setOnClickListener { requireActivity().onBackPressed() } - - binding.title.ellipsize = TextUtils.TruncateAt.END - binding.status.ellipsize = TextUtils.TruncateAt.END - - binding.title.text = title ?: "..." + binding.toolbar.title = title.orDots() val status = when { conversation.isChat() -> "${conversation.membersCount} members" @@ -125,7 +184,10 @@ class MessagesHistoryFragment : // TODO: 9/15/2021 user normal time user?.online == true -> "Online" user?.lastSeen != null -> "Last seen at ${ - SimpleDateFormat("HH:mm", Locale.getDefault()).format(user?.lastSeen!! * 1000L) + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(user?.lastSeen!! * 1000L) }" else -> if (user?.lastSeenStatus != null) "Last seen ${user?.lastSeenStatus!!}" else "Last seen recently" } @@ -133,7 +195,7 @@ class MessagesHistoryFragment : else -> null } - binding.status.text = status ?: "..." + binding.toolbar.subtitle = status.orDots() prepareAvatar() @@ -153,25 +215,48 @@ class MessagesHistoryFragment : if (lastVisiblePosition <= adapter.lastPosition - 10) return@addOnLayoutChangeListener binding.recyclerView.postDelayed({ + if (getView() == null) return@postDelayed binding.recyclerView.scrollToPosition(adapter.lastPosition) }, 25) } + binding.unreadCounter.setOnClickListener { + binding.recyclerView.scrollToPosition(adapter.lastPosition) + } + + binding.recyclerView.setItemViewCacheSize(30) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val firstPosition = - (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val firstPosition = layoutManager.findFirstVisibleItemPosition() + val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition() + + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefHideKeyboardOnScroll, + true + ) && dy < 0 + ) { + binding.recyclerView.hideKeyboard() + } + + setUnreadCounterVisibility(lastPosition, dy) adapter.getOrNull(firstPosition)?.let { if (it !is VkMessage) return - binding.timestamp.isVisible = true + binding.timestamp.visible() val time = "${ TimeUtils.getLocalizedDate( requireContext(), it.date * 1000L ) - }, ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(it.date * 1000L)}" + }, ${ + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(it.date * 1000L) + }" binding.timestamp.text = time @@ -197,13 +282,23 @@ class MessagesHistoryFragment : when { attachmentController.isEditing -> if (it.isNullOrBlank()) Action.DELETE else Action.EDIT canSend -> Action.SEND - else -> Action.RECORD + else -> { + if (attachmentsToLoad.isNotEmpty()) { + if (attachmentController.isEditing) { + Action.EDIT + } else { + Action.SEND + } + } else { + Action.RECORD + } + } } - if (action.value != newValue) action.value = newValue + actionState.setIfNotEquals(newValue) } - action.observe(viewLifecycleOwner) { + actionState.observe(viewLifecycleOwner) { binding.action.animate() .scaleX(1.25f) .scaleY(1.25f) @@ -238,32 +333,30 @@ class MessagesHistoryFragment : attachmentController.isPanelVisible.observe(viewLifecycleOwner) { isVisible -> if (isVisible) binding.message.setSelection(binding.message.text.toString().length) - val currentMargin = - (binding.refreshLayout.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin - - val newMargin = - if (isVisible) (binding.attachmentPanel.measuredHeight / 1.5).roundToInt() - else 0 - - ValueAnimator.ofInt(currentMargin, newMargin).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (getView() == null) return@addUpdateListener - val value = animator.animatedValue as Int - binding.refreshLayout.updateLayoutParams { - bottomMargin = value - } - } - }.start() +// val currentHeight = binding.listAnchor.height +// +// val newHeight = +// if (isVisible) (binding.attachmentPanel.measuredHeight / 1.5).roundToInt() +// else 1 +// +// ValueAnimator.ofInt(currentHeight, newHeight).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (getView() == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// binding.listAnchor.updateLayoutParams { +// height = value +// } +// } +// }.start() } - binding.attachmentPanel.setOnClickListener c@{ - val message = attachmentController.message.value ?: return@c - - val index = adapter.indexOf(message) - if (index == -1) return@c + binding.replyMessage.setOnClickListener { + val message = attachmentController.message.value ?: return@setOnClickListener + val index = adapter.searchMessageIndex(message.id) ?: return@setOnClickListener binding.recyclerView.scrollToPosition(index) } @@ -272,79 +365,268 @@ class MessagesHistoryFragment : if (attachmentController.message.value != null) attachmentController.message.value = null } + + binding.attach.setOnClickListener { + showAttachmentsPopupMenu() + } + + binding.attach.setOnLongClickListener { + pickPhoto() + true + } } - @ColorInt - private fun getColor(@ColorRes resId: Int): Int { - return ContextCompat.getColor(requireContext(), resId) + override fun onEvent(event: VkEvent) { + super.onEvent(event) + + when (event) { + is MessagesMarkAsImportantEvent -> markMessagesAsImportant(event) + is MessagesLoadedEvent -> refreshMessages(event) + is MessagesPinEvent -> conversation.pinnedMessage = event.message + is MessagesUnpinEvent -> conversation.pinnedMessage = null + is MessagesDeleteEvent -> deleteMessages(event) + is MessagesEditEvent -> editMessage(event) + is MessagesReadEvent -> readMessages(event) + is MessagesNewEvent -> addNewMessage(event) + } + } + + override fun toggleProgress(isProgressing: Boolean) { + view?.run { + findViewById(R.id.progress_bar).toggleVisibility( + if (isProgressing) adapter.isEmpty() else false + ) + findViewById(R.id.refresh_layout).isRefreshing = + if (isProgressing) adapter.isNotEmpty() else false + } + } + + private suspend fun processFileFromStorage(uri: Uri) { + var name = "" + var size = 0.0 + + val contentResolver = requireContext().contentResolver + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + + cursor.moveToFirst() + name = cursor.getString(nameIndex) + size = AndroidUtils.bytesToMegabytes(cursor.getLong(sizeIndex).toDouble()) + cursor.close() + } + + if (size > 200) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Selected file weighs more than 200 megabytes. Compress it or send other file") + .setPositiveButton(R.string.ok, null) + .setCancelable(false) + .show() + return + } + + val lastDotIndex = name.lastIndexOf(".") + var extension = if (lastDotIndex == -1) "" else name.substring(lastDotIndex + 1) + + if (extension.endsWith("msi") || extension.endsWith("exe") || extension.endsWith("apk")) { + extension += "fast" + name += "fast" + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Selected file is executable. Fast changed it extension to \"$extension\", so the final name is \"$name\"") + .setPositiveButton(R.string.ok, null) + .setCancelable(false) + .show() + } + + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + + "${File.separator}upload.$extension" + + val file = File(destination) + if (file.exists()) file.delete() + + withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + val inputStream = + requireActivity().contentResolver.openInputStream(uri) ?: return@withContext + + inputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + } + + val mimeType = contentResolver.getType(uri) ?: return + + if (pickFile) { + val uploadedAttachment = viewModel.uploadFile( + conversation.id, + file, + name, + FilesRepository.FileType.File + ) + addAttachment(uploadedAttachment) + } else { + when (MediaType.parse(mimeType).type()) { + MediaType.ANY_IMAGE_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadPhoto(conversation.id, file, name) + addAttachment(uploadedAttachment) + } + MediaType.ANY_VIDEO_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadVideo(file, name) + addAttachment(uploadedAttachment) + } + MediaType.ANY_AUDIO_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadAudio(file, name) + addAttachment(uploadedAttachment) + } + } + } + } + + private fun showAttachmentsPopupMenu() { + val popupMenu = PopupMenu(requireContext(), binding.attach) + + if (attachmentsToLoad.isNotEmpty()) { + popupMenu.menu.add("Clear attachments") + } + + popupMenu.menu.add("Photo") + popupMenu.menu.add("Video") + popupMenu.menu.add("Audio") + popupMenu.menu.add("File") + popupMenu.setOnMenuItemClickListener { menuItem -> + return@setOnMenuItemClickListener when (menuItem.title) { + "Clear attachments" -> { + clearAttachments() + true + } + "Photo" -> { + pickPhoto() + true + } + "Video" -> { + pickVideo() + true + } + "Audio" -> { + pickAudio() + true + } + "File" -> { + pickFile() + true + } + else -> false + } + } + popupMenu.show() + } + + private fun addAttachment(attachment: VkAttachment) { + attachmentsToLoad += attachment + binding.attachmentsCounter.visible() + binding.attachmentsCounter.text = attachmentsToLoad.size.toString() + + binding.attachmentsList.visible() + attachmentsAdapter.add(attachment) + + attachmentController.showPanel() + + actionState.setIfNotEquals( + if (attachmentController.isEditing) Action.EDIT + else Action.SEND + ) + } + + private fun removeAttachment(attachment: VkAttachment) { + attachmentsToLoad -= attachment + binding.attachmentsCounter.visible() + binding.attachmentsCounter.text = attachmentsToLoad.size.toString() + + binding.attachmentsList.visible() + + attachmentController.showPanel() + + if (attachmentsToLoad.isEmpty()) { + clearAttachments() + } else { + attachmentsAdapter.remove(attachment) + } + } + + private fun clearAttachments() { + attachmentsToLoad.clear() + binding.attachmentsCounter.gone() + binding.attachmentsCounter.text = null + + attachmentsAdapter.clear() + binding.attachmentsList.gone() + + attachmentController.hidePanel() + } + + private fun pickPhoto() { + getContent.launch(MediaType.ANY_IMAGE_TYPE.mimeType) + } + + private fun pickVideo() { + getContent.launch(MediaType.ANY_VIDEO_TYPE.mimeType) + } + + private fun pickAudio() { + getContent.launch(MediaType.MPEG_AUDIO.mimeType) + } + + private fun pickFile() { + pickFile = true + getContent.launch(MediaType.ANY_TYPE.mimeType) + } + + fun scrollToMessage(messageId: Int) { + adapter.searchMessageIndex(messageId)?.let { index -> + binding.recyclerView.scrollToPosition(index) + } } private fun prepareAvatar() { val avatar = when { - conversation.ownerId == VKConstants.FAST_GROUP_ID -> null conversation.isUser() -> user?.photo200 conversation.isGroup() -> group?.photo200 conversation.isChat() -> conversation.photo200 else -> null } - val colorOnPrimary = getColor(R.color.colorOnPrimary) - val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) - val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) + val avatarMenuItem = binding.toolbar.addAvatarMenuItem() + val avatarImageView: ImageView? = avatarMenuItem.actionView?.findViewById(R.id.avatar) - val icLauncherColor = getColor(R.color.a1_500) - - binding.avatar.toggleVisibility(avatar != null) - - if (avatar == null) { - binding.avatarPlaceholder.visible() - - if (conversation.ownerId == VKConstants.FAST_GROUP_ID) { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(icLauncherColor), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(colorOnPrimary) - binding.placeholder.setImageResource(R.drawable.ic_fast_logo) - binding.placeholder.setPadding(18) - } else { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(colorOnUserAvatarAction), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(colorUserAvatarAction) - binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut) - binding.placeholder.setPadding(0) - binding.avatar.clear() - } - } else { - binding.avatar.load(avatar) { - crossfade(200) - target { - binding.avatarPlaceholder.gone() - binding.avatar.setImageDrawable(it) - } - } - } - - binding.phantomIcon.toggleVisibility(conversation.isPhantom) - binding.online.toggleVisibility(user?.online) + avatarImageView?.loadWithGlide(url = avatar, asCircle = true, crossFade = true) } private fun performAction() { - when (action.value) { + when (actionState.value) { Action.RECORD -> { } Action.SEND -> { - val messageText = binding.message.text.toString().trim() - if (messageText.isBlank()) return + val messageText = binding.message.trimmedText + if (messageText.isBlank() && attachmentsToLoad.isEmpty()) { + Log.d( + "MessagesHistoryFragment", + "performAction: SEND: messageText is empty & attachments is empty. return" + ) + return + } val date = System.currentTimeMillis() val messageIndex = adapter.lastPosition + val attachments = attachmentsToLoad.ifEmpty { null }?.toList() + clearAttachments() + val message = VkMessage( id = Int.MAX_VALUE, text = messageText, @@ -352,9 +634,14 @@ class MessagesHistoryFragment : peerId = conversation.id, fromId = UserConfig.userId, date = (date / 1000).toInt(), - randomId = 0, - replyMessage = attachmentController.message.value - ) + randomId = Random.nextInt(), + replyMessage = attachmentController.message.value, + attachments = attachments, + ).also { + it.state = VkMessage.State.Sending + } + + Log.d("LongPollUpdatesParser", "newMessageRandomId: ${message.randomId}") adapter.add(message, beforeFooter = true, commitCallback = { binding.recyclerView.scrollToPosition(adapter.lastPosition) @@ -366,14 +653,25 @@ class MessagesHistoryFragment : viewModel.sendMessage( peerId = conversation.id, - message = messageText, - randomId = 0, + message = messageText.ifBlank { null }, + randomId = message.randomId, replyTo = replyMessage?.id, setId = { messageId -> val messageToUpdate = adapter[messageIndex] as VkMessage messageToUpdate.id = messageId - adapter[messageIndex] = messageToUpdate - } + messageToUpdate.state = VkMessage.State.Sent + adapter.notifyItemChanged(messageIndex, "kek") +// adapter[messageIndex] = messageToUpdate + attachmentsAdapter.clear() + }, + onError = { + val messageToUpdate = adapter[messageIndex] as VkMessage + messageToUpdate.state = VkMessage.State.Error + adapter.notifyItemChanged(messageIndex, "kek") +// adapter[messageIndex] = messageToUpdate + attachmentsAdapter.clear() + }, + attachments = attachments ) } Action.EDIT -> { @@ -397,35 +695,11 @@ class MessagesHistoryFragment : } } - override fun onEvent(event: VkEvent) { - super.onEvent(event) - - when (event) { - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() - - is MessagesMarkAsImportantEvent -> markMessagesAsImportant(event) - is MessagesLoadedEvent -> refreshMessages(event) - is MessagesPinEvent -> conversation.pinnedMessage = event.message - is MessagesUnpinEvent -> conversation.pinnedMessage = null - is MessagesDeleteEvent -> deleteMessages(event) - is MessagesEditEvent -> editMessage(event) - } - } - - private fun onProgressStarted() { - binding.progressBar.isVisible = adapter.isEmpty() - binding.refreshLayout.isRefreshing = adapter.isNotEmpty() - } - - private fun onProgressStopped() { - binding.progressBar.isVisible = false - binding.refreshLayout.isRefreshing = false - } - private fun prepareViews() { prepareRecyclerView() prepareRefreshLayout() + prepareEmojiButton() + prepareAttachmentsList() } private fun prepareRecyclerView() { @@ -446,28 +720,57 @@ class MessagesHistoryFragment : setColorSchemeColors( AndroidUtils.getThemeAttrColor( requireContext(), - R.attr.colorAccent + R.attr.colorPrimary ) ) setOnRefreshListener { viewModel.loadHistory(peerId = conversation.id) } } } + private fun prepareEmojiButton() { + binding.emoji.setOnLongClickListener { + val text = binding.message.text.toString() + AppGlobal.preferences.getString( + SettingsPrefsFragment.PrefFastText, SettingsPrefsFragment.PrefFastTextDefaultValue + ) + binding.message.setText(text) + binding.message.selectLast() + + binding.emoji.animate() + .scaleX(1.25f) + .scaleY(1.25f) + .setDuration(100) + .withEndAction { + if (view == null) return@withEndAction + + binding.emoji.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(100) + .start() + }.start() + true + } + } + + private fun prepareAttachmentsList() { + binding.attachmentsList.addItemDecoration( + SpaceItemDecoration(endMargin = 4.dpToPx()) + ) + binding.attachmentsList.adapter = attachmentsAdapter + } + private fun markMessagesAsImportant(event: MessagesMarkAsImportantEvent) { - var changed = false - val positions = mutableListOf() + val newList = adapter.cloneCurrentList() - for (i in adapter.indices) { - val message = adapter[i] as VkMessage - message.important = event.important + for (i in newList.indices) { + val item = newList[i] + val message: VkMessage = (if (item !is VkMessage) null else item) ?: continue if (event.messagesIds.contains(message.id)) { - if (!changed) changed = true - - positions.add(i) - - adapter[i] = message + newList[i] = message.copy(important = event.important) } } + + adapter.submitList(newList) } private fun refreshMessages(event: MessagesLoadedEvent) { @@ -485,6 +788,7 @@ class MessagesHistoryFragment : withHeader = true, withFooter = true, commitCallback = { + if (view == null) return@setItems if (smoothScroll) binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) else binding.recyclerView.scrollToPosition(adapter.lastPosition) } @@ -517,11 +821,6 @@ class MessagesHistoryFragment : ).format(message.date * 1000L) ) - val important = getString( - if (message.important) R.string.message_context_action_unmark_as_important - else R.string.message_context_action_mark_as_important - ) - val reply = getString(R.string.message_context_action_reply) val isMessageAlreadyPinned = message.id == conversation.pinnedMessage?.id @@ -531,44 +830,70 @@ class MessagesHistoryFragment : else R.string.message_context_action_pin ) + val important = getString( + if (message.important) R.string.message_context_action_unmark_as_important + else R.string.message_context_action_mark_as_important + ) + + val read = "Mark as read" + val edit = getString(R.string.message_context_action_edit) val delete = getString(R.string.message_context_action_delete) - val params = mutableListOf( - important, reply - ) + val params = mutableListOf() + val onlySentParams = mutableListOf() + + params += reply + onlySentParams += reply if (conversation.canChangePin) { params += pin + onlySentParams += pin + } + + params += important + onlySentParams += important + + if (!message.isRead(conversation) && !message.isOut) { + params += read + onlySentParams += read } if (message.canEdit()) { params += edit + onlySentParams += edit } params += delete + if (!message.isSent()) { + params.removeAll(onlySentParams) + } + val arrayParams = params.toTypedArray() MaterialAlertDialogBuilder(requireContext()) .setTitle(time) .setItems(arrayParams) { _, which -> when (params[which]) { - important -> viewModel.markAsImportant( - messagesIds = listOf(message.id), - important = !message.important - ) reply -> { if (attachmentController.message.value != message) attachmentController.message.value = message } - pin -> - showPinMessageDialog( - peerId = conversation.id, - messageId = message.id, - pin = !isMessageAlreadyPinned - ) + pin -> showPinMessageDialog( + peerId = conversation.id, + messageId = message.id, + pin = !isMessageAlreadyPinned + ) + important -> viewModel.markAsImportant( + messagesIds = listOf(message.id), + important = !message.important + ) + read -> viewModel.readMessage( + conversation.id, + message.id + ) edit -> { attachmentController.isEditing = true @@ -600,7 +925,7 @@ class MessagesHistoryFragment : pin = pin ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -613,9 +938,11 @@ class MessagesHistoryFragment : ) binding.check.isEnabled = - (conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit()) + message.isSent() && ((conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit())) - if (conversation.id == UserConfig.userId) binding.check.isChecked = true + if (message.isSent() && conversation.id == UserConfig.userId || + (binding.check.isEnabled && message.isOut) + ) binding.check.isChecked = true MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.confirm_delete_message) @@ -623,6 +950,14 @@ class MessagesHistoryFragment : .setPositiveButton(R.string.action_delete) { _, _ -> attachmentController.message.value = null + if (message.isError()) { + adapter.searchIndexOf(message)?.let { index -> + adapter.removeAt(index) + } + + return@setPositiveButton + } + viewModel.deleteMessage( peerId = conversation.id, messagesIds = listOf(message.id), @@ -630,18 +965,123 @@ class MessagesHistoryFragment : deleteForAll = if (!binding.check.isEnabled) null else binding.check.isChecked ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } private fun deleteMessages(event: MessagesDeleteEvent) { - val messagesToDelete = event.messagesIds.mapNotNull { id -> adapter.searchMessageById(id) } + if (event.peerId != conversation.id) return + val messagesToDelete = + event.messagesIds.mapNotNull { id -> adapter.searchMessageById(id) } adapter.removeAll(messagesToDelete) } private fun editMessage(event: MessagesEditEvent) { + if (event.message.peerId != conversation.id) return adapter.searchMessageIndex(event.message.id)?.let { index -> adapter[index] = event.message + adapter.notifyItemChanged(index) + } + } + + private fun readMessages(event: MessagesReadEvent) { + if (event.peerId != conversation.id) return + + val oldOutRead = conversation.outRead + val oldInRead = conversation.inRead + + if (event.isOut) { + conversation.outRead = event.messageId + } else { + conversation.inRead = event.messageId + } + + val positionsToUpdate = mutableListOf() + val newList = adapter.cloneCurrentList() + for (i in newList.indices) { + val message = newList[i] + if (message !is VkMessage) continue + + if ((message.isOut && conversation.outRead - oldOutRead > 0 && message.id > oldOutRead) || + (!message.isOut && conversation.inRead - oldInRead > 0 && message.id > oldInRead) + ) { + positionsToUpdate += i + } + } + + positionsToUpdate.forEach { index -> + adapter.notifyItemChanged(index) + + if (binding.unreadCounter.isVisible) { + setUnreadCounterVisibility( + (binding.recyclerView.layoutManager as LinearLayoutManager) + .findLastCompletelyVisibleItemPosition() + ) + } + } + } + + @Suppress("NAME_SHADOWING") + private fun setUnreadCounterVisibility( + lastCompletelyVisiblePosition: Int, + dy: Int? = null + ) { + if (lastCompletelyVisiblePosition >= adapter.lastPosition - 1) { + setUnreadCounterVisibility(false) + } else { + if (adapter.containsUnreadMessages()) { + setUnreadCounterVisibility(true) + } else { + if (dy == null) { + setUnreadCounterVisibility(false) + } else { + if (dy > 0) { + if (dy > 40) setUnreadCounterVisibility(true) + } else { + if (dy < -40) setUnreadCounterVisibility(false) + } + } + } + } + } + + private fun addNewMessage(event: MessagesNewEvent) { + if (event.message.peerId != conversation.id) return + + adapter.profiles += event.profiles + adapter.groups += event.groups + + if (adapter.containsRandomId(event.message.randomId) + || adapter.containsId(event.message.id) + ) return + + val itemCount = adapter.itemCount + + adapter.add(event.message, beforeFooter = true) { + if (view == null) return@add + + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + + if (abs(lastVisiblePosition - adapter.lastPosition) <= 3) { + binding.recyclerView.scrollToPosition(adapter.lastPosition) + } else { + setUnreadCounterVisibility(true) + // add counter of unread + } + } + adapter.notifyItemRangeChanged(0, itemCount, "avatars") + } + + private fun setUnreadCounterVisibility(isVisible: Boolean) { + if (view == null) return + + binding.unreadCounter.run { + if (isVisible) { + show() + } else { + hide() + } } } @@ -665,11 +1105,15 @@ class MessagesHistoryFragment : } private fun applyMessage(message: VkMessage) { - val title = when { - message.isGroup() && message.group.value != null -> message.group.value?.name - message.isUser() && message.user.value != null -> message.user.value?.fullName - else -> null - } + val messageUser: VkUser? = + if (message.isUser()) adapter.profiles[message.fromId] + else null + val messageGroup: VkGroup? = + if (message.isGroup()) adapter.groups[message.fromId] + else null + val title = VkUtils.getMessageTitle( + message, messageUser, messageGroup + ) val attachmentText = if (message.text == null) VkUtils.getAttachmentText( context = requireContext(), @@ -689,13 +1133,22 @@ class MessagesHistoryFragment : if (isEditing) { binding.message.setText(message.text) + binding.message.setSelection(message.text?.length ?: 0) + binding.message.requestFocusFromTouch() + binding.message.showKeyboard() } + binding.replyMessage.visible() + showPanel() } private fun clearMessage() { - hidePanel() + if (attachmentsToLoad.isEmpty()) { + hidePanel() + } + + binding.replyMessage.gone() binding.replyMessageTitle.clear() binding.replyMessageText.clear() @@ -706,66 +1159,100 @@ class MessagesHistoryFragment : } } - private fun showPanel() { - binding.attachmentPanel.visible() - binding.attachmentPanel.measure( - View.MeasureSpec.AT_MOST, View.MeasureSpec.UNSPECIFIED - ) + fun showPanel() { + if (isPanelVisible.requireValue()) return - if (attachmentController.isPanelVisible.value == false) + binding.attachmentPanel.visible() +// binding.attachmentPanel.measure( +// View.MeasureSpec.AT_MOST, View.MeasureSpec.UNSPECIFIED +// ) + + if (!attachmentController.isPanelVisible.requireValue()) attachmentController.isPanelVisible.value = true - val measuredHeight = binding.attachmentPanel.measuredHeight +// binding.attachmentPanel.visible() - binding.attachmentPanel.updateLayoutParams { - height = 0 - } - - binding.attachmentPanel.animate() - .translationY(0f) - .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) - .start() - - ValueAnimator.ofInt(0, measuredHeight).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (view == null) return@addUpdateListener - val value = animator.animatedValue as Int - binding.attachmentPanel.updateLayoutParams { - height = value - } - } - }.start() +// val measuredHeight = binding.attachmentPanel.measuredHeight +// +// binding.attachmentPanel.updateLayoutParams { +// height = 0 +// } +// +// binding.attachmentPanel.animate() +// .translationY(0f) +// .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) +// .start() +// +// ValueAnimator.ofInt(0, measuredHeight).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (view == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// if (value >= 36.dpToPx()) { +// binding.attachmentPanel.visible() +// } +// +// binding.attachmentPanel.updateLayoutParams { +// height = value +// } +// } +// }.start() } - private fun hidePanel() { - if (attachmentController.isPanelVisible.value == true) + fun hidePanel() { + if (!isPanelVisible.requireValue() || + attachmentsToLoad.isNotEmpty() || + message.value != null + ) return + + if (attachmentController.isPanelVisible.requireValue()) attachmentController.isPanelVisible.value = false - val currentHeight = binding.attachmentPanel.height + binding.attachmentPanel.gone() - binding.attachmentPanel.animate() - .translationY(75F) - .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) - .start() - - ValueAnimator.ofInt(currentHeight, 0).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (view == null) return@addUpdateListener - val value = animator.animatedValue as Int - - binding.attachmentPanel.updateLayoutParams { - height = value - } - } - }.start() +// val currentHeight = binding.attachmentPanel.height +// +// binding.attachmentPanel.animate() +// .translationY(75F) +// .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) +// .start() +// +// ValueAnimator.ofInt(currentHeight, 0).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (view == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// if (value <= 36.dpToPx()) { +// binding.attachmentPanel.gone() +// } +// +// binding.attachmentPanel.updateLayoutParams { +// height = value +// } +// } +// doOnEnd { +// if (view == null) return@doOnEnd +// binding.attachmentPanel.gone() +// } +// }.start() } + } + fun openForwardsScreen( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) { + requireActivityRouter().navigateTo( + Screens.ForwardedMessages(conversation, messages, profiles, groups) + ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt index e829cbc7..1051fd44 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt @@ -1,46 +1,100 @@ package com.meloda.fast.screens.messages -import androidx.lifecycle.viewModelScope -import com.meloda.fast.api.LongPollEvent -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.base.ApiError +import com.meloda.fast.api.longpoll.LongPollEvent +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.attachments.VkVideo +import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.messages.* +import com.meloda.fast.api.network.photos.PhotosSaveMessagePhotoRequest import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.data.audios.AudiosRepository +import com.meloda.fast.data.files.FilesRepository +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.photos.PhotosRepository +import com.meloda.fast.data.videos.VideosRepository +import com.meloda.fast.extensions.requireNotNull +import com.meloda.fast.screens.conversations.MessagesNewEvent import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + @HiltViewModel class MessagesHistoryViewModel @Inject constructor( - private val messages: MessagesDataSource, - updatesParser: LongPollUpdatesParser + private val messagesRepository: MessagesRepository, + updatesParser: LongPollUpdatesParser, + private val photosRepository: PhotosRepository, + private val filesRepository: FilesRepository, + private val audiosRepository: AudiosRepository, + private val videosRepository: VideosRepository ) : BaseViewModel() { init { updatesParser.onNewMessage { -// viewModelScope.launch { handleNewMessage(it) } + launch { handleNewMessage(it) } } updatesParser.onMessageEdited { - viewModelScope.launch { handleEditedMessage(it) } + launch { handleEditedMessage(it) } + } + + updatesParser.onMessageIncomingRead { + launch { handleReadIncomingEvent(it) } + } + + updatesParser.onMessageOutgoingRead { + launch { handleReadOutgoingEvent(it) } } } - private suspend fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { - sendEvent(com.meloda.fast.screens.messages.MessagesEditEvent(event.message)) + private suspend fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { + sendEvent(MessagesNewEvent(event.message, event.profiles, event.groups)) } - fun loadHistory(peerId: Int) = viewModelScope.launch { + private suspend fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { + sendEvent(MessagesEditEvent(event.message)) + } + + private suspend fun handleReadIncomingEvent(event: LongPollEvent.VkMessageReadIncomingEvent) { + sendEvent( + MessagesReadEvent( + isOut = false, + peerId = event.peerId, + messageId = event.messageId + ) + ) + } + + private suspend fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) { + sendEvent( + MessagesReadEvent( + isOut = true, + peerId = event.peerId, + messageId = event.messageId + ) + ) + } + + fun loadHistory(peerId: Int) = launch { makeJob({ - messages.getHistory( + messagesRepository.getHistory( MessagesGetHistoryRequest( - count = 30, + count = 100, peerId = peerId, extended = true, fields = VKConstants.ALL_FIELDS @@ -66,10 +120,11 @@ class MessagesHistoryViewModel @Inject constructor( val hashMessages = hashMapOf() response.items.forEach { baseMessage -> - baseMessage.asVkMessage().let { message -> hashMessages[message.id] = message } + baseMessage.asVkMessage() + .let { message -> hashMessages[message.id] = message } } - messages.store(hashMessages.values.toList()) + messagesRepository.store(hashMessages.values.toList()) val conversations = hashMapOf() response.conversations?.let { baseConversations -> @@ -97,31 +152,38 @@ class MessagesHistoryViewModel @Inject constructor( message: String? = null, randomId: Int = 0, replyTo: Int? = null, - setId: ((messageId: Int) -> Unit)? = null - ) = viewModelScope.launch { + setId: ((messageId: Int) -> Unit)? = null, + onError: ((error: Throwable) -> Unit)? = null, + attachments: List? = null + ) = launch { + delay(2500) makeJob( { - messages.send( + messagesRepository.send( MessagesSendRequest( peerId = peerId, randomId = randomId, message = message, - replyTo = replyTo + replyTo = replyTo, + attachments = attachments ) ) }, onAnswer = { val response = it.response ?: return@makeJob setId?.invoke(response) + }, + onError = { + onError?.invoke(it) }) } fun markAsImportant( messagesIds: List, important: Boolean - ) = viewModelScope.launch { + ) = launch { makeJob({ - messages.markAsImportant( + messagesRepository.markAsImportant( MessagesMarkAsImportantRequest( messagesIds = messagesIds, important = important @@ -144,10 +206,10 @@ class MessagesHistoryViewModel @Inject constructor( messageId: Int? = null, conversationMessageId: Int? = null, pin: Boolean - ) = viewModelScope.launch { + ) = launch { if (pin) { makeJob({ - messages.pin( + messagesRepository.pin( MessagesPinMessageRequest( peerId = peerId, messageId = messageId, @@ -161,7 +223,7 @@ class MessagesHistoryViewModel @Inject constructor( } ) } else { - makeJob({ messages.unpin(MessagesUnPinMessageRequest(peerId = peerId)) }, + makeJob({ messagesRepository.unpin(MessagesUnPinMessageRequest(peerId = peerId)) }, onAnswer = { println("Fast::MessagesHistoryViewModel::unPin::Response::${it.response}") sendEvent(MessagesUnpinEvent) @@ -176,18 +238,27 @@ class MessagesHistoryViewModel @Inject constructor( conversationsMessagesIds: List? = null, isSpam: Boolean? = null, deleteForAll: Boolean? = null - ) = viewModelScope.launch { - makeJob({ - messages.delete( - MessagesDeleteRequest( - peerId = peerId, - messagesIds = messagesIds, - conversationsMessagesIds = conversationsMessagesIds, - isSpam = isSpam, - deleteForAll = deleteForAll + ) = launch { + makeJob( + { + messagesRepository.delete( + MessagesDeleteRequest( + peerId = peerId, + messagesIds = messagesIds, + conversationsMessagesIds = conversationsMessagesIds, + isSpam = isSpam, + deleteForAll = deleteForAll + ) ) - ) - }, onAnswer = { sendEvent(MessagesDeleteEvent(messagesIds = messagesIds ?: emptyList())) }) + }, + onAnswer = { + sendEvent( + MessagesDeleteEvent( + peerId = peerId, + messagesIds = messagesIds ?: emptyList() + ) + ) + }) } fun editMessage( @@ -196,10 +267,10 @@ class MessagesHistoryViewModel @Inject constructor( messageId: Int, message: String? = null, attachments: List? = null - ) = viewModelScope.launch { + ) = launch { makeJob( { - messages.edit( + messagesRepository.edit( MessagesEditRequest( peerId = peerId, messageId = messageId, @@ -210,10 +281,318 @@ class MessagesHistoryViewModel @Inject constructor( }, onAnswer = { originalMessage.text = message - sendEvent(com.meloda.fast.screens.messages.MessagesEditEvent(originalMessage)) + sendEvent(MessagesEditEvent(originalMessage)) } ) } + + fun readMessage(peerId: Int, messageId: Int) { + makeJob( + { messagesRepository.markAsRead(peerId, startMessageId = messageId) }, + onAnswer = { + sendEvent(MessagesReadEvent(false, peerId, messageId)) + } + ) + } + + suspend fun uploadPhoto( + peerId: Int, + photo: File, + name: String + ) = suspendCoroutine { + launch { + val uploadServerUrl = getPhotoMessageUploadServer(peerId) + val uploadedFileInfo = uploadPhotoToServer(uploadServerUrl, photo, name) + + val savedAttachment = saveMessagePhoto( + uploadedFileInfo.first, + uploadedFileInfo.second, + uploadedFileInfo.third + ) + + it.resume(savedAttachment) + }.also { it.invokeOnCompletion { launch { onStop() } } } + } + + private suspend fun getPhotoMessageUploadServer(peerId: Int) = suspendCoroutine { + launch { + val uploadServerResponse = makeSuspendJob( + { photosRepository.getMessagesUploadServer(peerId) } + ) + if (!uploadServerResponse.isSuccessful()) { + throw requireNotNull(uploadServerResponse.error.throwable) + } else { + (uploadServerResponse as ApiAnswer.Success).run { + it.resume(requireNotNull(this.data.response?.uploadUrl)) + } + } + } + } + + private suspend fun uploadPhotoToServer( + uploadUrl: String, + photo: File, + name: String + ) = suspendCoroutine> { + launch { + val requestBody = photo.asRequestBody("image/*".toMediaType()) + val body = MultipartBody.Part.createFormData("photo", name, requestBody) + + val uploadFileResponse = makeSuspendJob( + { photosRepository.uploadPhoto(uploadUrl, body) } + ) + if (!uploadFileResponse.isSuccessful()) { + throw uploadFileResponse.error.throwable!! + } else { + (uploadFileResponse as ApiAnswer.Success).data.run { + it.resume(Triple(this.server, this.photo, this.hash)) + } + } + } + } + + private suspend fun saveMessagePhoto( + server: Int, + photo: String, + hash: String + ) = suspendCoroutine { + launch { + val saveResponse = makeSuspendJob( + { + photosRepository.saveMessagePhoto( + PhotosSaveMessagePhotoRequest( + photo, + server, + hash + ) + ) + } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.response?.run { + it.resume(requireNotNull(first().asVkPhoto())) + } + } + } + } + + suspend fun uploadVideo( + file: File, + name: String + ) = suspendCoroutine { + launch { + val uploadInfo = getVideoMessageUploadServer() + + uploadVideoToServer( + uploadInfo.first, + file, + name + ) + + it.resume(uploadInfo.second) + } + } + + private suspend fun getVideoMessageUploadServer() = suspendCoroutine> { + launch { + val saveResponse = makeSuspendJob( + { videosRepository.save() } + ) + + if (!saveResponse.isSuccessful()) { + it.resumeWithException(saveResponse.error.throwable!!) + return@launch + } else { + val response = (saveResponse as ApiAnswer.Success).data.response ?: return@launch + + val uploadUrl = response.uploadUrl + val video = VkVideo( + id = response.videoId, + ownerId = response.ownerId, + images = emptyList(), + firstFrames = null, + accessKey = response.accessKey, + title = response.title + ) + + it.resume(uploadUrl to video) + } + } + } + + private suspend fun uploadVideoToServer( + uploadUrl: String, + file: File, + name: String + ) = launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("video_file", name, requestBody) + + val response = makeSuspendJob( + { videosRepository.upload(uploadUrl, body) } + ) + if (!response.isSuccessful()) { + throw response.error.throwable!! + } + } + + suspend fun uploadAudio( + file: File, + name: String + ) = suspendCoroutine { + launch { + val uploadUrl = getAudioUploadServer() + val uploadInfo = uploadAudioToServer(uploadUrl, file, name) + val saveInfo = saveMessageAudio( + uploadInfo.first, uploadInfo.second, uploadInfo.third + ) + + it.resume(saveInfo) + } + } + + private suspend fun getAudioUploadServer() = suspendCoroutine { + launch { + val uploadResponse = makeSuspendJob( + { audiosRepository.getUploadServer() } + ) + if (!uploadResponse.isSuccessful()) { + throw uploadResponse.error.throwable!! + } else { + (uploadResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).uploadUrl) + } + } + } + } + + private suspend fun uploadAudioToServer( + uploadUrl: String, + file: File, + name: String + ) = suspendCoroutine> { + launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("file", name, requestBody) + + val uploadResponse = makeSuspendJob( + { audiosRepository.upload(uploadUrl, body) } + ) + if (!uploadResponse.isSuccessful()) { + throw uploadResponse.error.throwable!! + } else { + (uploadResponse as ApiAnswer.Success).data.run { + if (this.error != null) { + throw ApiError(error = error) + } else { + it.resume(Triple(this.server, requireNotNull(this.audio), this.hash)) + } + } + } + } + } + + private suspend fun saveMessageAudio( + server: Int, + audio: String, + hash: String + ) = suspendCoroutine { + launch { + val saveResponse = makeSuspendJob( + { audiosRepository.save(server, audio, hash) } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).asVkAudio()) + } + } + } + } + + suspend fun uploadFile( + peerId: Int, + file: File, + name: String, + type: FilesRepository.FileType + ) = suspendCoroutine { + launch { + val uploadServerUrl = getFileMessageUploadServer(peerId, type) + val uploadedFileInfo = uploadFileToServer(uploadServerUrl, file, name) + val savedAttachmentPair = saveMessageFile(uploadedFileInfo) + + it.resume(savedAttachmentPair.second) + }.also { it.invokeOnCompletion { launch { onStop() } } } + } + + private suspend fun getFileMessageUploadServer( + peerId: Int, + type: FilesRepository.FileType + ) = suspendCoroutine { + launch { + val uploadServerResponse = makeSuspendJob( + { filesRepository.getMessagesUploadServer(peerId, type) } + ) + if (!uploadServerResponse.isSuccessful()) { + throw uploadServerResponse.error.throwable!! + } else { + (uploadServerResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).uploadUrl) + } + } + } + } + + private suspend fun uploadFileToServer( + uploadUrl: String, + file: File, + name: String + ) = suspendCoroutine { + launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("file", name, requestBody) + + val uploadFileResponse = makeSuspendJob( + { filesRepository.uploadFile(uploadUrl, body) } + ) + if (!uploadFileResponse.isSuccessful()) { + throw uploadFileResponse.error.throwable!! + } else { + (uploadFileResponse as ApiAnswer.Success).data.run { + if (this.error != null) { + throw ApiError(error = this.error) + } else { + it.resume(this.file.requireNotNull()) + } + } + } + } + } + + private suspend fun saveMessageFile(file: String) = + suspendCoroutine> { + launch { + val saveResponse = makeSuspendJob( + { filesRepository.saveMessageFile(file) } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.run { + val response = this.response.requireNotNull() + it.resume( + response.type to ( + response.file?.asVkFile() + ?: response.voiceMessage?.asVkVoiceMessage() + ).requireNotNull() + ) + } + } + } + } } data class MessagesLoadedEvent( @@ -224,12 +603,19 @@ data class MessagesLoadedEvent( val groups: HashMap ) : VkEvent() -data class MessagesMarkAsImportantEvent(val messagesIds: List, val important: Boolean) : VkEvent() +data class MessagesMarkAsImportantEvent(val messagesIds: List, val important: Boolean) : + VkEvent() data class MessagesPinEvent(val message: VkMessage) : VkEvent() object MessagesUnpinEvent : VkEvent() -data class MessagesDeleteEvent(val messagesIds: List) : VkEvent() +data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List) : VkEvent() -data class MessagesEditEvent(val message: VkMessage) : VkEvent() \ No newline at end of file +data class MessagesEditEvent(val message: VkMessage) : VkEvent() + +data class MessagesReadEvent( + val isOut: Boolean, + val peerId: Int, + val messageId: Int +) : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt index a906f62d..67153ea4 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt @@ -1,37 +1,37 @@ package com.meloda.fast.screens.messages import android.content.Context +import android.content.res.ColorStateList import android.graphics.drawable.ColorDrawable import android.util.Log import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import android.widget.Space import android.widget.TextView import androidx.appcompat.widget.LinearLayoutCompat +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import coil.load +import androidx.core.view.updateLayoutParams import com.meloda.fast.R -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.model.attachments.VkSticker import com.meloda.fast.common.AppGlobal -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.extensions.visible -import com.meloda.fast.widget.BoundedLinearLayout +import com.meloda.fast.extensions.* +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import java.text.SimpleDateFormat import java.util.* -import kotlin.math.roundToInt class MessagesPreparator constructor( private val context: Context, + private val payloads: MutableList? = null, + private val root: View? = null, private val conversation: VkConversation, @@ -39,44 +39,42 @@ class MessagesPreparator constructor( private val prevMessage: VkMessage? = null, private val nextMessage: VkMessage? = null, - private val bubble: BoundedLinearLayout? = null, + private val bubble: ConstraintLayout, private val text: TextView? = null, private val avatar: ImageView? = null, private val title: TextView? = null, private val spacer: Space? = null, - private val unread: ImageView? = null, + private val messageState: ImageView? = null, private val time: TextView? = null, - private val textContainer: LinearLayoutCompat? = null, + private val replyContainer: FrameLayout? = null, + private val timeReadContainer: View, private val attachmentContainer: LinearLayoutCompat? = null, - private val attachmentSpacer: Space? = null, private val profiles: Map, - private val groups: Map + private val groups: Map, + + private val isForwards: Boolean = false ) { - init { - val maxWidth = (AppGlobal.screenWidth * 0.7).roundToInt() - - if (bubble != null) bubble.maxWidth = maxWidth - } - - private val backgroundNormalIn = - ContextCompat.getDrawable(context, R.drawable.ic_message_in_background) - private val backgroundMiddleIn = - ContextCompat.getDrawable(context, R.drawable.ic_message_in_background_middle) - - private val backgroundNormalOut = - ContextCompat.getDrawable(context, R.drawable.ic_message_out_background) - private val backgroundMiddleOut = - ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle) - private val rootHighlightedColor = ContextCompat.getColor(context, R.color.n2_100) private var photoClickListener: ((url: String) -> Unit)? = null + private var replyClickListener: ((replyMessage: VkMessage) -> Unit)? = null + private var forwardsClickListener: ((forwards: List) -> Unit)? = null - fun setPhotoClickListener(unit: ((url: String) -> Unit)?): MessagesPreparator { - this.photoClickListener = unit + fun withPhotoClickListener(block: ((url: String) -> Unit)?): MessagesPreparator { + this.photoClickListener = block + return this + } + + fun withReplyClickListener(block: ((replyMessage: VkMessage) -> Unit)?): MessagesPreparator { + this.replyClickListener = block + return this + } + + fun withForwardsClickListener(block: ((forwards: List) -> Unit)?): MessagesPreparator { + this.forwardsClickListener = block return this } @@ -94,8 +92,6 @@ class MessagesPreparator constructor( prepareAttachments() - prepareAttachmentsSpacer() - prepareBubbleBackground() prepareText() @@ -105,10 +101,12 @@ class MessagesPreparator constructor( messageGroup = messageGroup ) - if (message.isPeerChat()) { + if (message.isPeerChat() || isForwards) { val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message) val nextSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(message, nextMessage) val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val nextMessageFiveMinAfter = + VkUtils.isPreviousMessageSentFiveMinutesAgo(message, nextMessage) val change = (prevMessage?.date ?: 0) - message.date @@ -117,27 +115,37 @@ class MessagesPreparator constructor( "text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $prevSenderDiff; fiveMinAgo: $fiveMinAgo; " ) - title?.isVisible = prevSenderDiff || fiveMinAgo + title?.toggleVisibility(prevSenderDiff || fiveMinAgo) avatar?.visibility = if (nextSenderDiff - || (fiveMinAgo && prevSenderDiff) - || !prevSenderDiff + || (fiveMinAgo && prevSenderDiff && nextMessageFiveMinAfter) + || nextMessageFiveMinAfter + || (!prevSenderDiff && nextSenderDiff) || nextMessage == null ) View.VISIBLE else View.INVISIBLE } else { - title?.isVisible = false - avatar?.isVisible = false + title?.gone() + avatar?.gone() + } + + + bubble.run { + updateLayoutParams { + matchConstraintMaxWidth = + if (avatar?.isVisible == true) AppGlobal.screenWidth80 - avatar.width - 6.dpToPx() + else AppGlobal.screenWidth80 + } } if (title != null) { val titleString = when { - message.isUser() && messageUser != null -> messageUser.firstName + message.isUser() && messageUser != null -> messageUser.fullName message.isGroup() && messageGroup != null -> messageGroup.name else -> null } - title.text = titleString + title.text = titleString.orDots() } } @@ -150,92 +158,110 @@ class MessagesPreparator constructor( } private fun prepareTime() { - time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L) + val sentTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L) + + val timeText = + if (message.isUpdated()) context.getString(R.string.message_update_time_short, sentTime) + else sentTime + + time?.text = timeText } private fun prepareUnreadIndicator() { - unread?.toggleVisibility(!message.isRead(conversation)) + val isMessageRead = message.isRead(conversation) + + val drawableRes: Int = when (message.state) { + VkMessage.State.Sending -> { + R.drawable.ic_round_access_time_24 + } + VkMessage.State.Error -> { + R.drawable.ic_round_error_outline_24 + } + VkMessage.State.Sent -> { + if (isMessageRead) R.drawable.ic_round_done_all_24 + else R.drawable.ic_round_done_24 + } + } + + messageState?.run { + imageTintList = ColorStateList.valueOf( + ContextCompat.getColor( + context, + if (message.state == VkMessage.State.Error) R.color.colorError + else R.color.colorOnBackground + ) + ) + + toggleVisibility(!isMessageRead || message.isOut) + setImageResource(drawableRes) + } } private fun prepareSpacer() { - spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message) + spacer?.toggleVisibility(fiveMinAgo || prevSenderDiff) } private fun prepareAttachments() { attachmentContainer?.removeAllViews() - textContainer?.let { textContainer -> - if (textContainer.childCount > 1) { - textContainer.removeViews(1, textContainer.childCount - 1) - } - } - - if (attachmentContainer != null && textContainer != null) { - - if (message.attachments.isNullOrEmpty()) { + if (attachmentContainer != null && replyContainer != null) { + if ( + !message.hasAttachments() && + !message.hasReply() && + !message.hasForwards() && + !message.hasGeo() + ) { attachmentContainer.gone() + replyContainer.gone() } else { - attachmentContainer.visible() - AttachmentInflater( context = context, container = attachmentContainer, - textContainer = textContainer, + replyContainer = replyContainer, + timeReadContainer = timeReadContainer, message = message, groups = groups, profiles = profiles ) - .setPhotoClickListener(photoClickListener) + .withPhotoClickListener(photoClickListener) + .withReplyClickListener(replyClickListener) + .withForwardsClickListener(forwardsClickListener) .inflate() } } } - private fun prepareAttachmentsSpacer() { - attachmentSpacer?.isVisible = - !message.attachments.isNullOrEmpty() && text?.isVisible == true - } - private fun prepareBubbleBackground() { - if (bubble != null) { - // TODO: 9/23/2021 use external function - bubble.background = - if (!message.attachments.isNullOrEmpty() && message.attachments!![0] is VkSticker) null - else { - if (message.isOut) { - if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundNormalOut - else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleOut - else backgroundNormalOut - } else { - if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundNormalIn - else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleIn - else backgroundNormalIn - } - } - } +// bubble.background = if (message.isOut) backgroundMiddleOut else backgroundMiddleIn } private fun prepareText() { - if (bubble != null && text != null) { + if (text != null) { + text.updateLayoutParams { + val topMargin = if (title != null && title.isVisible) 6 else 0.dpToPx() + + goneTopMargin = topMargin + } + if (message.text == null) { + text.clear() text.gone() - - val hasAttachments = !message.attachments.isNullOrEmpty() - var shouldBeVisible = hasAttachments - if (hasAttachments) { - for (attachment in message.attachments ?: emptyList()) { - if (VKConstants.separatedFromTextAttachments.contains(attachment.javaClass)) { - shouldBeVisible = false - break - } - } - } - - bubble.toggleVisibility(shouldBeVisible) } else { text.visible() - bubble.visible() - text.text = VkUtils.prepareMessageText(message.text ?: "") + + val updSpacer = "\t\t\t\t" + val timeSpacer = "\t\t\t\t\t\t" + val messageStateSpacer = "\t\t\t" + + val preparedText = + VkUtils.prepareMessageText(message.text ?: "") + + (if (message.isUpdated()) updSpacer else "") + + timeSpacer + + (if (!message.isOut && message.isRead(conversation)) "" else messageStateSpacer) + + text.text = preparedText } } } @@ -247,7 +273,10 @@ class MessagesPreparator constructor( if (avatar != null) { val avatarUrl = VkUtils.getMessageAvatar(message, messageUser, messageGroup) - avatar.load(avatarUrl) { crossfade(100) } + avatar.loadWithGlide( + url = avatarUrl, + crossFade = true + ) } } } diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt index f3f99443..3ff40d25 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt @@ -6,7 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.fragment.app.viewModels -import com.meloda.fast.base.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt index b9a2984d..6b06b293 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt @@ -2,8 +2,8 @@ package com.meloda.fast.screens.photos import android.widget.ImageView import androidx.lifecycle.viewModelScope -import coil.load import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import kotlinx.coroutines.launch class PhotoViewViewModel : BaseViewModel() { @@ -12,11 +12,7 @@ class PhotoViewViewModel : BaseViewModel() { url: String, imageView: ImageView ) = viewModelScope.launch { - imageView.load(url) - } - - fun saveImageToLocalStorage(url: String) = viewModelScope.launch { - TODO("Not implemented") + imageView.loadWithGlide(url = url) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt new file mode 100644 index 00000000..a5174f6b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt @@ -0,0 +1,109 @@ +package com.meloda.fast.screens.settings + +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.common.AppGlobal +import com.microsoft.appcenter.crashes.Crashes + +class SettingsPrefsFragment : PreferenceFragmentCompat(), + Preference.OnPreferenceClickListener, + Preference.OnPreferenceChangeListener { + + @Suppress("unused") + companion object { + const val KeyChangeMultiline = "change_multiline" + const val ArgEnabled = "enabled" + + const val CategoryAppearance = "appearance" + const val PrefMultiline = "multiline" + + const val CategoryFeatures = "features" + const val PrefHideKeyboardOnScroll = "hide_keyboard_on_scroll" + const val PrefFastText = "fast_text" + const val PrefFastTextDefaultValue = "¯\\_(ツ)_/¯" + + const val CategoryVisibility = "visibility" + const val PrefSendOnlineStatus = "send_online_status" + + const val CategoryUpdates = "updates" + const val PrefCheckUpdates = "check_updates" + + const val CategoryDebug = "debug" + const val PrefPerformCrash = "perform_crash" + const val PrefShowDestroyedLongPollAlert = "show_destroyed_long_poll_alert" + const val PrefShowCrashAlert = "show_crash_alert" + + const val CategoryAppCenter = "msappcenter" + const val PrefEnableReporter = "msappcenter.enable" + } + + private val prefs = AppGlobal.preferences + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences, rootKey) + + getPreference(PrefMultiline)?.let { + it.onPreferenceChangeListener = this + } + + getPreference(PrefCheckUpdates)?.let { + val version = AppGlobal.versionName.split("_").getOrNull(1) + val summaryText = getString(R.string.pref_updates_check_update_summary, version) + it.summary = summaryText + + it.onPreferenceClickListener = this + } + + getPreference(CategoryDebug)?.let { + it.isVisible = BuildConfig.DEBUG + } + getPreference(PrefPerformCrash)?.let { + it.isVisible = BuildConfig.DEBUG + it.onPreferenceClickListener = this + } + + findPreference(PrefFastText)?.summaryProvider = fastTextSummaryProvider + } + + override fun onPreferenceClick(preference: Preference): Boolean { + return when (preference.key) { + PrefCheckUpdates -> { + rootFragment?.openUpdatesScreen() + true + } + PrefPerformCrash -> { + Crashes.generateTestCrash() + true + } + else -> false + } + } + + override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { + return when (preference.key) { + PrefMultiline -> { + val enabled = newValue as Boolean + setFragmentResult(KeyChangeMultiline, bundleOf(ArgEnabled to enabled)) + true + } + else -> false + } + } + + private val fastTextSummaryProvider = Preference.SummaryProvider { + getString( + R.string.pref_message_fast_text_summary, + prefs.getString(PrefFastText, PrefFastTextDefaultValue) + ) + } + + private val rootFragment: SettingsRootFragment? get() = parentFragment as? SettingsRootFragment + + private fun getPreference(key: String) = findPreference(key) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt new file mode 100644 index 00000000..4c2272f9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt @@ -0,0 +1,39 @@ +package com.meloda.fast.screens.settings + +import android.os.Bundle +import android.view.View +import android.viewbinding.library.fragment.viewBinding +import androidx.fragment.app.commit +import androidx.fragment.app.setFragmentResultListener +import com.meloda.fast.R +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.common.Screens +import com.meloda.fast.databinding.FragmentSettingsRootBinding + +class SettingsRootFragment : BaseFragment(R.layout.fragment_settings_root) { + + companion object { + const val KeyCheckUpdates = "check_updates" + } + + private val binding: FragmentSettingsRootBinding by viewBinding() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + setFragmentResultListener(KeyCheckUpdates) { _, _ -> + openUpdatesScreen() + } + + childFragmentManager.commit { + replace(R.id.settings_fragment_container, SettingsPrefsFragment()) + } + } + + fun openUpdatesScreen() { + requireActivityRouter().navigateTo(Screens.Updates()) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt new file mode 100644 index 00000000..24b7ef02 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt @@ -0,0 +1,5 @@ +package com.meloda.fast.screens.updates + +enum class UpdateState { + NewUpdate, NoUpdates, Loading, Error, Downloading +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt new file mode 100644 index 00000000..bda360af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt @@ -0,0 +1,362 @@ +package com.meloda.fast.screens.updates + +import android.animation.ObjectAnimator +import android.app.DownloadManager +import android.content.IntentFilter +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.util.Log +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.viewbinding.library.fragment.viewBinding +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.R +import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.common.AppConstants +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.databinding.FragmentUpdatesBinding +import com.meloda.fast.extensions.clear +import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.model.UpdateItem +import com.meloda.fast.receiver.DownloadManagerReceiver +import com.meloda.fast.util.AndroidUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.ResponseBody +import java.io.* +import java.util.* + +@AndroidEntryPoint +class UpdatesFragment : BaseViewModelFragment(R.layout.fragment_updates) { + + companion object { + private const val ARG_UPDATE_ITEM = "arg_update_item" + private const val ARG_FILE_BASE_PATH = "file://" + private const val ARG_PROVIDER_PATH = ".provider" + + fun newInstance(updateItem: UpdateItem? = null): UpdatesFragment { + val fragment = UpdatesFragment() + if (updateItem != null) { + fragment.arguments = bundleOf(ARG_UPDATE_ITEM to updateItem) + } else { + fragment.arguments = Bundle() + } + + return fragment + } + } + + override val viewModel: UpdatesViewModel by viewModels() + + private val binding: FragmentUpdatesBinding by viewBinding() + + private var downloadId: Long? = null + + private var timer: Timer? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + UpdateManager.newUpdate.observe(viewLifecycleOwner) { item -> + viewModel.currentItem.setIfNotEquals(item) + } + + viewModel.updateState.observe(viewLifecycleOwner) { state -> + state?.run { refreshState(this) } + } + + if (requireArguments().containsKey(ARG_UPDATE_ITEM)) { + val updateItem: UpdateItem = requireArguments().getParcelable(ARG_UPDATE_ITEM) ?: return + viewModel.currentItem.setIfNotEquals(updateItem) + viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) + } else { + viewModel.checkUpdates() + } + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + binding.changelog.setOnClickListener { + showChangelogAlert() + } + } + + private fun refreshState(state: UpdateState) { + binding.actionButton.toggleVisibility( + !listOf( + UpdateState.Downloading, + UpdateState.Loading + ).contains(viewModel.updateState.value) + ) + binding.flow.toggleVisibility( + !listOf( + UpdateState.Downloading, + UpdateState.Loading + ).contains(viewModel.updateState.value) + ) + binding.progress.toggleVisibility( + viewModel.updateState.value == UpdateState.Loading + ) + binding.changelog.toggleVisibility( + viewModel.updateState.value == UpdateState.NewUpdate + ) + binding.loadingProgress.toggleVisibility( + viewModel.updateState.value == UpdateState.Downloading + ) + + if (state != UpdateState.Downloading) { + timer?.cancel() + downloadId?.run { AppGlobal.downloadManager.remove(this) } + } + + when (state) { + UpdateState.NewUpdate -> { + val item = viewModel.currentItem.value ?: return + binding.title.setText(R.string.fragment_updates_new_version) + + binding.description.text = getString( + R.string.fragment_updates_new_version_description, + item.versionName + ) + + binding.actionButton.setText(R.string.fragment_updates_download_update) + binding.actionButton.setOnClickListener { checkIsInstallingAllowed(item) } + } + UpdateState.NoUpdates -> { + binding.title.setText(R.string.fragment_updates_no_updates) + binding.description.setText(R.string.fragment_updates_no_updates_description) + + binding.actionButton.setText(R.string.fragment_updates_check_updates) + binding.actionButton.setOnClickListener { viewModel.checkUpdates() } + } + UpdateState.Loading -> { + binding.title.clear() + binding.description.clear() + binding.actionButton.clear() + } + UpdateState.Error -> { + val error = viewModel.currentError.value ?: return + + binding.title.setText(R.string.fragment_updates_error_occurred) + + val errorText = + if (error.contains("cannot be converted", ignoreCase = true) + || error.contains("begin_object", ignoreCase = true) + ) { + "OTA Server is unavailable" + } else { + getString(R.string.fragment_updates_error_occurred_description, error) + } + + binding.description.text = errorText + + binding.actionButton.setText(R.string.fragment_updates_try_again) + binding.actionButton.setOnClickListener { viewModel.checkUpdates() } + } + UpdateState.Downloading -> { + binding.loadingProgress.run { + max = 0 + progress = 0 + isIndeterminate = true + } + } + } + } + + private fun checkIsInstallingAllowed(item: UpdateItem) { + if (!AndroidUtils.isCanInstallUnknownApps(requireContext())) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.warning) + builder.setMessage(R.string.fragment_updates_unknown_sources_disabled_message) + builder.setPositiveButton(R.string.yes) { _, _ -> + AndroidUtils.openInstallUnknownAppsScreen(requireContext()) + } + builder.setNegativeButton(R.string.no, null) + builder.show() + } else { + downloadUpdate(item) + } + } + + private fun downloadUpdate(newUpdate: UpdateItem) { + viewModel.updateState.setIfNotEquals(UpdateState.Loading) + + timer = Timer() + + val apkName = newUpdate.versionName + + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/$apkName.apk" + + val file = File(destination) + if (file.exists()) file.delete() + + val request = DownloadManager.Request(Uri.parse(newUpdate.downloadLink)).apply { + setTitle("${getString(R.string.app_name)} ${apkName}.apk") + setMimeType(AppConstants.INSTALL_APP_MIME_TYPE) + setDestinationInExternalFilesDir( + requireContext(), + Environment.DIRECTORY_DOWNLOADS, + "$apkName.apk" + ) + setAllowedNetworkTypes( + DownloadManager.Request.NETWORK_WIFI or + DownloadManager.Request.NETWORK_MOBILE + ) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + + val receiver = DownloadManagerReceiver() + receiver.onReceiveAction = { + timer?.cancel() + downloadId = null + + installUpdate(file) + + requireContext().unregisterReceiver(receiver) + + viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) + } + + requireContext().registerReceiver( + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + + downloadId = AppGlobal.downloadManager.enqueue(request) + + viewModel.updateState.setIfNotEquals(UpdateState.Downloading) + + if (binding.loadingProgress.max != 100 * 100) { + binding.loadingProgress.max = 100 * 100 + } + + timer?.schedule(object : TimerTask() { // for progress + override fun run() { + val query = DownloadManager.Query() + query.setFilterById(downloadId ?: -1) + + val cursor = AppGlobal.downloadManager.query(query) + if (cursor.moveToFirst()) { + val sizeIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val downloadedIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val size = cursor.getInt(sizeIndex) + val downloaded = cursor.getInt(downloadedIndex) + + val progress = + if (size != -1) (downloaded * 100.0F / size) else 0.0F + + if (progress.toInt() >= 1) { + lifecycleScope.launch(Dispatchers.Main) { + if (view == null) { + downloadId?.run { AppGlobal.downloadManager.remove(this) } + timer?.cancel() + return@launch + } + binding.loadingProgress.isIndeterminate = false + + if (binding.loadingProgress.progress != progress.toInt()) { + ObjectAnimator.ofInt( + binding.loadingProgress, + "progress", + binding.loadingProgress.progress, + progress.toInt() * 100 + ).apply { + duration = 250 + setAutoCancel(true) + interpolator = DecelerateInterpolator() + }.start() + } + } + } + + Log.d("Downloading update", "progress $progress%") + } + } + + }, 0, 250) + } + + private fun writeFileToStorage(responseBody: ResponseBody?) { + if (responseBody == null) return + + val updateItem = requireNotNull(viewModel.currentItem.value) + + try { + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + + "${File.separator}${updateItem.fileName}.${updateItem.extension}" + + val file = File(destination) + if (file.exists()) file.delete() + + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + val fileReader = ByteArray(4096) + val fileSize: Long = responseBody.contentLength() + + requireActivity().runOnUiThread { + binding.loadingProgress.max = fileSize.toInt() + binding.loadingProgress.progress = 0 + } + + var fileSizeDownloaded: Long = 0 + inputStream = responseBody.byteStream() + outputStream = FileOutputStream(file) + while (true) { + val read: Int = inputStream.read(fileReader) + if (read == -1) { + break + } + outputStream.write(fileReader, 0, read) + fileSizeDownloaded += read.toLong() + } + outputStream.flush() + + requireActivity().runOnUiThread { + installUpdate(file) + } + } catch (e: IOException) { + + } finally { + inputStream?.close() + outputStream?.close() + } + } catch (e: IOException) { + + } + } + + private fun installUpdate(file: File) { + val installIntent = AndroidUtils.getInstallPackageIntent( + requireContext(), + ARG_PROVIDER_PATH, + file + ) + + requireContext().startActivity(installIntent) + } + + private fun showChangelogAlert() { + val changelog = viewModel.currentItem.value?.changelog + + val messageText = + if (changelog.isNullOrBlank()) getString(R.string.fragment_updates_changelog_none) + else changelog + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.fragment_updates_changelog) + .setMessage(messageText) + .setPositiveButton(R.string.ok, null) + .show() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt new file mode 100644 index 00000000..416fa2b3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt @@ -0,0 +1,10 @@ +package com.meloda.fast.screens.updates + +import android.content.Context +import com.meloda.fast.base.ResourceProvider + +class UpdatesResourceProvider(context: Context) : ResourceProvider(context) { + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt new file mode 100644 index 00000000..950c48ba --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt @@ -0,0 +1,50 @@ +package com.meloda.fast.screens.updates + +import androidx.lifecycle.MutableLiveData +import com.meloda.fast.data.ota.OtaApi +import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.model.UpdateItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import javax.inject.Inject + + +@HiltViewModel +class UpdatesViewModel @Inject constructor( + private val updateManager: UpdateManager, + private val otaApi: OtaApi +) : BaseViewModel() { + + val updateState = MutableLiveData(UpdateState.Loading) + val currentError = MutableLiveData(null) + val currentItem = MutableLiveData(null) + + private var currentJob: Job? = null + + fun checkUpdates() { + if (currentJob != null) { + currentJob?.cancel() + currentJob = null + } + updateState.setIfNotEquals(UpdateState.Loading) + + currentJob = updateManager.checkUpdates { item, error -> + when { + item != null -> { + currentError.setIfNotEquals(null) + updateState.setIfNotEquals(UpdateState.NewUpdate) + } + error != null -> { + currentError.setIfNotEquals(error.message ?: "") + updateState.setIfNotEquals(UpdateState.Error) + } + else -> { + currentError.setIfNotEquals(null) + updateState.setIfNotEquals(UpdateState.NoUpdates) + } + } + }.apply { invokeOnCompletion { currentJob = null } } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt rename to app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt index a7a5657c..d38c230a 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt @@ -4,27 +4,33 @@ import android.app.Service import android.content.Intent import android.os.IBinder import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.edit import com.google.gson.JsonArray import com.google.gson.JsonObject -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.VKConstants -import com.meloda.fast.api.VKException +import com.meloda.fast.api.base.ApiError +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.model.base.BaseVkLongPoll -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.api.network.messages.MessagesDataSource import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.util.NotificationsUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import javax.inject.Inject import kotlin.coroutines.CoroutineContext @AndroidEntryPoint -class MessagesUpdateService : Service(), CoroutineScope { +class LongPollService : Service(), CoroutineScope { companion object { const val TAG = "LongPollTask" + + const val KeyLongPollWasDestroyed = "long_poll_was_destroyed" } private val job = SupervisorJob() @@ -38,10 +44,10 @@ class MessagesUpdateService : Service(), CoroutineScope { get() = Dispatchers.Default + job + exceptionHandler @Inject - lateinit var dataSource: MessagesDataSource + lateinit var repository: MessagesRepository @Inject - lateinit var longPollRepo: LongPollRepo + lateinit var longPollApi: LongPollApi @Inject lateinit var updatesParser: LongPollUpdatesParser @@ -51,19 +57,41 @@ class MessagesUpdateService : Service(), CoroutineScope { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d("LongPollService", "onStartCommand: flags: $flags; startId: $startId") launch { startPolling().join() } + + val notificationBuilder = + NotificationsUtils.createNotification( + context = this, + title = "Сервис анального зондирования", + contentText = "ищем нюдесы в ваших сообщениях", + notRemovable = true, + channelId = "long_polling", + priority = NotificationsUtils.NotificationPriority.Min, + category = NotificationCompat.CATEGORY_SERVICE + ) + + startForeground( + startId, + notificationBuilder.build() + ) return START_STICKY } private fun startPolling(): Job { - if (job.isCompleted || job.isCancelled) throw Exception("Job is over") + if (job.isCompleted || job.isCancelled) { + Log.d("LongPollService", "job is completed or cancelled. Fuck off") + throw Exception("Job is over") + } + + Log.d("LongPollService", "job started") return launch { var serverInfo = getServerInfo() - ?: throw VKException(error = "bad VK response (server info)") + ?: throw ApiError(errorMessage = "bad VK response (server info)") var lastUpdatesResponse: JsonObject? = getUpdatesResponse(serverInfo) - ?: throw VKException(error = "initiation error: bad VK response (last updates)") + ?: throw ApiError(errorMessage = "initiation error: bad VK response (last updates)") var failCount = 0 @@ -71,7 +99,7 @@ class MessagesUpdateService : Service(), CoroutineScope { if (lastUpdatesResponse == null) { failCount++ serverInfo = getServerInfo() - ?: throw VKException(error = "failed retrieving server info after error: bad VK response (server info #2)") + ?: throw ApiError(errorMessage = "failed retrieving server info after error: bad VK response (server info #2)") lastUpdatesResponse = getUpdatesResponse(serverInfo) continue } @@ -88,8 +116,8 @@ class MessagesUpdateService : Service(), CoroutineScope { } 2, 3 -> { serverInfo = getServerInfo() - ?: throw VKException( - error = "failed retrieving server info after error: bad VK response (server info #3)" + ?: throw ApiError( + errorMessage = "failed retrieving server info after error: bad VK response (server info #3)" ) lastUpdatesResponse = getUpdatesResponse(serverInfo) } @@ -122,7 +150,7 @@ class MessagesUpdateService : Service(), CoroutineScope { } private suspend fun getServerInfo(): BaseVkLongPoll? { - val response = dataSource.getLongPollServer( + val response = repository.getLongPollServer( MessagesGetLongPollServerRequest( needPts = true, version = VKConstants.LP_VERSION @@ -131,8 +159,8 @@ class MessagesUpdateService : Service(), CoroutineScope { println("$TAG: serverInfoResponse: $response") - if (response is Answer.Error) return null - if (response is Answer.Success) { + if (response is ApiAnswer.Error) return null + if (response is ApiAnswer.Success) { return response.data.response } @@ -140,21 +168,22 @@ class MessagesUpdateService : Service(), CoroutineScope { } private suspend fun getUpdatesResponse(server: BaseVkLongPoll): JsonObject? { - val response = dataSource.getLongPollUpdates( + val response = repository.getLongPollUpdates( serverUrl = "https://${server.server}", params = LongPollGetUpdatesRequest( key = server.key, ts = server.ts, wait = 25, - mode = 2 or 8 or 32 or 64 or 128 + mode = 2 or 8 or 32 or 64 or 128, + version = VKConstants.LP_VERSION ) ) println("$TAG: lastUpdateResponse: $response") - if (response is Answer.Error) return null + if (response is ApiAnswer.Error) return null - if (response is Answer.Success) { + if (response is ApiAnswer.Success) { return response.data } @@ -162,21 +191,24 @@ class MessagesUpdateService : Service(), CoroutineScope { } private fun handleUpdateEvent(eventJson: JsonArray) { -// println("$TAG: handleUpdateEvent: $eventJson") - updatesParser.parseNextUpdate(eventJson) } -// fun registerListener(eventType: Int, listener: VkEventCallback) = -// updatesParser.registerListener(eventType, listener) - override fun onDestroy() { + Log.d("LongPollService", "onDestroy") try { + AppGlobal.preferences.edit { + putBoolean(KeyLongPollWasDestroyed, true) + } job.cancel() } catch (e: Exception) { + e.printStackTrace() } - updatesParser.clearListeners() super.onDestroy() } + override fun onLowMemory() { + Log.d("LongPollService", "onLowMemory") + super.onLowMemory() + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt index f654dc84..a0ada619 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt @@ -5,9 +5,11 @@ import android.content.Intent import android.os.IBinder import android.util.Log import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.network.account.AccountDataSource import com.meloda.fast.api.network.account.AccountSetOfflineRequest import com.meloda.fast.api.network.account.AccountSetOnlineRequest +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.data.account.AccountsRepository +import com.meloda.fast.screens.settings.SettingsPrefsFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import java.util.* @@ -18,14 +20,10 @@ import kotlin.coroutines.CoroutineContext @AndroidEntryPoint class OnlineService : Service(), CoroutineScope { - private companion object { - private const val TAG = "OnlineService" - } - private val job = SupervisorJob() private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.d(MessagesUpdateService.TAG, "error: $throwable") + Log.d(LongPollService.TAG, "error: $throwable") throwable.printStackTrace() } @@ -33,43 +31,72 @@ class OnlineService : Service(), CoroutineScope { get() = Dispatchers.Default + job + exceptionHandler @Inject - lateinit var dataSource: AccountDataSource + lateinit var repository: AccountsRepository private var timer: Timer? = null override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - timer = Timer().apply { - schedule(delay = 0, period = 60_000) { - launch { - setOffline() - delay(5000) - setOnline() - } - } - } + Log.d("OnlineService", "onStartCommand: flags: $flags; startId: $startId") + createTimer() return START_STICKY_COMPATIBILITY } + private fun createTimer() { + timer = Timer().apply { + schedule(delay = 0, period = 60 * 1000L) { + launch { performJob() } + } + } + } + + private suspend fun performJob() { + if (!AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefSendOnlineStatus, true)) { + return + } + + setOffline() + delay(5000) + setOnline() + } + private suspend fun setOnline() { - println("$TAG: setOnline()") - val response = dataSource.setOnline( + Log.d("OnlineService", "setOnline()") + + val fastToken = UserConfig.fastToken + + val token = + if (fastToken == null) { + Log.d("OnlineService", "setOnline: Fast token is null. Using VK token") + UserConfig.accessToken + } else { + fastToken + } + + val response = repository.setOnline( AccountSetOnlineRequest( voip = false, - accessToken = UserConfig.fastToken + accessToken = token ) ) + Log.d("OnlineService", "setOnline: response: $response") } private suspend fun setOffline() { - println("$TAG: setOffline()") - val response = dataSource.setOffline( + Log.d("OnlineService", "setOffline()") + val response = repository.setOffline( AccountSetOfflineRequest( accessToken = UserConfig.accessToken ) ) + Log.d("OnlineService", "setOffline: response: $response") + } + + override fun onDestroy() { + super.onDestroy() + Log.d("OnlineService", "onDestroy") } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt index f9a04c30..ece66913 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt @@ -2,11 +2,20 @@ package com.meloda.fast.util import android.content.ClipData import android.content.Context +import android.content.Intent import android.content.res.Configuration +import android.content.res.Resources import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.provider.Settings import android.util.TypedValue import androidx.annotation.AttrRes +import androidx.core.content.FileProvider +import com.meloda.fast.BuildConfig +import com.meloda.fast.common.AppConstants import com.meloda.fast.common.AppGlobal +import java.io.File object AndroidUtils { @@ -33,11 +42,11 @@ object AndroidUtils { } fun getDisplayWidth(): Int { - return AppGlobal.resources.displayMetrics.widthPixels + return Resources.getSystem().displayMetrics.widthPixels } fun getDisplayHeight(): Int { - return AppGlobal.resources.displayMetrics.heightPixels + return Resources.getSystem().displayMetrics.heightPixels } fun copyText(label: String? = "", text: String) { @@ -58,11 +67,61 @@ object AndroidUtils { return color } - fun bytesToHumanReadableSize(bytes: Double) = when { + fun bytesToMegabytes(bytes: Double): Double { + return bytes / 1024 / 1024 + } + + fun bytesToHumanReadableSize(bytes: Double): String = when { bytes >= 1 shl 30 -> "%.1f GB".format(bytes / (1 shl 30)) bytes >= 1 shl 20 -> "%.1f MB".format(bytes / (1 shl 20)) bytes >= 1 shl 10 -> "%.0f KB".format(bytes / (1 shl 10)) else -> "$bytes B" } + @Suppress("DEPRECATION") + fun isCanInstallUnknownApps(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AppGlobal.packageManager.canRequestPackageInstalls() + } else { + Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.INSTALL_NON_MARKET_APPS + ) == 1 + } + } + + fun openInstallUnknownAppsScreen(context: Context) { + context.startActivity(Intent().apply { + action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Settings.ACTION_SECURITY_SETTINGS + } else { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES + } + }) + } + + fun getInstallPackageIntent( + context: Context, + providerPath: String, + fileToRead: File + ): Intent { + val intent = Intent(Intent.ACTION_VIEW) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + intent.data = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + providerPath, + fileToRead + ) + } else { + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.setDataAndType(Uri.fromFile(fileToRead), AppConstants.INSTALL_APP_MIME_TYPE) + } + + return intent + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt deleted file mode 100644 index 2c76a379..00000000 --- a/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.util - -import android.view.View -import com.meloda.fast.common.AppGlobal - -object KeyboardUtils { - - fun hideKeyboardFrom(focusedView: View?) { - AppGlobal.inputMethodManager.hideSoftInputFromWindow(focusedView?.windowToken, 0) - } - - fun showKeyboard(viewToFocus: View) { - AppGlobal.inputMethodManager.showSoftInput(viewToFocus, 0) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt new file mode 100644 index 00000000..e3833efc --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt @@ -0,0 +1,64 @@ +package com.meloda.fast.util + +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.meloda.fast.R + +object NotificationsUtils { + + fun createNotification( + context: Context, + title: String? = null, + contentText: String? = null, + bigText: String? = null, + customNotificationId: Int? = null, + showWhen: Boolean = false, + timeStampWhen: Long? = null, + notify: Boolean = false, + notRemovable: Boolean = false, + channelId: String = "simple_notifications", + priority: NotificationPriority = NotificationPriority.Default, + contentIntent: PendingIntent? = null, + category: String? = null + ): NotificationCompat.Builder { + var builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_fast_logo) + .setContentTitle(title) + .setPriority(priority.value) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setShowWhen(showWhen) + .setOngoing(notRemovable) + + if (category != null) { + builder = builder.setCategory(category) + } + + if (contentText != null) { + builder = builder.setContentText(contentText) + } + + if (bigText != null) { + builder = builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + } + + if (timeStampWhen != null) { + builder = builder.setWhen(timeStampWhen) + } + + if (notify) { + with(NotificationManagerCompat.from(context)) { + notify(customNotificationId ?: -1, builder.build()) + } + } + + return builder + } + + enum class NotificationPriority(val value: Int) { + Default(0), Low(-1), Min(-2), High(1), Max(2) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt index f934a9e4..494655bd 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt @@ -4,10 +4,11 @@ import android.content.Context import com.meloda.fast.R import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.TimeUnit object TimeUtils { - const val ONE_DAY_IN_SECONDS = 86400 + val OneDayInSeconds get() = TimeUnit.DAYS.toSeconds(1) fun removeTime(date: Date): Long { return Calendar.getInstance().apply { @@ -23,20 +24,18 @@ object TimeUtils { val now = Calendar.getInstance() val then = Calendar.getInstance().also { it.timeInMillis = date } - val pattern = - if (now[Calendar.YEAR] != then[Calendar.YEAR]) { - "dd MMM yyyy" - } else if (now[Calendar.MONTH] != then[Calendar.MONTH]) { - "dd MMMM" - } else if (now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH]) { + val pattern = when { + now[Calendar.YEAR] != then[Calendar.YEAR] -> "dd MMM yyyy" + now[Calendar.MONTH] != then[Calendar.MONTH] -> "dd MMMM" + now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH] -> { if (now[Calendar.DAY_OF_MONTH] - then[Calendar.DAY_OF_MONTH] == 1) { return context.getString(R.string.yesterday) } else { "dd MMMM" } - } else { - return context.getString(R.string.today) } + else -> return context.getString(R.string.today) + } return SimpleDateFormat(pattern, Locale.getDefault()).format(date) } diff --git a/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt new file mode 100644 index 00000000..872d7dbb --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt @@ -0,0 +1,40 @@ +package com.meloda.fast.util + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.R + +object ViewUtils { + + fun Context.showErrorDialog( + message: String, + showErrorPrefix: Boolean = true, + isCancelable: Boolean? = null, + positiveText: Int? = null, + positiveAction: (() -> Unit)? = null, + negativeText: Int? = null, + negativeAction: (() -> Unit)? = null, + ): AlertDialog { + val builder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage( + if (showErrorPrefix) getString(R.string.error, message) + else message + ) + .setPositiveButton(positiveText ?: R.string.ok) { _, _ -> + positiveAction?.invoke() + } + + negativeAction?.run { + builder.setNegativeButton( + negativeText ?: R.string.cancel + ) { _, _ -> this.invoke() } + } + + isCancelable?.run { builder.setCancelable(this) } + + return builder.show() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt b/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt new file mode 100644 index 00000000..9db7d758 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt @@ -0,0 +1,62 @@ +package com.meloda.fast.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.content.withStyledAttributes +import com.meloda.fast.R + +@SuppressLint("CustomViewStyleable") +class BoundedLinearLayout : LinearLayoutCompat { + private var mBoundedWidth: Int = 0 + private var mBoundedHeight: Int = 0 + + constructor(context: Context) : super(context) { + mBoundedWidth = 0 + mBoundedHeight = 0 + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + context.withStyledAttributes(attrs, R.styleable.BoundedView) { + mBoundedWidth = getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) + mBoundedHeight = getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) + } + } + + var maxWidth: Int + get() = mBoundedWidth + set(width) { + if (mBoundedWidth != width) { + mBoundedWidth = width + requestLayout() + } + } + + var maxHeight: Int + get() = mBoundedHeight + set(height) { + if (mBoundedHeight != height) { + mBoundedHeight = height + requestLayout() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var newWidthMeasureSpec = widthMeasureSpec + var newHeightMeasureSpec = heightMeasureSpec + + val measuredWidth = MeasureSpec.getSize(newWidthMeasureSpec) + if (mBoundedWidth in 1 until measuredWidth) { + val measureMode = MeasureSpec.getMode(newWidthMeasureSpec) + newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) + } + + val measuredHeight = MeasureSpec.getSize(newHeightMeasureSpec) + if (mBoundedHeight in 1 until measuredHeight) { + val measureMode = MeasureSpec.getMode(newHeightMeasureSpec) + newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) + } + super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt b/app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt similarity index 56% rename from app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt rename to app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt index 3aa3579c..6e574c3f 100644 --- a/app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt +++ b/app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt @@ -1,12 +1,12 @@ -package com.meloda.fast.widget +package com.meloda.fast.view import android.content.Context import android.graphics.Canvas import android.graphics.Path import android.graphics.RectF import android.util.AttributeSet -import android.view.ViewTreeObserver import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.doOnPreDraw class CircleImageView : AppCompatImageView { @@ -30,38 +30,32 @@ class CircleImageView : AppCompatImageView { } - override fun onDraw(canvas: Canvas?) { - rect ?: return - canvas ?: return - - if (rect!!.right == 0f || rect!!.bottom == 0f) { - createRect(width, height) + override fun onDraw(canvas: Canvas) { + rect?.let { rect -> + if (rect.right == 0F || rect.bottom == 0F) { + createRect(width, height) + } } - canvas.clipPath(path!!) + path?.run { canvas.clipPath(this) } super.onDraw(canvas) } private fun init() { scaleType = SCALE_TYPE - viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - createRect(width, height) - viewTreeObserver.removeOnPreDrawListener(this) - return false - } - }) + doOnPreDraw { createRect(width, height) } } private fun createRect(width: Int, height: Int) { - rect = RectF(0f, 0f, width.toFloat(), height.toFloat()) path = Path() - path!!.addRoundRect( - rect!!, - (width / 2).toFloat(), - (height / 2).toFloat(), - Path.Direction.CW - ) + rect = RectF(0f, 0f, width.toFloat(), height.toFloat()).apply { + path?.addRoundRect( + this, + width.toFloat() / 2F, + height.toFloat() / 2F, + Path.Direction.CW + ) + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt b/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt new file mode 100644 index 00000000..874a7a24 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.view + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class SpaceItemDecoration( + private val topMargin: Int? = null, + private val endMargin: Int? = null, + private val bottomMargin: Int? = null, + private val startMargin: Int? = null +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + topMargin?.run { outRect.top = this } + endMargin?.run { outRect.right = this } + bottomMargin?.run { outRect.bottom = this } + startMargin?.run { outRect.left = this } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt deleted file mode 100644 index 8781b954..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.meloda.fast.widget - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import com.meloda.fast.R - -class BoundedFrameLayout : FrameLayout { - private var mBoundedWidth: Int - private var mBoundedHeight: Int - - constructor(context: Context) : super(context) { - mBoundedWidth = 0 - mBoundedHeight = 0 - } - - @SuppressLint("CustomViewStyleable") - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - val a = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) - mBoundedWidth = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) - mBoundedHeight = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) - a.recycle() - } - - var maxWidth: Int - get() = mBoundedWidth - set(width) { - if (mBoundedWidth != width) { - mBoundedWidth = width - requestLayout() - } - } - - var maxHeight: Int - get() = mBoundedHeight - set(height) { - if (mBoundedHeight != height) { - mBoundedHeight = height - requestLayout() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Adjust width as necessary - var widthMeasureSpec = widthMeasureSpec - var heightMeasureSpec = heightMeasureSpec - val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) - - if (mBoundedWidth in 1 until measuredWidth) { - val measureMode = MeasureSpec.getMode(widthMeasureSpec) - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) - } - - // Adjust height as necessary - val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) - if (mBoundedHeight in 1 until measuredHeight) { - val measureMode = MeasureSpec.getMode(heightMeasureSpec) - heightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt deleted file mode 100644 index 754cc4b5..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.meloda.fast.widget - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.widget.LinearLayout -import com.meloda.fast.R - -class BoundedLinearLayout : LinearLayout { - private var mBoundedWidth: Int - private var mBoundedHeight: Int - - constructor(context: Context) : super(context) { - mBoundedWidth = 0 - mBoundedHeight = 0 - } - - @SuppressLint("CustomViewStyleable") - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - val a = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) - mBoundedWidth = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) - mBoundedHeight = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) - a.recycle() - } - - var maxWidth: Int - get() = mBoundedWidth - set(width) { - if (mBoundedWidth != width) { - mBoundedWidth = width - requestLayout() - } - } - - var maxHeight: Int - get() = mBoundedHeight - set(height) { - if (mBoundedHeight != height) { - mBoundedHeight = height - requestLayout() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Adjust width as necessary - var widthMeasureSpec = widthMeasureSpec - var heightMeasureSpec = heightMeasureSpec - val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) - if (mBoundedWidth in 1 until measuredWidth) { - val measureMode = MeasureSpec.getMode(widthMeasureSpec) - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) - } - // Adjust height as necessary - val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) - if (mBoundedHeight in 1 until measuredHeight) { - val measureMode = MeasureSpec.getMode(heightMeasureSpec) - heightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt b/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt deleted file mode 100644 index 94caf9a0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.Gravity -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.isVisible -import com.meloda.fast.R -import com.meloda.fast.extensions.dpToPx - -@Suppress("UNCHECKED_CAST") -class NoItemsView @JvmOverloads constructor( - context: Context, private var attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr) { - - private lateinit var noItemsPicture: ImageView - private lateinit var noItemsTextView: TextView - - private val textViewParams - get() = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT - ) - - private val imageViewParams - get() = LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ) - - init { - create() - } - - private fun create() { - val a = context.obtainStyledAttributes(attrs, R.styleable.NoItemsView) - - minimumWidth = 256.dpToPx() - minimumHeight = minimumWidth - - orientation = VERTICAL - gravity = Gravity.CENTER - - noItemsPicture = ImageView(context) - - val imageViewSize = 64.dpToPx() - - val params = imageViewParams.apply { - height = imageViewSize - width = imageViewSize - } - - noItemsPicture.layoutParams = params - - val noItemsDrawable = a.getDrawable(R.styleable.NoItemsView_noItemsImage) - noItemsDrawable?.let { - val noItemsDrawableTintColor = a.getColor(R.styleable.NoItemsView_noItemsImageTint, -1) - if (noItemsDrawableTintColor != -1) { - it.setTint(noItemsDrawableTintColor) - } - - setNoItemsImage(it) - } - - addView(noItemsPicture) - - noItemsTextView = TextView(context) - - val textParams = textViewParams - textParams.width = 256.dpToPx() - - if (noItemsDrawable != null) { - textParams.topMargin = 8.dpToPx() - } - - noItemsTextView.layoutParams = textParams - - noItemsTextView.gravity = Gravity.CENTER - noItemsTextView.setTextAppearance(R.style.TextAppearance_MaterialComponents_Body1) - - val noItemsTextColor = a.getColor(R.styleable.NoItemsView_noItemsTextColor, -1) - if (noItemsTextColor != -1) { - setNoItemsTextColor(noItemsTextColor) - } - - val noItemsText = a.getString(R.styleable.NoItemsView_noItemsText) - noItemsText?.let { - setNoItemsText(it) - } - - addView(noItemsTextView) - - val isVisibleByDefault = a.getBoolean(R.styleable.NoItemsView_isVisibleByDefault, true) - isVisible = isVisibleByDefault - - a.recycle() - } - - fun setNoItemsImage(@DrawableRes resId: Int) { - setNoItemsImage(AppCompatResources.getDrawable(context, resId)) - } - - fun setNoItemsImage(drawable: Drawable?) { - noItemsPicture.setImageDrawable(drawable) - } - - fun setNoItemsImageTint(@ColorInt color: Int) { - noItemsPicture.drawable?.setTint(color) - } - - fun setNoItemsText(@StringRes resId: Int) { - noItemsTextView.setText(resId) - } - - fun setNoItemsText(text: String) { - noItemsTextView.text = text - } - - fun setNoItemsTextColor(@ColorInt color: Int) { - noItemsTextView.setTextColor(color) - } - - fun show() { - isVisible = true - } - - fun hide() { - isVisible = false - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java b/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java deleted file mode 100644 index b97454e9..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.meloda.fast.widget; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.widget.FrameLayout; - -public class RoundedCornerLayout extends FrameLayout { - private final static float CORNER_RADIUS = 40.0f; - - private Bitmap maskBitmap; - private Paint paint, maskPaint; - private float cornerRadius; - - public RoundedCornerLayout(Context context) { - super(context); - init(context, null, 0); - } - - public RoundedCornerLayout(Context context, AttributeSet attrs) { - super(context, attrs); - init(context, attrs, 0); - } - - public RoundedCornerLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(context, attrs, defStyle); - } - - private void init(Context context, AttributeSet attrs, int defStyle) { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, CORNER_RADIUS, metrics); - - paint = new Paint(Paint.ANTI_ALIAS_FLAG); - - maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); - maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - - setWillNotDraw(false); - } - - @Override - public void draw(Canvas canvas) { - Bitmap offscreenBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - Canvas offscreenCanvas = new Canvas(offscreenBitmap); - - super.draw(offscreenCanvas); - - if (maskBitmap == null) { - maskBitmap = createMask(getWidth(), getHeight()); - } - - offscreenCanvas.drawBitmap(maskBitmap, 0f, 0f, maskPaint); - canvas.drawBitmap(offscreenBitmap, 0f, 0f, paint); - } - - private Bitmap createMask(int width, int height) { - Bitmap mask = Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8); - Canvas canvas = new Canvas(mask); - - Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); - paint.setColor(Color.WHITE); - - canvas.drawRect(0, 0, width, height, paint); - - paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - canvas.drawRoundRect(new RectF(0, 0, width, height), cornerRadius, cornerRadius, paint); - - return mask; - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt deleted file mode 100644 index fe2ee3ba..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Region -import android.util.AttributeSet -import android.widget.FrameLayout -import com.meloda.fast.R - -class RoundedFrameLayout : FrameLayout { - /** - * The corners than can be changed - */ - private var topLeftCornerRadius = 0f - private var topRightCornerRadius = 0f - private var bottomLeftCornerRadius = 0f - private var bottomRightCornerRadius = 0f - - constructor(context: Context) : super(context) { - init(context, null, 0) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context, attrs, 0) - } - - constructor( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int - ) : super(context, attrs, defStyleAttr) { - init(context, attrs, defStyleAttr) - } - - private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) { - val typedArray = context.obtainStyledAttributes( - attrs, - R.styleable.RoundedFrameLayout, 0, 0 - ) - - topLeftCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_topLeftCornerRadius, 0f) - topRightCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_topRightCornerRadius, 0f) - bottomLeftCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_bottomLeftCornerRadius, 0f) - bottomRightCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_bottomRightCornerRadius, 0f) - - typedArray.recycle() - setLayerType(LAYER_TYPE_HARDWARE, null) - } - - override fun dispatchDraw(canvas: Canvas) { - val count: Int = canvas.save() - val path = Path() - val cornerDimensions = floatArrayOf( - topLeftCornerRadius, topLeftCornerRadius, - topRightCornerRadius, topRightCornerRadius, - bottomRightCornerRadius, bottomRightCornerRadius, - bottomLeftCornerRadius, bottomLeftCornerRadius - ) - path.addRoundRect( - RectF(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat()), - cornerDimensions, - Path.Direction.CW - ) - canvas.clipPath(path, Region.Op.INTERSECT) - canvas.clipPath(path) - super.dispatchDraw(canvas) - canvas.restoreToCount(count) - } - - fun setTopLeftCornerRadius(topLeftCornerRadius: Float) { - this.topLeftCornerRadius = topLeftCornerRadius - invalidate() - } - - fun setTopRightCornerRadius(topRightCornerRadius: Float) { - this.topRightCornerRadius = topRightCornerRadius - invalidate() - } - - fun setBottomLeftCornerRadius(bottomLeftCornerRadius: Float) { - this.bottomLeftCornerRadius = bottomLeftCornerRadius - invalidate() - } - - fun setBottomRightCornerRadius(bottomRightCornerRadius: Float) { - this.bottomRightCornerRadius = bottomRightCornerRadius - invalidate() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt b/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt deleted file mode 100644 index 3431a7e1..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.Rect -import android.util.AttributeSet -import com.google.android.material.textview.MaterialTextView - -class ScrollingTextView : MaterialTextView { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { - if (focused) super.onFocusChanged(focused, direction, previouslyFocusedRect) - } - - override fun onWindowFocusChanged(focused: Boolean) { - if (focused) super.onWindowFocusChanged(true) - } - - override fun isFocused(): Boolean { - return true - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt b/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt deleted file mode 100644 index ff134f32..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.text.Layout -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatTextView -import com.meloda.fast.R -import kotlin.math.ceil -import kotlin.math.max - -class WrapTextView(context: Context, attrs: AttributeSet? = null) : - AppCompatTextView(context, attrs) { - - private var fixWrapText = false - - constructor(context: Context) : this(context, null) - - init { - init(context, attrs) - } - - private fun init(context: Context, attrs: AttributeSet?) { - val a = context.theme.obtainStyledAttributes(attrs, R.styleable.WrapTextView, 0, 0) - - try { - fixWrapText = a.getBoolean(R.styleable.WrapTextView_fixWrap, false) - } finally { - a.recycle() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - if (fixWrapText && MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) { - val width = getMaxWidth(layout) - if (width in 1 until measuredWidth) { - super.onMeasure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), - heightMeasureSpec - ) - } - } - } - - private fun getMaxWidth(layout: Layout): Int { - if (layout.lineCount < 2) return 0 - - var maxWidth = 0.0f - for (i in 0 until layout.lineCount) { - maxWidth = max(maxWidth, layout.getLineWidth(i)) - } - - return ceil(maxWidth).toInt() - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml b/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml new file mode 100644 index 00000000..776ab808 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_notification_new_message.png b/app/src/main/res/drawable-hdpi/ic_notification_new_message.png new file mode 100644 index 00000000..61433bfc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification_new_message.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification_new_message.png b/app/src/main/res/drawable-mdpi/ic_notification_new_message.png new file mode 100644 index 00000000..aa174d47 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification_new_message.png differ diff --git a/app/src/main/res/drawable-v21/ic_fast_lightning.xml b/app/src/main/res/drawable-v21/ic_fast_lightning.xml deleted file mode 100644 index 526f0859..00000000 --- a/app/src/main/res/drawable-v21/ic_fast_lightning.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v21/ic_message_outline.xml b/app/src/main/res/drawable-v21/ic_message_outline.xml deleted file mode 100644 index dbb492ab..00000000 --- a/app/src/main/res/drawable-v21/ic_message_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_notification_new_message.png b/app/src/main/res/drawable-xhdpi/ic_notification_new_message.png new file mode 100644 index 00000000..760af05f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification_new_message.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png b/app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png new file mode 100644 index 00000000..6499dce6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png differ diff --git a/app/src/main/res/drawable/ic_close_in_circle.xml b/app/src/main/res/drawable/ic_close_in_circle.xml new file mode 100644 index 00000000..96c95cab --- /dev/null +++ b/app/src/main/res/drawable/ic_close_in_circle.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground_splash.xml b/app/src/main/res/drawable/ic_launcher_foreground_splash.xml new file mode 100644 index 00000000..0bf84b05 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_splash.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_message_in_background_middle.xml b/app/src/main/res/drawable/ic_message_in_background_middle.xml index f44081fd..2000155e 100644 --- a/app/src/main/res/drawable/ic_message_in_background_middle.xml +++ b/app/src/main/res/drawable/ic_message_in_background_middle.xml @@ -4,10 +4,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_out_background_middle.xml b/app/src/main/res/drawable/ic_message_out_background_middle.xml index 16ef3543..d1c61162 100644 --- a/app/src/main/res/drawable/ic_message_out_background_middle.xml +++ b/app/src/main/res/drawable/ic_message_out_background_middle.xml @@ -8,10 +8,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_panel_background.xml b/app/src/main/res/drawable/ic_message_panel_background.xml index fdcd1463..ff07f7a5 100644 --- a/app/src/main/res/drawable/ic_message_panel_background.xml +++ b/app/src/main/res/drawable/ic_message_panel_background.xml @@ -2,11 +2,7 @@ - + diff --git a/app/src/main/res/drawable/ic_online_pc.xml b/app/src/main/res/drawable/ic_online_pc.xml index 74261079..1f7e959a 100644 --- a/app/src/main/res/drawable/ic_online_pc.xml +++ b/app/src/main/res/drawable/ic_online_pc.xml @@ -5,7 +5,7 @@ android:height="14dp"> - + + + + + + diff --git a/app/src/main/res/drawable/ic_round_access_time_24.xml b/app/src/main/res/drawable/ic_round_access_time_24.xml new file mode 100644 index 00000000..b0aaa17c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_access_time_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml new file mode 100644 index 00000000..855e6815 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_bookmark_border_24.xml b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml new file mode 100644 index 00000000..3429f276 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_close_24.xml b/app/src/main/res/drawable/ic_round_close_24.xml new file mode 100644 index 00000000..0aa41eb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_close_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_done_all_24.xml b/app/src/main/res/drawable/ic_round_done_all_24.xml new file mode 100644 index 00000000..f134c697 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_done_all_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml new file mode 100644 index 00000000..c8e85353 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_error_outline_24.xml b/app/src/main/res/drawable/ic_round_error_outline_24.xml new file mode 100644 index 00000000..7d643610 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_error_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_group_24.xml b/app/src/main/res/drawable/ic_round_group_24.xml new file mode 100644 index 00000000..7704f2b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_group_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml new file mode 100644 index 00000000..a8340c19 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_mail_24.xml b/app/src/main/res/drawable/ic_round_mail_24.xml new file mode 100644 index 00000000..d9a337d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mail_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_mic_none_24.xml b/app/src/main/res/drawable/ic_round_mic_none_24.xml new file mode 100644 index 00000000..a18159f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mic_none_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_settings_24.xml b/app/src/main/res/drawable/ic_round_settings_24.xml new file mode 100644 index 00000000..a277a99d --- /dev/null +++ b/app/src/main/res/drawable/ic_round_settings_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_star_24.xml b/app/src/main/res/drawable/ic_round_star_24.xml new file mode 100644 index 00000000..f62410ba --- /dev/null +++ b/app/src/main/res/drawable/ic_round_star_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml b/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml new file mode 100644 index 00000000..cf263555 --- /dev/null +++ b/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 124de6fa..d634b0e7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,42 @@ - \ No newline at end of file + android:layout_height="match_parent"> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_captcha.xml b/app/src/main/res/layout/dialog_captcha.xml index 31348ebd..0cb45b0a 100644 --- a/app/src/main/res/layout/dialog_captcha.xml +++ b/app/src/main/res/layout/dialog_captcha.xml @@ -10,7 +10,8 @@ @@ -25,12 +26,15 @@ android:id="@+id/captchaImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_gravity="center_vertical" + android:layout_marginTop="2dp" + android:layout_marginEnd="16dp" android:src="@drawable/ic_security" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> @@ -51,27 +55,26 @@ diff --git a/app/src/main/res/layout/dialog_fast_login.xml b/app/src/main/res/layout/dialog_fast_login.xml new file mode 100644 index 00000000..f20d70b0 --- /dev/null +++ b/app/src/main/res/layout/dialog_fast_login.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_validation.xml b/app/src/main/res/layout/dialog_validation.xml index 9bf823a9..6f636d65 100644 --- a/app/src/main/res/layout/dialog_validation.xml +++ b/app/src/main/res/layout/dialog_validation.xml @@ -10,19 +10,21 @@ android:id="@+id/codeContainer" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" android:orientation="horizontal"> + app:tint="?colorPrimary" /> @@ -40,31 +42,29 @@ - diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml new file mode 100644 index 00000000..915e4c3f --- /dev/null +++ b/app/src/main/res/layout/drawer_header.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_conversations.xml b/app/src/main/res/layout/fragment_conversations.xml index 59042084..d0d5c48c 100644 --- a/app/src/main/res/layout/fragment_conversations.xml +++ b/app/src/main/res/layout/fragment_conversations.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:elevation="0dp"> - - - - - - - - - - + app:titleTextColor="?colorOnBackground" /> @@ -62,6 +34,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" + android:overScrollMode="ifContentScrolls" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/item_conversation" /> @@ -69,7 +42,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml index 4b17b183..a80a4e37 100644 --- a/app/src/main/res/layout/fragment_login.xml +++ b/app/src/main/res/layout/fragment_login.xml @@ -7,11 +7,33 @@ android:layout_height="match_parent" android:orientation="vertical"> - + android:visibility="gone"> + + + + + + + + - + android:src="@drawable/ic_launcher_foreground" + android:tint="?colorPrimary" /> @@ -61,20 +83,22 @@ android:id="@+id/loginImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="18dp" android:src="@drawable/ic_baseline_account_circle_24" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> @@ -94,25 +118,28 @@ android:id="@+id/passwordImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="18dp" android:src="@drawable/ic_key" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> + app:passwordToggleTint="?colorPrimary"> @@ -124,8 +151,7 @@ android:layout_width="wrap_content" android:layout_height="60dp" android:layout_gravity="center_horizontal|bottom" - android:layout_marginTop="12dp" - android:backgroundTint="@color/a1_600" + android:layout_marginTop="48dp" android:fontFamily="@font/google_sans_medium" android:letterSpacing="0" android:paddingStart="24dp" @@ -135,7 +161,8 @@ app:cornerRadius="50dp" app:elevation="16dp" app:icon="@drawable/ic_arrow_end" - app:iconGravity="end" /> + app:iconGravity="end" + app:tint="?colorPrimary" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_messages_history.xml b/app/src/main/res/layout/fragment_messages_history.xml index c157381a..4967b7e1 100644 --- a/app/src/main/res/layout/fragment_messages_history.xml +++ b/app/src/main/res/layout/fragment_messages_history.xml @@ -1,250 +1,120 @@ - + android:id="@+id/refresh_layout" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/list_anchor" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/app_bar"> - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - + app:navigationIcon="@drawable/ic_round_arrow_back_24" + app:navigationIconTint="?colorOnBackground" + app:subtitleCentered="true" + app:titleCentered="true" + tools:subtitle="Last seen at 05.26.21, 17:55" + tools:title="@tools:sample/full_names" /> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + tools:visibility="visible"> - + android:layout_marginHorizontal="16dp" + tools:visibility="gone"> - + android:ellipsize="end" + android:maxLines="1" + android:textColor="?colorOnBackground" + app:fontFamily="@font/google_sans_regular" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toStartOf="@id/dismissReply" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="Michael Bae" /> - - - - - + - + + + + - + android:backgroundTint="?colorBackground" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> - + + + + - - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> + android:padding="12dp" + android:src="@drawable/ic_round_add_circle_outline_24" + android:tint="?colorPrimary" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + android:layout_height="18dp" + android:layout_weight="0" + android:background="@drawable/ic_back" + android:backgroundTint="?colorOnBackgroundVariantContainer" + android:gravity="center" + android:minWidth="18dp" + android:paddingHorizontal="2dp" + android:textColor="?colorOnBackgroundVariantOnContainer" + android:textSize="11sp" + android:visibility="gone" + app:layout_constraintEnd_toEndOf="@id/attach" + app:layout_constraintTop_toTopOf="@id/attach" + tools:text="3" + tools:visibility="visible" /> - + + + + + + + + + + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings_root.xml b/app/src/main/res/layout/fragment_settings_root.xml new file mode 100644 index 00000000..72ed28c1 --- /dev/null +++ b/app/src/main/res/layout/fragment_settings_root.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_updates.xml b/app/src/main/res/layout/fragment_updates.xml new file mode 100644 index 00000000..7dea74e9 --- /dev/null +++ b/app/src/main/res/layout/fragment_updates.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 5ac5b951..00c71acf 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -11,19 +11,19 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="20dp" + android:layout_marginStart="16dp" android:backgroundTint="?colorBackgroundVariant" android:orientation="horizontal" android:paddingVertical="8dp" android:paddingStart="8dp" - android:paddingEnd="32dp" + android:paddingEnd="24dp" tools:background="@drawable/ic_message_unread"> - - - - - - - - + - - + diff --git a/app/src/main/res/layout/item_message_attachment_call.xml b/app/src/main/res/layout/item_message_attachment_call.xml index 843c0078..750f452f 100644 --- a/app/src/main/res/layout/item_message_attachment_call.xml +++ b/app/src/main/res/layout/item_message_attachment_call.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_file.xml b/app/src/main/res/layout/item_message_attachment_file.xml index cadbb2a9..0d83ba32 100644 --- a/app/src/main/res/layout/item_message_attachment_file.xml +++ b/app/src/main/res/layout/item_message_attachment_file.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_forwards.xml b/app/src/main/res/layout/item_message_attachment_forwards.xml new file mode 100644 index 00000000..bb0f2f9a --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_forwards.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_geo.xml b/app/src/main/res/layout/item_message_attachment_geo.xml new file mode 100644 index 00000000..950b9f77 --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_geo.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_gift.xml b/app/src/main/res/layout/item_message_attachment_gift.xml index 56adcaca..8b89d013 100644 --- a/app/src/main/res/layout/item_message_attachment_gift.xml +++ b/app/src/main/res/layout/item_message_attachment_gift.xml @@ -1,5 +1,5 @@ - @@ -8,4 +8,4 @@ android:id="@+id/image" android:layout_width="wrap_content" android:layout_height="wrap_content" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_graffiti.xml b/app/src/main/res/layout/item_message_attachment_graffiti.xml index 56adcaca..8b89d013 100644 --- a/app/src/main/res/layout/item_message_attachment_graffiti.xml +++ b/app/src/main/res/layout/item_message_attachment_graffiti.xml @@ -1,5 +1,5 @@ - @@ -8,4 +8,4 @@ android:id="@+id/image" android:layout_width="wrap_content" android:layout_height="wrap_content" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_link.xml b/app/src/main/res/layout/item_message_attachment_link.xml index c97a1d4b..584e5008 100644 --- a/app/src/main/res/layout/item_message_attachment_link.xml +++ b/app/src/main/res/layout/item_message_attachment_link.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_photo.xml b/app/src/main/res/layout/item_message_attachment_photo.xml index 0a6ce835..97f91bbd 100644 --- a/app/src/main/res/layout/item_message_attachment_photo.xml +++ b/app/src/main/res/layout/item_message_attachment_photo.xml @@ -20,7 +20,7 @@ android:id="@+id/image" android:layout_width="0dp" android:layout_height="0dp" - android:layout_margin="2dp" + android:layout_margin="0dp" android:adjustViewBounds="true" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="@id/border" diff --git a/app/src/main/res/layout/item_message_attachment_reply.xml b/app/src/main/res/layout/item_message_attachment_reply.xml new file mode 100644 index 00000000..0181df75 --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_reply.xml @@ -0,0 +1,49 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_video.xml b/app/src/main/res/layout/item_message_attachment_video.xml index 580a09d6..1829dd48 100644 --- a/app/src/main/res/layout/item_message_attachment_video.xml +++ b/app/src/main/res/layout/item_message_attachment_video.xml @@ -20,7 +20,7 @@ android:id="@+id/image" android:layout_width="0dp" android:layout_height="0dp" - android:layout_margin="2dp" + android:layout_margin="0dp" android:adjustViewBounds="true" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="@id/border" diff --git a/app/src/main/res/layout/item_message_attachment_voice.xml b/app/src/main/res/layout/item_message_attachment_voice.xml index 774b8789..4532d531 100644 --- a/app/src/main/res/layout/item_message_attachment_voice.xml +++ b/app/src/main/res/layout/item_message_attachment_voice.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_wall_post.xml b/app/src/main/res/layout/item_message_attachment_wall_post.xml index 056f58e1..a4e0a877 100644 --- a/app/src/main/res/layout/item_message_attachment_wall_post.xml +++ b/app/src/main/res/layout/item_message_attachment_wall_post.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_in.xml b/app/src/main/res/layout/item_message_in.xml index 332e3c67..f955d8d9 100644 --- a/app/src/main/res/layout/item_message_in.xml +++ b/app/src/main/res/layout/item_message_in.xml @@ -1,100 +1,140 @@ - - - + + - - + android:layout_marginStart="6dp" + android:background="@drawable/ic_message_in_background_middle" + android:backgroundTint="?colorSurfaceVariant" + android:minWidth="60dp" + android:minHeight="36dp" + app:layout_constrainedWidth="true" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toEndOf="@id/avatar" + app:layout_constraintTop_toBottomOf="@id/spacer"> - + android:layout_margin="4dp" + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/text" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/title" /> - - - - - - - - - - - - - - - - + + + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text" /> - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_out.xml b/app/src/main/res/layout/item_message_out.xml index ca88b6a6..95c03742 100644 --- a/app/src/main/res/layout/item_message_out.xml +++ b/app/src/main/res/layout/item_message_out.xml @@ -1,74 +1,114 @@ - - + - + android:layout_marginEnd="6dp" + android:background="@drawable/ic_message_in_background_middle" + android:backgroundTint="?colorTertiaryContainer" + android:minWidth="60dp" + android:minHeight="36dp" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintTop_toBottomOf="@id/spacer"> - - - + android:layout_margin="4dp" + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/text" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:autoLink="all" + android:padding="6dp" + android:textColor="?colorOnBackground" + android:textSize="16sp" + app:layout_constrainedHeight="true" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/attachment_container" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/reply_container" + app:layout_constraintVertical_bias="0" + app:layout_constraintVertical_chainStyle="packed" + tools:text="This is some kind of a text\ " /> + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text" /> - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_audio.xml b/app/src/main/res/layout/item_uploaded_attachment_audio.xml new file mode 100644 index 00000000..a85ffdca --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_audio.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_file.xml b/app/src/main/res/layout/item_uploaded_attachment_file.xml new file mode 100644 index 00000000..b5fd2bab --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_file.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_photo.xml b/app/src/main/res/layout/item_uploaded_attachment_photo.xml new file mode 100644 index 00000000..1e02c66e --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_photo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_video.xml b/app/src/main/res/layout/item_uploaded_attachment_video.xml new file mode 100644 index 00000000..6a36733e --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_video.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_menu_item_avatar.xml b/app/src/main/res/layout/toolbar_menu_item_avatar.xml new file mode 100644 index 00000000..68ca49af --- /dev/null +++ b/app/src/main/res/layout/toolbar_menu_item_avatar.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_bottom.xml b/app/src/main/res/menu/activity_main_bottom.xml index fac928d2..36b364d7 100644 --- a/app/src/main/res/menu/activity_main_bottom.xml +++ b/app/src/main/res/menu/activity_main_bottom.xml @@ -1,9 +1,19 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml new file mode 100644 index 00000000..3da21556 --- /dev/null +++ b/app/src/main/res/menu/activity_main_drawer.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_conversations.xml b/app/src/main/res/menu/fragment_conversations.xml index e006374c..07727f64 100644 --- a/app/src/main/res/menu/fragment_conversations.xml +++ b/app/src/main/res/menu/fragment_conversations.xml @@ -2,10 +2,11 @@ - - - - - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1..ef49c991 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml new file mode 100644 index 00000000..90a3711b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml new file mode 100644 index 00000000..2f2de293 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7353dbd1..ef49c991 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml new file mode 100644 index 00000000..ab0e9e61 --- /dev/null +++ b/app/src/main/res/values-v27/themes.xml @@ -0,0 +1,12 @@ + + + + + + - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d606e5f0..b570edc2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ - + - + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml new file mode 100644 index 00000000..d8cf32d5 --- /dev/null +++ b/app/src/main/res/xml/preferences.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 5b9dd9fb..a7fa4c3b 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -3,7 +3,10 @@ - + \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -89,84 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done fi +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f9553162..107acd32 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -13,15 +29,18 @@ if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ota_alpha.json b/ota_alpha.json new file mode 100644 index 00000000..07c89ef7 --- /dev/null +++ b/ota_alpha.json @@ -0,0 +1,12 @@ +{ + "version": "1.4.8", + "link": "https://github.com/melod1n/fast-messenger/releases/download/1.4.8/app-alpha.apk", + "changelogs": { + "1.4.3": "* forwards attachment\n* fix empty screen after clear database", + "1.4.4": "* hotfix crash on conversations list", + "1.4.5": "* ability to see changelog on update screen\n* settings screen\n* ACRA Crash Reporter", + "1.4.6": "* hotfix light status bar on android < 8", + "1.4.7": "* multiline dialogs in settings\n* fix crash on 2FA sign in", + "1.4.8": "* Microsoft AppCenter integration with crashes and distribution" + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 47bfbee1..1e8d82b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,116 @@ rootProject.name = "fast-messenger" -include(":app") \ No newline at end of file +include(":app") + +enableFeaturePreview("VERSION_CATALOGS") + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + // androidx - Core + library("androidx-core", "androidx.core:core-ktx:1.8.0") + + // androidx - Lifecycle + version("androidx-lifecycle", "2.5.1") + library("androidx-lifecycle-viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-livedata", "androidx.lifecycle", "lifecycle-livedata-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-runtime", "androidx.lifecycle", "lifecycle-runtime-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-viewmodel-savedstate", "androidx.lifecycle", "lifecycle-viewmodel-savedstate").versionRef("androidx-lifecycle") + library("androidx-lifecycle-common-java8", "androidx.lifecycle", "lifecycle-common-java8").versionRef("androidx-lifecycle") + + // androidx - SplashScreen + library("androidx-splashScreen", "androidx.core:core-splashscreen:1.0.0") + + // androidx - DataStore + library("androidx-dataStore", "androidx.datastore:datastore-preferences:1.0.0") + + // androidx - AppCompat + library("androidx-appCompat", "androidx.appcompat:appcompat:1.5.0") + + // androidx - Activity + library("androidx-activity", "androidx.activity:activity-ktx:1.5.1") + + // androidx - Fragment + library("androidx-fragment", "androidx.fragment:fragment-ktx:1.5.2") + + // androidx - Preference + library("androidx-preference", "androidx.preference:preference-ktx:1.2.0") + + // androidx - SwipeRefreshLayout + library("androidx-swipeRefreshLayout", "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // androidx - RecyclerView + library("androidx-recyclerView", "androidx.recyclerview:recyclerview:1.2.1") + + // androidx - CardView + library("androidx-cardView", "androidx.cardview:cardview:1.0.0") + + // androidx - ConstraintLayout + library("androidx-constraintLayout", "androidx.constraintlayout:constraintlayout:2.1.4") + + // androidx - Room + version("room", "2.4.3") + library("androidx-room", "androidx.room", "room-ktx").versionRef("room") + library("androidx-room-runtime", "androidx.room", "room-runtime").versionRef("room") + library("androidx-room-compiler", "androidx.room", "room-compiler").versionRef("room") + + // Cicerone + library("cicerone", "com.github.terrakok:cicerone:7.1") + + // WaveformSeekBar + library("waveformSeekBar", "com.github.massoudss:waveformSeekBar:5.0.0") + + // Glide + version("glide", "4.13.0") + library("glide", "com.github.bumptech.glide", "glide").versionRef("glide") + library("glide-compiler", "com.github.bumptech.glide", "compiler").versionRef("glide") + + // KPermissions + version("kPermissions", "3.3.0") + library("kPermissions", "com.github.fondesa", "kpermissions").versionRef("kPermissions") + library("kPermissions-coroutines", "com.github.fondesa", "kpermissions-coroutines").versionRef("kPermissions") + + // Microsoft AppCenter + version("appCenterSdk", "4.3.1") + library("appCenter-analytics", "com.microsoft.appcenter", "appcenter-analytics").versionRef("appCenterSdk") + library("appCenter-crashes", "com.microsoft.appcenter", "appcenter-crashes").versionRef("appCenterSdk") + + // Hilt + version("hilt", "2.39.1") + library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") + library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") + + // Retrofit + version("retrofit", "2.9.0") + library("retrofit", "com.squareup.retrofit2", "retrofit").versionRef("retrofit") + library("retrofit-gson-converter", "com.squareup.retrofit2", "converter-gson").versionRef("retrofit") + + // OkHttp3 + version("okhttp3", "5.0.0-alpha.2") + library("okhttp3", "com.squareup.okhttp3", "okhttp").versionRef("okhttp3") + library("okhttp3-interceptor", "com.squareup.okhttp3", "logging-interceptor").versionRef("okhttp3") + + // Coroutines + version("coroutines", "1.6.1") + library("coroutines-core", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").versionRef("coroutines") + library("coroutines-android", "org.jetbrains.kotlinx", "kotlinx-coroutines-android").versionRef("coroutines") + + // ViewBinding Delegate + library("viewBindingDelegate", "com.github.yogacp:android-viewbinding:1.0.4") + + // Google - Gson + library("google-gson", "com.google.code.gson:gson:2.8.9") + + // Google - Guava + library("google-guava", "com.google.guava:guava:31.1-android") + + // Google - Material + library("google-material", "com.google.android.material:material:1.6.1") + + // Jsoup + library("jsoup", "org.jsoup:jsoup:1.15.1") + + // Chucker + library("chucker", "com.github.chuckerteam.chucker:library:3.5.2") + } + } +} \ No newline at end of file