Merge pull request #8 from melod1n/code_saving

Code saving
This commit is contained in:
2021-10-11 12:10:37 +03:00
committed by GitHub
127 changed files with 2585 additions and 1565 deletions
+19 -10
View File
@@ -7,9 +7,9 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
id("kotlin-parcelize")
id("androidx.navigation.safeargs.kotlin")
id("dagger.hilt.android.plugin")
id("kotlin-parcelize")
}
android {
@@ -38,6 +38,9 @@ android {
getByName("release") {
isMinifyEnabled = false
buildConfigField("String", "vkLogin", login)
buildConfigField("String", "vkPassword", password)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -68,7 +71,7 @@ android {
kapt {
correctErrorTypes = true
//use this shit if you don't want to have hilt errors
//use this shit if you don't want have hilt errors
javacOptions {
option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true")
}
@@ -79,13 +82,19 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
implementation("com.github.massoudss:waveformSeekBar:3.1.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.work:work-runtime-ktx:2.6.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.appcompat:appcompat:1.4.0-alpha03")
implementation("com.google.android.material:material:1.5.0-alpha03")
implementation("androidx.core:core-ktx:1.7.0-beta01")
implementation("androidx.paging:paging-runtime-ktx:3.0.1")
implementation("androidx.appcompat:appcompat:1.4.0-beta01")
implementation("com.google.android.material:material:1.5.0-alpha04")
implementation("androidx.core:core-ktx:1.7.0-beta02")
implementation("androidx.preference:preference-ktx:1.1.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
implementation("androidx.recyclerview:recyclerview:1.2.1")
@@ -93,7 +102,7 @@ dependencies {
implementation("androidx.fragment:fragment-ktx:1.3.6")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("androidx.room:room-ktx:2.3.0")
implementation("androidx.room:room-runtime:2.3.0")
@@ -113,16 +122,16 @@ dependencies {
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.google.dagger:hilt-android:2.38.1")
kapt("com.google.dagger:hilt-android-compiler:2.38.1")
implementation("com.google.dagger:hilt-android:2.39.1")
kapt("com.google.dagger:hilt-android-compiler:2.39.1")
implementation("androidx.hilt:hilt-navigation-fragment:1.0.0")
implementation("com.github.yogacp:android-viewbinding:1.0.3")
implementation("io.coil-kt:coil:1.3.2")
implementation("io.coil-kt:coil:1.4.0")
implementation("com.google.code.gson:gson:2.8.8")
implementation("org.jsoup:jsoup:1.14.2")
implementation("org.jsoup:jsoup:1.14.3")
implementation("ch.acra:acra:4.11.1")
}
+1
View File
@@ -9,6 +9,7 @@
<application
android:name=".common.AppGlobal"
android:allowBackup="false"
android:extractNativeLibs="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -1,8 +1,17 @@
package com.meloda.fast.activity
import android.os.Bundle
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.meloda.fast.R
import com.meloda.fast.base.BaseActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseActivity(R.layout.activity_main)
class MainActivity : BaseActivity(R.layout.activity_main) {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
}
}
@@ -0,0 +1,7 @@
package com.meloda.fast.api
object ApiExtensions {
val Boolean.intString get() = (if (this) 1 else 0).toString()
}
@@ -6,6 +6,7 @@ import com.meloda.fast.common.AppGlobal
object UserConfig {
private const val FAST_TOKEN = "fast_token"
private const val TOKEN = "token"
private const val USER_ID = "user_id"
@@ -25,8 +26,16 @@ object UserConfig {
AppGlobal.preferences.edit().putString(TOKEN, value).apply()
}
var fastToken: String = ""
get() = AppGlobal.preferences.getString(FAST_TOKEN, "") ?: ""
set(value) {
field = value
AppGlobal.preferences.edit().putString(FAST_TOKEN, value).apply()
}
fun clear() {
accessToken = ""
fastToken = ""
userId = -1
}
@@ -1,5 +1,7 @@
package com.meloda.fast.api
import com.meloda.fast.api.model.attachments.*
object VKConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified"
@@ -7,6 +9,8 @@ object VKConstants {
const val USER_FIELDS =
"photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info"
const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS"
const val API_VERSION = "5.132"
const val VK_APP_ID = "2274003"
const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH"
@@ -33,4 +37,15 @@ object VKConstants {
const val PASSWORD = "password"
}
}
val restrictedToEditAttachments = listOf(
VkCall::class.java,
VkCurator::class.java,
VkEvent::class.java,
VkGift::class.java,
VkGraffiti::class.java,
VkGroupCall::class.java,
VkStory::class.java,
VkVoiceMessage::class.java
)
}
@@ -8,11 +8,9 @@ open class VKException(
var code: Int = -1,
var description: String = "",
var error: String
) :
IOException(description) {
) : IOException(description) {
var captcha: Pair<String, String>? = null
var validationSid: String? = null
// TODO: 10-Oct-21 remove this
var json: JSONObject? = null
override fun toString(): String {
@@ -7,6 +7,7 @@ import android.text.SpannableString
import android.text.style.StyleSpan
import androidx.core.content.ContextCompat
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
@@ -16,6 +17,88 @@ import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem
object VkUtils {
fun <T> attachmentToString(
attachmentClass: Class<T>,
id: Int,
ownerId: Int,
withAccessKey: Boolean,
accessKey: String?
): String {
val type = when (attachmentClass) {
VkAudio::class.java -> "audio"
VkFile::class.java -> "doc"
VkVideo::class.java -> "video"
VkPhoto::class.java -> "photo"
else -> throw IllegalArgumentException("unknown attachment class: $attachmentClass")
}
val result = StringBuilder(type).append(ownerId).append('_').append(id)
if (withAccessKey && !accessKey.isNullOrBlank()) {
result.append('_')
result.append(accessKey)
}
return result.toString()
}
fun getMessageUser(message: VkMessage, profiles: Map<Int, VkUser>): VkUser? {
return (if (!message.isUser()) null
else profiles[message.fromId]).also { message.user.value = it }
}
fun getMessageGroup(message: VkMessage, groups: Map<Int, VkGroup>): VkGroup? {
return (if (!message.isGroup()) null
else groups[message.fromId]).also { message.group.value = it }
}
fun getMessageAvatar(
message: VkMessage,
messageUser: VkUser?,
messageGroup: VkGroup?
): String? {
return when {
message.isUser() -> messageUser?.photo200
message.isGroup() -> messageGroup?.photo200
else -> null
}
}
fun getMessageTitle(
message: VkMessage,
messageUser: VkUser?,
messageGroup: VkGroup?
): String? {
return when {
message.isUser() -> messageUser?.fullName
message.isGroup() -> messageGroup?.name
else -> null
}
}
fun getConversationUser(conversation: VkConversation, profiles: Map<Int, VkUser>): VkUser? {
return (if (!conversation.isUser()) null
else profiles[conversation.id]).also { conversation.user.value = it }
}
fun getConversationGroup(conversation: VkConversation, groups: Map<Int, VkGroup>): VkGroup? {
return (if (!conversation.isGroup()) null
else groups[conversation.id]).also { conversation.group.value = it }
}
fun getConversationAvatar(
conversation: VkConversation,
conversationUser: VkUser?,
conversationGroup: VkGroup?
): String? {
return when {
conversation.ownerId == VKConstants.FAST_GROUP_ID -> null
conversation.isUser() -> conversationUser?.photo200
conversation.isGroup() -> conversationGroup?.photo200
conversation.isChat() -> conversation.photo200
else -> null
}
}
fun prepareMessageText(text: String, forConversations: Boolean? = null): String {
return text.apply {
if (forConversations == true) replace("\n", "")
@@ -42,6 +125,12 @@ object VkUtils {
return forwards
}
fun parseReplyMessage(baseReplyMessage: BaseVkMessage?): VkMessage? {
if (baseReplyMessage == null) return null
return baseReplyMessage.asVkMessage()
}
fun parseAttachments(baseAttachments: List<BaseVkAttachmentItem>?): List<VkAttachment>? {
if (baseAttachments.isNullOrEmpty()) return null
@@ -77,9 +166,7 @@ object VkUtils {
}
BaseVkAttachmentItem.AttachmentType.VOICE -> {
val voiceMessage = baseAttachment.voiceMessage ?: continue
attachments += VkVoiceMessage(
link = voiceMessage.link_mp3
)
attachments += voiceMessage.asVkVoiceMessage()
}
BaseVkAttachmentItem.AttachmentType.STICKER -> {
val sticker = baseAttachment.sticker ?: continue
@@ -87,9 +174,7 @@ object VkUtils {
}
BaseVkAttachmentItem.AttachmentType.GIFT -> {
val gift = baseAttachment.gift ?: continue
attachments += VkGift(
link = gift.thumb_48
)
attachments += gift.asVkGift()
}
BaseVkAttachmentItem.AttachmentType.WALL -> {
val wall = baseAttachment.wall ?: continue
@@ -97,9 +182,7 @@ object VkUtils {
}
BaseVkAttachmentItem.AttachmentType.GRAFFITI -> {
val graffiti = baseAttachment.graffiti ?: continue
attachments += VkGraffiti(
link = graffiti.url
)
attachments += graffiti.asVkGraffiti()
}
BaseVkAttachmentItem.AttachmentType.POLL -> {
val poll = baseAttachment.poll ?: continue
@@ -115,9 +198,7 @@ object VkUtils {
}
BaseVkAttachmentItem.AttachmentType.CALL -> {
val call = baseAttachment.call ?: continue
attachments += VkCall(
initiatorId = call.initiator_id
)
attachments += call.asVkCall()
}
BaseVkAttachmentItem.AttachmentType.GROUP_CALL_IN_PROGRESS -> {
val groupCall = baseAttachment.groupCall ?: continue
@@ -1,38 +1,50 @@
package com.meloda.fast.api.model
import android.os.Parcelable
import androidx.lifecycle.MutableLiveData
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Entity(tableName = "conversations")
@Parcelize
data class VkConversation(
@PrimaryKey(autoGenerate = false)
val id: Int,
val ownerId: Int?,
val title: String?,
val photo200: String?,
val type: String,
val callInProgress: Boolean,
val isPhantom: Boolean,
val lastConversationMessageId: Int,
val inRead: Int,
val outRead: Int,
val isMarkedUnread: Boolean,
val lastMessageId: Int,
val unreadCount: Int?,
val membersCount: Int?,
val isPinned: Boolean,
var id: Int,
var ownerId: Int?,
var title: String?,
var photo200: String?,
var type: String,
var callInProgress: Boolean,
var isPhantom: Boolean,
var lastConversationMessageId: Int,
var inRead: Int,
var outRead: Int,
var isMarkedUnread: Boolean,
var lastMessageId: Int,
var unreadCount: Int?,
var membersCount: Int?,
var isPinned: Boolean,
var canChangePin: Boolean,
@Embedded(prefix = "pinnedMessage_")
var pinnedMessage: VkMessage? = null,
@Embedded(prefix = "lastMessage_")
var lastMessage: VkMessage? = null
var lastMessage: VkMessage? = null,
) : Parcelable {
@Ignore
@IgnoredOnParcel
val user = MutableLiveData<VkUser?>()
@Ignore
@IgnoredOnParcel
val group = MutableLiveData<VkGroup?>()
fun isChat() = type == "chat"
fun isUser() = type == "user"
fun isGroup() = type == "group"
@@ -1,17 +1,23 @@
package com.meloda.fast.api.model
import android.os.Parcelable
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.base.adapter.SelectableItem
import com.meloda.fast.util.TimeUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Entity(tableName = "messages")
@Parcelize
data class VkMessage(
@PrimaryKey(autoGenerate = false)
val id: Int,
val text: String? = null,
var id: Int,
var text: String? = null,
val isOut: Boolean,
val peerId: Int,
val fromId: Int,
@@ -23,10 +29,22 @@ data class VkMessage(
val actionConversationMessageId: Int? = null,
val actionMessage: String? = null,
val geoType: String? = null,
val important: Boolean = false,
var important: Boolean = false,
var forwards: List<VkMessage>? = null,
var attachments: List<VkAttachment>? = null
) : Parcelable {
var attachments: List<VkAttachment>? = null,
// @Embedded(prefix = "replyMessage_")
var replyMessage: VkMessage? = null
) : SelectableItem() {
@Ignore
@IgnoredOnParcel
val user = MutableLiveData<VkUser?>()
@Ignore
@IgnoredOnParcel
val group = MutableLiveData<VkGroup?>()
fun isPeerChat() = peerId > 2_000_000_000
@@ -43,40 +61,12 @@ data class VkMessage(
return Action.parse(action)
}
fun copyMessage(
id: Int = this.id,
text: String? = this.text,
isOut: Boolean = this.isOut,
peerId: Int = this.peerId,
fromId: Int = this.fromId,
date: Int = this.date,
randomId: Int = this.randomId,
action: String? = this.action,
actionMemberId: Int? = this.actionMemberId,
actionText: String? = this.actionText,
actionConversationMessageId: Int? = this.actionConversationMessageId,
actionMessage: String? = this.actionMessage,
geoType: String? = this.geoType,
important: Boolean = this.important
) = VkMessage(
id = id,
text = text,
isOut = isOut,
peerId = peerId,
fromId = fromId,
date = date,
randomId = randomId,
action = action,
actionMemberId = actionMemberId,
actionText = actionText,
actionConversationMessageId = actionConversationMessageId,
actionMessage = actionMessage,
geoType = geoType,
important = important
).also {
it.attachments = attachments
it.forwards = forwards
}
fun canEdit() =
fromId == UserConfig.userId &&
(attachments == null || !VKConstants.restrictedToEditAttachments.contains(
attachments!![0].javaClass
)) &&
(System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.ONE_DAY_IN_SECONDS)
enum class Action(val value: String) {
CHAT_CREATE("chat_create"),
@@ -4,4 +4,8 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
open class VkAttachment : Parcelable
open class VkAttachment : Parcelable {
open fun asString(withAccessKey: Boolean = true) = ""
}
@@ -1,17 +1,28 @@
package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkAudio(
val id: Int,
val ownerId: Int,
val title: String,
val artist: String,
val url: String,
val duration: Int
val duration: Int,
val accessKey: String?
) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
}
@@ -5,7 +5,12 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkCall(
val initiatorId: Int
val initiatorId: Int,
val receiverId: Int,
val state: String,
val time: Int,
val duration: Int,
val isVideo: Boolean
) : VkAttachment() {
@IgnoredOnParcel
@@ -1,17 +1,28 @@
package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class VkFile(
val id: Int,
val ownerId: Int,
val title: String,
val ext: String,
val size: Int,
val url: String
val url: String,
val accessKey: String?
) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
}
@@ -5,7 +5,10 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkGift(
val link: String
val id: Int,
val thumb256: String?,
val thumb96: String?,
val thumb48: String
) : VkAttachment() {
@IgnoredOnParcel
@@ -5,7 +5,12 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkGraffiti(
val link: String
val id: Int,
val ownerId: Int,
val url: String,
val width: Int,
val height: Int,
val accessKey: String
) : VkAttachment() {
@IgnoredOnParcel
@@ -9,7 +9,7 @@ data class VkLink(
val title: String?,
val caption: String?,
val photo: VkPhoto?,
val target: String,
val target: String?,
val isFavorite: Boolean
) : VkAttachment() {
@@ -1,8 +1,11 @@
package com.meloda.fast.api.model.attachments
import androidx.room.Ignore
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.base.attachments.BaseVkPhoto
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class VkPhoto(
@@ -13,17 +16,70 @@ data class VkPhoto(
val hasTags: Boolean,
val accessKey: String?,
val sizes: List<BaseVkPhoto.Size>,
val text: String,
val text: String?,
val userId: Int?
) : VkAttachment() {
@Ignore
@IgnoredOnParcel
private val sizesChars = Stack<Char>()
init {
sizesChars.push('s')
sizesChars.push('m')
sizesChars.push('x')
sizesChars.push('o')
sizesChars.push('p')
sizesChars.push('q')
sizesChars.push('r')
sizesChars.push('y')
sizesChars.push('z')
sizesChars.push('w')
}
@IgnoredOnParcel
val className: String = this::class.java.name
fun sizeOfType(type: Char): BaseVkPhoto.Size? {
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
fun getMaxSize(): BaseVkPhoto.Size? {
return getSizeOrSmaller(sizesChars.peek())
}
fun getSizeOrNull(type: Char): BaseVkPhoto.Size? {
for (size in sizes) {
if (size.type == type.toString())
if (size.type == type.toString()) return size
}
return null
}
fun getSizeOrSmaller(type: Char): BaseVkPhoto.Size? {
val photoStack = sizesChars.clone() as Stack<Char>
val sizeIndex = photoStack.search(type)
if (sizeIndex == -1) return null
for (i in 0 until sizeIndex) {
photoStack.pop()
}
for (i in 0 until photoStack.size) {
val size = getSizeOrNull(photoStack.peek())
if (size == null) {
photoStack.pop()
continue
} else {
return size
}
}
return null
@@ -1,5 +1,6 @@
package com.meloda.fast.api.model.attachments
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.base.attachments.BaseVkVideo
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@@ -7,8 +8,10 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkVideo(
val id: Int,
val ownerId: Int,
val images: List<BaseVkVideo.Image>,
val firstFrames: List<BaseVkVideo.FirstFrame>?
val firstFrames: List<BaseVkVideo.FirstFrame>?,
val accessKey: String?
) : VkAttachment() {
@IgnoredOnParcel
@@ -18,4 +21,12 @@ data class VkVideo(
return images.find { it.width == width }
}
override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString(
attachmentClass = this::class.java,
id = id,
ownerId = ownerId,
withAccessKey = withAccessKey,
accessKey = accessKey
)
}
@@ -5,9 +5,18 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class VkVoiceMessage(
val link: String
val id: Int,
val ownerId: Int,
val duration: Int,
val waveform: List<Int>,
val linkOgg: String,
val linkMp3: String,
val accessKey: String,
val transcriptState: String,
val transcript: String
) : VkAttachment() {
@IgnoredOnParcel
val className: String = this::class.java.name
}
@@ -40,7 +40,8 @@ data class BaseVkConversation(
unreadCount = unread_count,
membersCount = chat_settings?.members_count,
ownerId = chat_settings?.owner_id,
isPinned = sort_id.major_id > 0
isPinned = sort_id.major_id > 0,
canChangePin = chat_settings?.acl?.can_change_pin == true
).apply {
this.lastMessage = lastMessage
this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage()
@@ -23,7 +23,8 @@ data class BaseVkMessage(
val payload: String,
val geo: Geo?,
val action: Action?,
val ttl: Int
val ttl: Int,
val reply_message: BaseVkMessage?
) : Parcelable {
fun asVkMessage() = VkMessage(
@@ -44,6 +45,7 @@ data class BaseVkMessage(
).also {
it.attachments = VkUtils.parseAttachments(attachments)
it.forwards = VkUtils.parseForwards(fwd_messages)
it.replyMessage = VkUtils.parseReplyMessage(reply_message)
}
@Parcelize
@@ -13,7 +13,7 @@ data class BaseVkAudio(
val url: String,
val date: Int,
val owner_id: Int,
val access_key: String,
val access_key: String?,
val is_explicit: Boolean,
val is_focus_track: Boolean,
val is_licensed: Boolean,
@@ -27,10 +27,12 @@ data class BaseVkAudio(
fun asVkAudio() = VkAudio(
id = id,
ownerId = owner_id,
title = title,
artist = artist,
url = url,
duration = duration
duration = duration,
accessKey = access_key
)
@Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkCall
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -11,4 +12,15 @@ data class BaseVkCall(
val time: Int,
val duration: Int,
val video: Boolean
) : Parcelable
) : Parcelable {
fun asVkCall() = VkCall(
initiatorId = initiator_id,
receiverId = receiver_id,
state = state,
time = time,
duration = duration,
isVideo = video
)
}
@@ -16,16 +16,18 @@ data class BaseVkFile(
val url: String,
val preview: Preview?,
val ic_licensed: Int,
val access_key: String,
val access_key: String?,
val web_preview_url: String?
) : BaseVkAttachment() {
fun asVkFile() = VkFile(
id = id,
ownerId = owner_id,
title = title,
ext = ext,
url = url,
size = size
size = size,
accessKey = access_key
)
@Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkGift
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -9,4 +10,13 @@ data class BaseVkGift(
val thumb_256: String?,
val thumb_96: String?,
val thumb_48: String
) : Parcelable
) : Parcelable {
fun asVkGift() = VkGift(
id = id,
thumb256 = thumb_256,
thumb96 = thumb_96,
thumb48 = thumb_48
)
}
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkGraffiti
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -11,4 +12,15 @@ data class BaseVkGraffiti(
val width: Int,
val height: Int,
val access_key: String
) : Parcelable
) : Parcelable {
fun asVkGraffiti() = VkGraffiti(
id = id,
ownerId = owner_id,
url = url,
width = width,
height = height,
accessKey = access_key
)
}
@@ -9,7 +9,7 @@ data class BaseVkLink(
val title: String?,
val caption: String?,
val photo: BaseVkPhoto?,
val target: String,
val target: String?,
val is_favorite: Boolean
) : BaseVkAttachment() {
@@ -13,7 +13,7 @@ data class BaseVkPhoto(
val has_tags: Boolean,
val access_key: String?,
val sizes: List<Size>,
val text: String,
val text: String?,
val user_id: Int?,
val lat: Double?,
val long: Double?,
@@ -26,7 +26,7 @@ data class BaseVkVideo(
val can_add_to_faves: Int,
val can_add: Int,
val can_attach_link: Int,
val access_key: String,
val access_key: String?,
val owner_id: Int,
val ov_id: String,
val is_favorite: Boolean,
@@ -40,8 +40,10 @@ data class BaseVkVideo(
fun asVkVideo() = VkVideo(
id = id,
ownerId = owner_id,
images = image,
firstFrames = first_frame
firstFrames = first_frame,
accessKey = access_key
)
@Parcelize
@@ -1,6 +1,7 @@
package com.meloda.fast.api.model.base.attachments
import android.os.Parcelable
import com.meloda.fast.api.model.attachments.VkVoiceMessage
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -14,4 +15,18 @@ data class BaseVkVoiceMessage(
val access_key: String,
val transcript_state: String,
val transcript: String
) : Parcelable
) : Parcelable {
fun asVkVoiceMessage() = VkVoiceMessage(
id = id,
ownerId = owner_id,
duration = duration,
waveform = waveform,
linkOgg = link_ogg,
linkMp3 = link_mp3,
accessKey = access_key,
transcriptState = transcript_state,
transcript = transcript
)
}
@@ -1,84 +0,0 @@
package com.meloda.fast.api.model.request
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class MessagesGetHistoryRequest(
val count: Int? = null,
val offset: Int? = null,
val peerId: Int,
val extended: Boolean? = null,
val startMessageId: Int? = null,
val rev: Boolean? = null,
val fields: String? = null,
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
extended?.let { this["extended"] = (if (it) 1 else 0).toString() }
startMessageId?.let { this["start_message_id"] = it.toString() }
rev?.let { this["rev"] = (if (it) 1 else 0).toString() }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesSendRequest(
val peerId: Int,
val randomId: Int = 0,
val message: String? = null,
val lat: Int? = null,
val lon: Int? = null,
val replyTo: Int? = null,
val stickerId: Int? = null,
val disableMentions: Boolean? = null,
val dontParseLinks: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"random_id" to randomId.toString()
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = (if (it) 1 else 0).toString() }
dontParseLinks?.let { this["dont_parse_links"] = (if (it) 1 else 0).toString() }
}
}
@Parcelize
data class MessagesMarkAsImportantRequest(
val messagesIds: List<Int>,
val important: Boolean
) : Parcelable {
val map
get() = mutableMapOf(
"message_ids" to messagesIds.joinToString { it.toString() },
"important" to (if (important) 1 else 0).toString()
)
}
@Parcelize
data class MessagesGetLongPollServerRequest(
val needPts: Boolean,
val version: Int
) : Parcelable {
val map
get() = mutableMapOf(
"need_pts" to (if (needPts) 1 else 0).toString(),
"version" to version.toString()
)
}
@@ -1,2 +0,0 @@
package com.meloda.fast.api.model.response
@@ -17,6 +17,7 @@ class AuthInterceptor : Interceptor {
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())
}
@@ -1,6 +1,7 @@
package com.meloda.fast.api.network
import com.meloda.fast.api.VKException
import com.meloda.fast.api.base.ApiError
import com.meloda.fast.api.base.ApiResponse
import okhttp3.Request
import okio.IOException
@@ -93,7 +94,6 @@ internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Answer<T>>(proxy)
if (result is Answer.Error && isVkException) if (checkErrors(call, result)) return
callback.onResponse(proxy, Response.success(result))
}
@@ -105,6 +105,11 @@ internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Answer<T>>(proxy)
}
private fun checkErrors(call: Call<T>, result: Answer.Error): Boolean {
if (result.throwable is ApiError) {
onFailure(call, result.throwable)
return true
}
val json = JSONObject(result.throwable.message ?: "{}")
return if (json.has("error")) {
@@ -1,6 +1,6 @@
package com.meloda.fast.api.network
object VKUrls {
object VkUrls {
const val OAUTH = "https://oauth.vk.com"
const val API = "https://api.vk.com/method"
@@ -12,6 +12,10 @@ object VKUrls {
object Conversations {
const val Get = "$API/messages.getConversations"
const val Delete = "$API/messages.deleteConversation"
const val Pin = "$API/messages.pinConversation"
const val Unpin = "$API/messages.unpinConversation"
const val ReorderPinned = "$API/messages.reorderPinnedConversations"
}
object Users {
@@ -24,6 +28,10 @@ object VKUrls {
const val MarkAsImportant = "$API/messages.markAsImportant"
const val GetLongPollServer = "$API/messages.getLongPollServer"
const val GetLongPollHistory = "$API/messages.getLongPollHistory"
const val Pin = "$API/messages.pin"
const val Unpin = "$API/messages.unpin"
const val Delete = "$API/messages.delete"
const val Edit = "$API/messages.edit"
}
@@ -1,7 +1,5 @@
package com.meloda.fast.api.network.datasource
package com.meloda.fast.api.network.auth
import com.meloda.fast.api.network.repo.AuthRepo
import com.meloda.fast.api.model.request.RequestAuthDirect
import javax.inject.Inject
class AuthDataSource @Inject constructor(
@@ -0,0 +1,17 @@
package com.meloda.fast.api.network.auth
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.QueryMap
interface AuthRepo {
@GET(VkUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VkUrls.Auth.SendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request
package com.meloda.fast.api.network.auth
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response
package com.meloda.fast.api.network.auth
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
@@ -0,0 +1,22 @@
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<VkConversation>) = dao.insert(conversations)
}
@@ -0,0 +1,32 @@
package com.meloda.fast.api.network.conversations
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface ConversationsRepo {
@FormUrlEncoded
@POST(VkUrls.Conversations.Get)
suspend fun get(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Delete)
suspend fun delete(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Pin)
suspend fun pin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.Unpin)
suspend fun unpin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Conversations.ReorderPinned)
suspend fun reorderPinned(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request
package com.meloda.fast.api.network.conversations
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -24,3 +24,18 @@ data class ConversationsGetRequest(
startMessageId?.let { this["start_message_id"] = it.toString() }
}
}
@Parcelize
data class ConversationsDeleteRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@Parcelize
data class ConversationsPinRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@Parcelize
data class ConversationsUnpinRequest(val peerId: Int) : Parcelable {
val map get() = mapOf("peer_id" to peerId.toString())
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response
package com.meloda.fast.api.network.conversations
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
@@ -1,18 +0,0 @@
package com.meloda.fast.api.network.datasource
import com.meloda.fast.api.model.VkConversation
import com.meloda.fast.api.network.repo.ConversationsRepo
import com.meloda.fast.api.model.request.ConversationsGetRequest
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 getAllChats(params: ConversationsGetRequest) = repo.getAllChats(params.map)
suspend fun storeConversations(conversations: List<VkConversation>) = dao.insert(conversations)
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.network.repo
package com.meloda.fast.api.network.longpoll
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer
@@ -1,11 +1,6 @@
package com.meloda.fast.api.network.datasource
package com.meloda.fast.api.network.messages
import com.meloda.fast.api.model.VkMessage
import com.meloda.fast.api.model.request.MessagesGetHistoryRequest
import com.meloda.fast.api.model.request.MessagesGetLongPollServerRequest
import com.meloda.fast.api.model.request.MessagesMarkAsImportantRequest
import com.meloda.fast.api.model.request.MessagesSendRequest
import com.meloda.fast.api.network.repo.MessagesRepo
import com.meloda.fast.database.dao.MessagesDao
import javax.inject.Inject
@@ -26,8 +21,20 @@ class MessagesDataSource @Inject constructor(
suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) =
repo.getLongPollServer(params.map)
suspend fun storeMessages(messages: List<VkMessage>) = dao.insert(messages)
suspend fun pin(params: MessagesPinMessageRequest) =
repo.pin(params.map)
suspend fun getCachedMessages(peerId: Int) = dao.getByPeerId(peerId)
suspend fun unpin(params: MessagesUnPinMessageRequest) =
repo.unpin(params.map)
suspend fun delete(params: MessagesDeleteRequest) =
repo.delete(params.map)
suspend fun edit(params: MessagesEditRequest) =
repo.edit(params.map)
suspend fun store(messages: List<VkMessage>) = dao.insert(messages)
suspend fun getCached(peerId: Int) = dao.getByPeerId(peerId)
}
@@ -0,0 +1,46 @@
package com.meloda.fast.api.network.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.VkUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface MessagesRepo {
@FormUrlEncoded
@POST(VkUrls.Messages.GetHistory)
suspend fun getHistory(@FieldMap params: Map<String, String>): Answer<ApiResponse<MessagesGetHistoryResponse>>
@FormUrlEncoded
@POST(VkUrls.Messages.Send)
suspend fun send(@FieldMap params: Map<String, String>): Answer<ApiResponse<Int>>
@FormUrlEncoded
@POST(VkUrls.Messages.MarkAsImportant)
suspend fun markAsImportant(@FieldMap params: Map<String, String>): Answer<ApiResponse<List<Int>>>
@FormUrlEncoded
@POST(VkUrls.Messages.GetLongPollServer)
suspend fun getLongPollServer(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkLongPoll>>
@FormUrlEncoded
@POST(VkUrls.Messages.Pin)
suspend fun pin(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkMessage>>
@FormUrlEncoded
@POST(VkUrls.Messages.Unpin)
suspend fun unpin(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Messages.Delete)
suspend fun delete(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
@FormUrlEncoded
@POST(VkUrls.Messages.Edit)
suspend fun edit(@FieldMap params: Map<String, String>): Answer<ApiResponse<Any>>
}
@@ -0,0 +1,168 @@
package com.meloda.fast.api.network.messages
import android.os.Parcelable
import com.meloda.fast.api.ApiExtensions.intString
import com.meloda.fast.api.model.attachments.VkAttachment
import kotlinx.parcelize.Parcelize
@Parcelize
data class MessagesGetHistoryRequest(
val count: Int? = null,
val offset: Int? = null,
val peerId: Int,
val extended: Boolean? = null,
val startMessageId: Int? = null,
val rev: Boolean? = null,
val fields: String? = null,
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
extended?.let { this["extended"] = it.intString }
startMessageId?.let { this["start_message_id"] = it.toString() }
rev?.let { this["rev"] = it.intString }
fields?.let { this["fields"] = it }
}
}
@Parcelize
data class MessagesSendRequest(
val peerId: Int,
val randomId: Int = 0,
val message: String? = null,
val lat: Int? = null,
val lon: Int? = null,
val replyTo: Int? = null,
val stickerId: Int? = null,
val disableMentions: Boolean? = null,
val dontParseLinks: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"random_id" to randomId.toString()
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.intString }
dontParseLinks?.let { this["dont_parse_links"] = it.intString }
}
}
@Parcelize
data class MessagesMarkAsImportantRequest(
val messagesIds: List<Int>,
val important: Boolean
) : Parcelable {
val map
get() = mutableMapOf(
"message_ids" to messagesIds.joinToString { it.toString() },
"important" to important.intString
)
}
@Parcelize
data class MessagesGetLongPollServerRequest(
val needPts: Boolean,
val version: Int
) : Parcelable {
val map
get() = mutableMapOf(
"need_pts" to needPts.intString,
"version" to version.toString()
)
}
@Parcelize
data class MessagesPinMessageRequest(
val peerId: Int,
val messageId: Int? = null,
val conversationMessageId: Int? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
messageId?.let { this["message_id"] = it.toString() }
conversationMessageId?.let { this["conversation_message_id"] = it.toString() }
}
}
@Parcelize
data class MessagesUnPinMessageRequest(val peerId: Int) : Parcelable {
val map get() = mutableMapOf("peer_id" to peerId.toString())
}
@Parcelize
data class MessagesDeleteRequest(
val peerId: Int,
val messagesIds: List<Int>? = null,
val conversationsMessagesIds: List<Int>? = null,
val isSpam: Boolean? = null,
val deleteForAll: Boolean? = null
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString()
).apply {
isSpam?.let { this["spam"] = it.intString }
deleteForAll?.let { this["delete_for_all"] = it.intString }
messagesIds?.let {
this["message_ids"] = it.joinToString { id -> id.toString() }
}
conversationsMessagesIds?.let {
this["conversation_message_ids"] = it.joinToString { id -> id.toString() }
}
}
}
@Parcelize
data class MessagesEditRequest(
val peerId: Int,
val messageId: Int,
val message: String? = null,
val lat: Float? = null,
val lon: Float? = null,
val attachments: List<VkAttachment>? = null,
val notParseLinks: Boolean = false,
val keepSnippets: Boolean = true,
val keepForwardedMessages: Boolean = true
) : Parcelable {
val map
get() = mutableMapOf(
"peer_id" to peerId.toString(),
"message_id" to messageId.toString(),
"dont_parse_links" to notParseLinks.intString,
"keep_snippets" to keepSnippets.intString,
"keep_forward_messages" to keepForwardedMessages.intString
).apply {
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
attachments?.let {
val attachments =
if (it.isEmpty()) ""
else it.joinToString(separator = ",") { attachment -> attachment.asString() }
this["attachment"] = attachments
}
}
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response
package com.meloda.fast.api.network.messages
import android.os.Parcelable
import com.meloda.fast.api.model.base.BaseVkConversation
@@ -1,17 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.model.response.ResponseAuthDirect
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.model.response.ResponseSendSms
import retrofit2.http.*
interface AuthRepo {
@GET(VKUrls.Auth.DirectAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VKUrls.Auth.SendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -1,17 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.model.response.ConversationsGetResponse
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface ConversationsRepo {
@FormUrlEncoded
@POST(VKUrls.Conversations.Get)
suspend fun getAllChats(@FieldMap params: Map<String, String>): Answer<ApiResponse<ConversationsGetResponse>>
}
@@ -1,30 +0,0 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.base.ApiResponse
import com.meloda.fast.api.model.base.BaseVkLongPoll
import com.meloda.fast.api.model.response.MessagesGetHistoryResponse
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface MessagesRepo {
@FormUrlEncoded
@POST(VKUrls.Messages.GetHistory)
suspend fun getHistory(@FieldMap params: Map<String, String>): Answer<ApiResponse<MessagesGetHistoryResponse>>
@FormUrlEncoded
@POST(VKUrls.Messages.Send)
suspend fun send(@FieldMap params: Map<String, String>): Answer<ApiResponse<Int>>
@FormUrlEncoded
@POST(VKUrls.Messages.MarkAsImportant)
suspend fun markAsImportant(@FieldMap params: Map<String, String>): Answer<ApiResponse<List<Int>>>
@FormUrlEncoded
@POST(VKUrls.Messages.GetLongPollServer)
suspend fun getLongPollServer(@FieldMap params: Map<String, String>): Answer<ApiResponse<BaseVkLongPoll>>
}
@@ -1,8 +1,6 @@
package com.meloda.fast.api.network.datasource
package com.meloda.fast.api.network.users
import com.meloda.fast.api.model.VkUser
import com.meloda.fast.api.network.repo.UsersRepo
import com.meloda.fast.api.model.request.UsersGetRequest
import com.meloda.fast.database.dao.UsersDao
import javax.inject.Inject
@@ -1,9 +1,9 @@
package com.meloda.fast.api.network.repo
package com.meloda.fast.api.network.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.VKUrls
import com.meloda.fast.api.network.VkUrls
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
@@ -11,7 +11,7 @@ import retrofit2.http.POST
interface UsersRepo {
@FormUrlEncoded
@POST(VKUrls.Users.GetById)
@POST(VkUrls.Users.GetById)
suspend fun getById(
@FieldMap params: Map<String, String>?
): Answer<ApiResponse<List<BaseVkUser>>>
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request
package com.meloda.fast.api.network.users
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -0,0 +1,2 @@
package com.meloda.fast.api.network.users
@@ -1,13 +1,11 @@
package com.meloda.fast.base
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.google.android.material.snackbar.Snackbar
abstract class BaseActivity : AppCompatActivity, LifecycleOwner {
@@ -39,10 +37,4 @@ abstract class BaseActivity : AppCompatActivity, LifecycleOwner {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
val rootView: View? get() = findViewById(android.R.id.content)
fun requireRootView() = rootView!!
var errorSnackbar: Snackbar? = null
}
@@ -11,7 +11,7 @@ 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 com.meloda.fast.base.viewmodel.VkEvent
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
@@ -30,7 +30,7 @@ abstract class BaseViewModelFragment<VM : BaseViewModel> : BaseFragment {
}
}
protected open fun onEvent(event: VKEvent) {
protected open fun onEvent(event: VkEvent) {
if (event is IllegalTokenEvent) {
Toast.makeText(
requireContext(), R.string.authorization_failed, Toast.LENGTH_LONG
@@ -114,7 +114,7 @@ abstract class BaseAdapter<Item, VH : BaseHolder>(
holder.bind(position)
}
protected fun initListeners(itemView: View, position: Int) {
protected open fun initListeners(itemView: View, position: Int) {
if (itemView is AdapterView<*>) return
itemView.setOnClickListener { itemClickListener.invoke(position) }
@@ -0,0 +1,15 @@
package com.meloda.fast.base.adapter
import android.os.Parcelable
import androidx.room.Ignore
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
open class SelectableItem : Parcelable {
@Ignore
@IgnoredOnParcel
var isSelected: Boolean = false
}
@@ -15,7 +15,7 @@ abstract class BaseViewModel : ViewModel() {
var unknownErrorDefaultText: String = ""
protected val tasksEventChannel = Channel<VKEvent>()
protected val tasksEventChannel = Channel<VkEvent>()
val tasksEvent = tasksEventChannel.receiveAsFlow()
protected fun <T> makeJob(
@@ -25,23 +25,35 @@ abstract class BaseViewModel : ViewModel() {
onEnd: (suspend () -> Unit)? = null,
onError: (suspend (Throwable) -> Unit)? = null
) = viewModelScope.launch {
onStart?.invoke()
onStart?.invoke() ?: onStart()
when (val response = job()) {
is Answer.Success -> onAnswer(response.data)
is Answer.Error -> {
checkErrors(response.throwable)
onError?.invoke(response.throwable)
?: sendEvent(
ErrorEvent(
response.throwable.message
?: unknownErrorDefaultText
)
)
onError?.invoke(response.throwable) ?: onError(response.throwable)
}
}
}.also { it.invokeOnCompletion { viewModelScope.launch { onEnd?.invoke() } } }
}.also {
it.invokeOnCompletion {
viewModelScope.launch {
onEnd?.invoke() ?: onStop()
}
}
}
protected suspend fun <T : VKEvent> sendEvent(event: T) = tasksEventChannel.send(event)
protected suspend fun onStart() {
sendEvent(StartProgressEvent)
}
protected suspend fun onStop() {
sendEvent(StopProgressEvent)
}
protected suspend fun onError(throwable: Throwable) {
sendEvent(ErrorEvent(throwable.message ?: unknownErrorDefaultText))
}
protected suspend fun <T : VkEvent> sendEvent(event: T) = tasksEventChannel.send(event)
private suspend fun checkErrors(throwable: Throwable) {
when (throwable) {
@@ -5,15 +5,15 @@ data class ShowDialogInfoEvent(
val message: String,
val positiveBtn: String? = null,
val negativeBtn: String? = null
) : VKEvent()
) : VkEvent()
data class ErrorEvent(val errorText: String) : 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 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()
object StartProgressEvent : VkEvent()
object StopProgressEvent : VkEvent()
abstract class VKEvent
abstract class VkEvent
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
object AppSettings {
val keyIsMultilineEnabled = booleanPreferencesKey("isMultilineEnabled")
}
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
@@ -19,7 +19,7 @@ import com.meloda.fast.database.dao.UsersDao
VkUser::class,
VkGroup::class
],
version = 24,
version = 26,
exportSchema = false,
)
@TypeConverters(Converters::class)
@@ -4,11 +4,15 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.meloda.fast.api.network.AuthInterceptor
import com.meloda.fast.api.network.ResultCallFactory
import com.meloda.fast.api.network.datasource.AuthDataSource
import com.meloda.fast.api.network.datasource.ConversationsDataSource
import com.meloda.fast.api.network.datasource.MessagesDataSource
import com.meloda.fast.api.network.datasource.UsersDataSource
import com.meloda.fast.api.network.repo.*
import com.meloda.fast.api.network.auth.AuthRepo
import com.meloda.fast.api.network.auth.AuthDataSource
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.users.UsersDataSource
import com.meloda.fast.api.network.messages.MessagesRepo
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
@@ -1,37 +0,0 @@
package com.meloda.fast.extensions
import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.*
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
object ContextExtensions {
fun Context.drawable(@DrawableRes resId: Int): Drawable? {
return ContextCompat.getDrawable(this, resId)
}
@ColorInt
fun Context.color(@ColorRes resId: Int): Int {
return ContextCompat.getColor(this, resId)
}
fun Context.font(@FontRes resId: Int): Typeface? {
return ResourcesCompat.getFont(this, resId)
}
fun Context.string(@StringRes resId: Int): String {
return getString(resId)
}
fun Context.view(resId: Int, root: ViewGroup? = null, attachToRoot: Boolean = false): View {
return LayoutInflater.from(this).inflate(resId, root, attachToRoot)
}
}
@@ -1,13 +0,0 @@
package com.meloda.fast.extensions
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
object DrawableExtensions {
fun Drawable?.tint(@ColorInt color: Int): Drawable? {
this?.setTint(color)
return this
}
}
@@ -1,73 +0,0 @@
package com.meloda.fast.extensions
import android.graphics.*
import kotlin.math.min
fun Bitmap.borderedCircularBitmap(
borderColor: Int = 0,
borderWidth: Int = 0
): Bitmap? {
val bitmap = Bitmap.createBitmap(
width, // width in pixels
height, // height in pixels
Bitmap.Config.ARGB_8888
)
// canvas to draw circular bitmap
val canvas = Canvas(bitmap)
// get the maximum radius
val radius = min(width / 2f, height / 2f)
// create a path to draw circular bitmap border
val borderPath = Path().apply {
addCircle(
width / 2f,
height / 2f,
radius,
Path.Direction.CCW
)
}
// draw border on circular bitmap
canvas.clipPath(borderPath)
canvas.drawColor(borderColor)
// create a path for circular bitmap
val bitmapPath = Path().apply {
addCircle(
width / 2f,
height / 2f,
radius - borderWidth,
Path.Direction.CCW
)
}
canvas.clipPath(bitmapPath)
val paint = Paint().apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
isAntiAlias = true
}
// clear the circular bitmap drawing area
// it will keep bitmap transparency
canvas.drawBitmap(this, 0f, 0f, paint)
// now draw the circular bitmap
canvas.drawBitmap(this, 0f, 0f, null)
val diameter = (radius * 2).toInt()
val x = (width - diameter) / 2
val y = (height - diameter) / 2
// return cropped circular bitmap with border
return Bitmap.createBitmap(
bitmap, // source bitmap
x, // x coordinate of the first pixel in source
y, // y coordinate of the first pixel in source
diameter, // width
diameter // height
)
}
@@ -1,11 +0,0 @@
package com.meloda.fast.extensions
import kotlin.math.roundToInt
object FloatExtensions {
fun Float.int(): Int {
return roundToInt()
}
}
@@ -1,116 +0,0 @@
package com.meloda.fast.extensions
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
object LiveDataExtensions {
operator fun <T> MutableLiveData<MutableList<T>>.set(position: Int, v: T) {
val value = (this.value ?: arrayListOf()).apply { this[position] = v }
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.get(position: Int): T {
return (value as MutableList<T>)[position]
}
@JvmOverloads
fun <T> MutableLiveData<MutableList<T>>.add(v: T, position: Int = -1) {
val value = (this.value ?: arrayListOf()).apply {
if (position == -1) this.add(v) else this.add(position, v)
}
this.value = value
}
@JvmOverloads
fun <T> MutableLiveData<MutableList<T>>.addAll(values: List<T>, position: Int = -1) {
val value = (this.value ?: arrayListOf()).apply {
if (position == -1) this.addAll(values)
else this.addAll(position, values)
}
this.value = value
}
@Suppress("TYPE_INFERENCE_ONLY_INPUT_TYPES_WARNING")
fun <T> MutableLiveData<MutableList<T>>.removeAll(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAll(values)
}
this.value = value
}
fun <T> MutableLiveData<MutableList<T>>.removeAt(index: Int) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAt(index)
}
this.value = value
}
fun <T> MutableLiveData<MutableList<T>>.remove(item: T) {
val value = (this.value ?: arrayListOf()).apply {
this.remove(item)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.iterator(): Iterator<T> {
return (value as MutableList<T>).iterator()
}
fun <T> MutableLiveData<MutableList<T>>.clear() {
value = arrayListOf()
}
val <T> MutableLiveData<MutableList<T>>.indices get() = (value as MutableList<T>).indices
val <T> MutableLiveData<MutableList<T>>.size get() = (value as MutableList<T>).size
fun <T> MutableLiveData<MutableList<T>>.isEmpty(): Boolean {
return (value as MutableList<T>).isEmpty()
}
fun <T> MutableLiveData<MutableList<T>>.isNotEmpty(): Boolean {
return !isEmpty()
}
fun <T> MutableLiveData<MutableList<T>>.requireValue() = value!!
@UiThread
operator fun <T> MutableLiveData<MutableList<T>>.plusAssign(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.addAll(values)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.plusAssign(v: T) {
val value = (this.value ?: arrayListOf()).apply {
this.add(v)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.minusAssign(values: List<T>) {
val value = (this.value ?: arrayListOf()).apply {
this.removeAll(values)
}
this.value = value
}
operator fun <T> MutableLiveData<MutableList<T>>.minusAssign(v: T) {
val value = (this.value ?: arrayListOf()).apply {
this.remove(v)
}
this.value = value
}
}
@@ -1,11 +0,0 @@
package com.meloda.fast.extensions
import java.util.*
object StringExtensions {
fun String.lowerCase(): String {
return toLowerCase(Locale.getDefault())
}
}
@@ -1,17 +1,11 @@
package com.meloda.fast.extensions
import android.widget.TextView
import com.google.android.material.textfield.TextInputLayout
object TextViewExtensions {
fun TextView.clear() {
text = ""
text = null
}
fun TextInputLayout.clear() {
editText?.setText("")
}
}
@@ -1,10 +0,0 @@
package com.meloda.fast.io
import java.io.ByteArrayOutputStream
class BytesOutputStream : ByteArrayOutputStream {
constructor() : super(8192)
constructor(size: Int) : super(size)
val byteArray: ByteArray = buf
}
@@ -1,12 +0,0 @@
package com.meloda.fast.io
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
object Charsets {
val ASCII: Charset = StandardCharsets.US_ASCII
val UTF_8: Charset = StandardCharsets.UTF_8
}
@@ -1,174 +0,0 @@
package com.meloda.fast.io
import org.jetbrains.annotations.Contract
import java.io.*
import java.nio.charset.Charset
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.max
object EasyStreams {
const val BUFFER_SIZE = 8192
const val CHAR_BUFFER_SIZE = 4096
@JvmOverloads
@Throws(IOException::class)
fun read(from: InputStream, encoding: Charset? = Charsets.UTF_8): String {
return read(InputStreamReader(from, encoding))
}
@JvmStatic
@Throws(IOException::class)
fun read(from: Reader): String {
val builder = StringWriter(CHAR_BUFFER_SIZE)
return try {
copy(from, builder)
builder.toString()
} finally {
close(from)
}
}
@JvmStatic
@Throws(IOException::class)
fun readBytes(from: InputStream): ByteArray {
val output = ByteArrayOutputStream(max(from.available(), BUFFER_SIZE))
try {
copy(from, output)
} finally {
close(from)
}
return output.toByteArray()
}
@Throws(IOException::class)
fun write(from: ByteArray?, to: OutputStream) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@Throws(IOException::class)
fun write(from: String?, to: OutputStream?) {
write(from, OutputStreamWriter(to, Charsets.UTF_8))
}
@Throws(IOException::class)
fun write(from: CharArray?, to: Writer) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@JvmStatic
@Throws(IOException::class)
fun write(from: String?, to: Writer) {
try {
to.write(from)
to.flush()
} finally {
close(to)
}
}
@Throws(IOException::class)
fun copy(from: Reader, to: Writer): Long {
val buffer = CharArray(CHAR_BUFFER_SIZE)
var read: Int
var total: Long = 0
while (from.read(buffer).also { read = it } != -1) {
to.write(buffer, 0, read)
total += read.toLong()
}
return total
}
@Throws(IOException::class)
fun copy(from: InputStream, to: OutputStream): Long {
val buffer = ByteArray(BUFFER_SIZE)
var read: Int
var total: Long = 0
while (from.read(buffer).also { read = it } != -1) {
to.write(buffer, 0, read)
total += read.toLong()
}
return total
}
fun buffer(input: InputStream?): BufferedInputStream {
return buffer(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(input: InputStream?, size: Int): BufferedInputStream {
return if (input is BufferedInputStream) input else BufferedInputStream(input, size)
}
fun buffer(output: OutputStream?): BufferedOutputStream {
return buffer(output, BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(output: OutputStream?, size: Int): BufferedOutputStream {
return if (output is BufferedOutputStream) output else BufferedOutputStream(output, size)
}
fun buffer(input: Reader?): BufferedReader {
return buffer(input, CHAR_BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(input: Reader?, size: Int): BufferedReader {
return if (input is BufferedReader) input else BufferedReader(input, size)
}
fun buffer(output: Writer?): BufferedWriter {
return buffer(output, CHAR_BUFFER_SIZE)
}
@Contract("null, _ -> new")
fun buffer(output: Writer?, size: Int): BufferedWriter {
return if (output is BufferedWriter) output else BufferedWriter(output, size)
}
@Throws(IOException::class)
fun gzip(input: InputStream?): GZIPInputStream {
return gzip(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
@Throws(IOException::class)
fun gzip(input: InputStream?, size: Int): GZIPInputStream {
return if (input is GZIPInputStream) input else GZIPInputStream(input, size)
}
@Throws(IOException::class)
fun gzip(input: OutputStream?): GZIPOutputStream {
return gzip(input, BUFFER_SIZE)
}
@Contract("null, _ -> new")
@Throws(IOException::class)
fun gzip(input: OutputStream?, size: Int): GZIPOutputStream {
return if (input is GZIPOutputStream) input else GZIPOutputStream(input, size)
}
fun close(c: Closeable?): Boolean {
if (c != null) {
try {
c.close()
return true
} catch (e: IOException) {
e.printStackTrace()
}
}
return false
}
}
@@ -1,96 +0,0 @@
package com.meloda.fast.io
import org.jetbrains.annotations.Contract
import java.io.*
import java.math.BigInteger
object FileStreams {
val lineSeparatorChar = lineSeparator()[0]
const val ONE_KB = 1024
const val ONE_MB = ONE_KB * 1024
const val ONE_GB = ONE_MB * 1024
const val ONE_TB = ONE_GB * 1024L
const val ONE_PB = ONE_TB * 1024L
const val ONE_EB = ONE_PB * 1024L
val ONE_ZB: BigInteger = BigInteger.valueOf(ONE_EB).multiply(BigInteger.valueOf(1024L))
val ONE_YB: BigInteger = ONE_ZB.multiply(BigInteger.valueOf(1024L))
@Throws(IOException::class)
fun read(from: File?): String {
return EasyStreams.read(reader(from))
}
@Throws(IOException::class)
fun write(from: String?, to: File?) {
EasyStreams.write(from, writer(to))
}
@Throws(IOException::class)
fun write(from: ByteArray?, to: File?) {
EasyStreams.write(from, FileOutputStream(to))
}
@Throws(IOException::class)
fun append(from: ByteArray?, to: File?) {
EasyStreams.write(from, FileOutputStream(to, true))
}
@Throws(IOException::class)
fun append(from: CharArray?, to: File?) {
EasyStreams.write(from, FileWriter(to, true))
}
@Throws(IOException::class)
fun append(from: CharSequence, to: File?) {
EasyStreams.write(if (from is String) from else from.toString(), FileWriter(to, true))
}
fun delete(dir: File) {
if (dir.isDirectory) {
val files = dir.listFiles() ?: return
for (file in files) {
delete(file)
}
} else {
dir.delete()
}
}
fun lineSeparator(): String {
return System.lineSeparator()
}
fun search(dir: File, name: String?): File? {
require(dir.isDirectory) { "dir can't be file." }
val files = dir.listFiles() ?: return null
if (files.isEmpty()) {
return null
}
for (file in files) {
if (file.isDirectory) {
search(file, name)
} else if (file.name.contains(name!!)) {
return file
}
}
return null
}
@Contract("_ -> new")
@Throws(FileNotFoundException::class)
fun reader(from: File?): Reader {
return InputStreamReader(FileInputStream(from), Charsets.UTF_8)
}
@Contract("_ -> new")
@Throws(FileNotFoundException::class)
fun writer(to: File?): Writer {
return OutputStreamWriter(FileOutputStream(to), Charsets.UTF_8)
}
}
@@ -75,29 +75,17 @@ class ConversationsAdapter constructor(
return
}
val chatUser: VkUser? = if (conversation.isUser()) {
profiles[conversation.id]
} else null
val conversationUser = VkUtils.getConversationUser(conversation, profiles)
val conversationGroup = VkUtils.getConversationGroup(conversation, groups)
val messageUser: VkUser? = if (message.isUser()) {
profiles[message.fromId]
} else null
val messageUser = VkUtils.getMessageUser(message, profiles)
val messageGroup = VkUtils.getMessageGroup(message, groups)
val chatGroup: VkGroup? = if (conversation.isGroup()) {
groups[conversation.id]
} else null
val messageGroup: VkGroup? = if (message.isGroup()) {
groups[message.fromId]
} else null
val avatar = when {
conversation.ownerId == VKConstants.FAST_GROUP_ID -> null
conversation.isUser() && chatUser != null && !chatUser.photo200.isNullOrBlank() -> chatUser.photo200
conversation.isGroup() && chatGroup != null && !chatGroup.photo200.isNullOrBlank() -> chatGroup.photo200
conversation.isChat() && !conversation.photo200.isNullOrBlank() -> conversation.photo200
else -> null
}
val avatar = VkUtils.getConversationAvatar(
conversation = conversation,
conversationUser = conversationUser,
conversationGroup = conversationGroup
)
binding.avatar.isVisible = avatar != null
@@ -136,7 +124,7 @@ class ConversationsAdapter constructor(
}
}
binding.online.isVisible = chatUser?.online == true
binding.online.isVisible = conversationUser?.online == true
binding.pin.isVisible = conversation.isPinned
@@ -210,7 +198,8 @@ class ConversationsAdapter constructor(
binding.message.text = spanMessage
binding.title.text =
getItem(position).title ?: chatUser?.toString() ?: chatGroup?.name ?: "..."
getItem(position).title ?: conversationUser?.toString() ?: conversationGroup?.name
?: "..."
binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L)
@@ -232,6 +221,18 @@ class ConversationsAdapter constructor(
}
}
fun removeConversation(conversationId: Int): Int? {
for (i in values.indices) {
val conversation = values[i]
if (conversation.id == conversationId) {
values.removeAt(i)
return i
}
}
return null
}
companion object {
private val COMPARATOR = object : DiffUtil.ItemCallback<VkConversation>() {
override fun areItemsTheSame(
@@ -1,43 +1,46 @@
package com.meloda.fast.screens.conversations
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.viewbinding.library.fragment.viewBinding
import androidx.core.content.ContextCompat
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.datastore.preferences.core.edit
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import coil.load
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.meloda.fast.R
import com.meloda.fast.activity.MainActivity
import com.meloda.fast.api.UserConfig
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.VKEvent
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.databinding.FragmentConversationsBinding
import com.meloda.fast.util.AndroidUtils
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@AndroidEntryPoint
class ConversationsFragment :
BaseViewModelFragment<ConversationsViewModel>(R.layout.fragment_conversations) {
companion object {
const val TAG = "ConversationsFragment"
}
override val viewModel: ConversationsViewModel by viewModels()
private val binding: FragmentConversationsBinding by viewBinding()
@@ -53,8 +56,25 @@ class ConversationsFragment :
}
}
private val avatarPopupMenu: PopupMenu
get() =
PopupMenu(
requireContext(),
binding.avatar,
Gravity.BOTTOM
).apply {
menu.add(getString(R.string.log_out))
setOnMenuItemClickListener { item ->
if (item.title == getString(R.string.log_out)) {
showLogOutDialog()
return@setOnMenuItemClickListener true
}
false
}
}
private var isPaused = false
private var isExpanded = true
override fun onPause() {
super.onPause()
@@ -71,14 +91,10 @@ class ConversationsFragment :
requireContext().dataStore.data.map {
adapter.isMultilineEnabled = it[AppSettings.keyIsMultilineEnabled] ?: true
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}.collect { }
}.collect()
}
binding.createChat.setOnClickListener {
Snackbar.make(it, "Test snackbar", Snackbar.LENGTH_SHORT)
.setAction("Action") {}
.show()
}
binding.createChat.setOnClickListener {}
UserConfig.vkUser.observe(viewLifecycleOwner) {
it?.let { user -> binding.avatar.load(user.photo200) { crossfade(100) } }
@@ -87,28 +103,32 @@ class ConversationsFragment :
binding.appBar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, verticalOffset ->
if (isPaused) return@OnOffsetChangedListener
if (verticalOffset <= -100) {
binding.avatarContainer.alpha = 0f
return@OnOffsetChangedListener
}
binding.appBar.animate().translationZ(
if (verticalOffset < 0) AndroidUtils.px(3).roundToInt().toFloat()
else 0f
).setDuration(50).start()
val alpha = 1 - abs(verticalOffset * 0.01).toFloat()
val padding = AndroidUtils.px(if (verticalOffset <= -100) 10 else 30).roundToInt()
binding.avatarContainer.updatePadding(
bottom = padding,
right = padding
)
val minusAlpha = (1 - (abs(verticalOffset) * 0.01)).toFloat()
val plusAlpha = (abs(1 + verticalOffset * 0.01) * 1.01).toFloat()
println("Fast::ConversationsFragment::onOffset offset: $verticalOffset; minusAlpha: $minusAlpha; plusAlpha: $plusAlpha")
val alpha: Float = if (verticalOffset <= -100) plusAlpha else minusAlpha
binding.avatarContainer.alpha = alpha
})
if (isPaused) {
isPaused = false
return
}
binding.avatar.setOnClickListener { avatarPopupMenu.show() }
binding.toolbar.overflowIcon = ContextCompat.getDrawable(requireContext(), R.drawable.test)
viewModel.loadProfileUser()
viewModel.loadConversations()
binding.avatar.setOnClickListener {
lifecycleScope.launchWhenResumed {
binding.avatar.setOnLongClickListener {
lifecycleScope.launch {
requireContext().dataStore.edit { settings ->
val isMultilineEnabled = settings[AppSettings.keyIsMultilineEnabled] ?: true
settings[AppSettings.keyIsMultilineEnabled] = !isMultilineEnabled
@@ -117,15 +137,59 @@ class ConversationsFragment :
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
}
true
}
if (isPaused) {
isPaused = false
return
}
viewModel.loadProfileUser()
viewModel.loadConversations()
}
override fun onEvent(event: VKEvent) {
private fun showLogOutDialog() {
val isEasterEgg = UserConfig.userId == UserConfig.userId
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (isEasterEgg) "Выйти внаружу?"
else getString(R.string.sign_out_confirm_title)
)
.setMessage(R.string.sign_out_confirm)
.setPositiveButton(
if (isEasterEgg) "Выйти внаружу"
else getString(R.string.action_sign_out)
) { _, _ ->
lifecycleScope.launch(Dispatchers.Default) {
UserConfig.clear()
AppGlobal.appDatabase.clearAllTables()
requireActivity().finishAffinity()
requireActivity().startActivity(
Intent(
requireContext(),
MainActivity::class.java
)
)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
override fun onEvent(event: VkEvent) {
super.onEvent(event)
when (event) {
is ConversationsLoaded -> refreshConversations(event)
is StartProgressEvent -> onProgressStarted()
is StopProgressEvent -> onProgressStopped()
is ConversationsLoaded -> refreshConversations(event)
is ConversationsDelete -> deleteConversation(event.peerId)
// TODO: 10-Oct-21 remove this and sort conversations list
is ConversationsPin, is ConversationsUnpin -> viewModel.loadConversations()
}
}
@@ -179,13 +243,19 @@ class ConversationsFragment :
private fun fillRecyclerView(values: List<VkConversation>) {
adapter.values.clear()
adapter.values += values
adapter.notifyItemRangeChanged(0, adapter.itemCount)
adapter.submitList(values)
}
private fun onItemClick(position: Int) {
val conversation = adapter[position]
val user = if (conversation.isUser()) adapter.profiles[conversation.id] else null
val group = if (conversation.isGroup()) adapter.groups[conversation.id] else null
val user =
if (conversation.isUser()) adapter.profiles[conversation.id]
else null
val group =
if (conversation.isGroup()) adapter.groups[conversation.id]
else null
findNavController().navigate(
R.id.toMessagesHistory,
@@ -198,8 +268,81 @@ class ConversationsFragment :
}
private fun onItemLongClick(position: Int): Boolean {
binding.createChat.performClick()
showOptionsDialog(position)
return true
}
private fun showOptionsDialog(position: Int) {
val conversation = adapter[position]
var canPinOneMoreDialog = true
if (adapter.itemCount > 4) {
val firstFiveDialogs = adapter.values.subList(0, 5)
var pinnedCount = 0
firstFiveDialogs.forEach { if (it.isPinned) pinnedCount++ }
if (pinnedCount == 5 && position > 4) {
canPinOneMoreDialog = false
}
}
val pin = getString(
if (conversation.isPinned) R.string.conversation_context_action_unpin
else R.string.conversation_context_action_pin
)
val delete = getString(R.string.conversation_context_action_delete)
val params = mutableListOf<String>()
if (canPinOneMoreDialog) params += pin
params += delete
val arrayParams = params.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayParams) { _, which ->
when (params[which]) {
pin -> showPinConversationDialog(conversation)
delete -> showDeleteConversationDialog(conversation.id)
}
}.show()
}
private fun showDeleteConversationDialog(conversationId: Int) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.confirm_delete_conversation)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteConversation(conversationId)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteConversation(conversationId: Int) {
val index = adapter.removeConversation(conversationId) ?: return
adapter.notifyItemRemoved(index)
}
private fun showPinConversationDialog(conversation: VkConversation) {
val isPinned = conversation.isPinned
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (isPinned) R.string.confirm_unpin_conversation
else R.string.confirm_pin_conversation
)
.setPositiveButton(
if (isPinned) R.string.action_unpin
else R.string.action_pin
) { _, _ ->
viewModel.pinConversation(
peerId = conversation.id,
pin = !isPinned
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
@@ -3,17 +3,14 @@ package com.meloda.fast.screens.conversations
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.network.datasource.ConversationsDataSource
import com.meloda.fast.api.network.datasource.UsersDataSource
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.api.model.request.ConversationsGetRequest
import com.meloda.fast.api.model.request.UsersGetRequest
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.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import com.meloda.fast.base.viewmodel.VkEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -22,18 +19,20 @@ import javax.inject.Inject
@HiltViewModel
class ConversationsViewModel @Inject constructor(
private val dataSource: ConversationsDataSource,
private val usersDataSource: UsersDataSource
private val conversations: ConversationsDataSource,
private val users: UsersDataSource
) : BaseViewModel() {
fun loadConversations() = viewModelScope.launch(Dispatchers.Default) {
fun loadConversations(
offset: Int? = null
) = viewModelScope.launch(Dispatchers.Default) {
makeJob({
dataSource.getAllChats(
conversations.get(
ConversationsGetRequest(
count = 30,
// offset = 177,
extended = true,
fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}"
offset = offset,
fields = VKConstants.ALL_FIELDS
)
)
},
@@ -52,6 +51,7 @@ class ConversationsViewModel @Inject constructor(
sendEvent(
ConversationsLoaded(
count = response.count,
offset = offset,
unreadCount = response.unreadCount ?: 0,
conversations = response.items.map { items ->
items.conversation.asVkConversation(
@@ -63,34 +63,59 @@ class ConversationsViewModel @Inject constructor(
)
)
}
},
onError = {
val er = it
throw it
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) })
}
)
}
fun loadProfileUser() = viewModelScope.launch {
makeJob({
usersDataSource.getById(UsersGetRequest(fields = "online,photo_200"))
},
makeJob({ users.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) },
onAnswer = {
it.response?.let { r ->
val users = r.map { u -> u.asVkUser() }
usersDataSource.storeUsers(users)
this@ConversationsViewModel.users.storeUsers(users)
UserConfig.vkUser.value = users[0]
}
})
}
fun deleteConversation(peerId: Int) = viewModelScope.launch {
makeJob({
conversations.delete(
ConversationsDeleteRequest(peerId)
)
}, onAnswer = { sendEvent(ConversationsDelete(peerId)) })
}
fun pinConversation(
peerId: Int,
pin: Boolean
) = viewModelScope.launch {
if (pin) {
makeJob(
{ conversations.pin(ConversationsPinRequest(peerId)) },
onAnswer = { sendEvent(ConversationsPin(peerId)) }
)
} else {
makeJob(
{ conversations.unpin(ConversationsUnpinRequest(peerId)) },
onAnswer = { sendEvent(ConversationsUnpin(peerId)) }
)
}
}
}
data class ConversationsLoaded(
val count: Int,
val offset: Int?,
val unreadCount: Int?,
val conversations: List<VkConversation>,
val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup>
) : VKEvent()
) : VkEvent()
data class ConversationsDelete(val peerId: Int) : VkEvent()
data class ConversationsPin(val peerId: Int) : VkEvent()
data class ConversationsUnpin(val peerId: Int) : VkEvent()
@@ -1,11 +1,17 @@
package com.meloda.fast.screens.login
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.viewbinding.library.fragment.viewBinding
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
@@ -19,6 +25,8 @@ import com.google.android.material.snackbar.Snackbar
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.databinding.DialogCaptchaBinding
@@ -29,7 +37,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import java.net.URLEncoder
import java.util.*
import java.util.regex.Pattern
import kotlin.concurrent.schedule
@AndroidEntryPoint
@@ -59,14 +70,14 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
binding.loginInput.clearFocus()
}
override fun onEvent(event: VKEvent) {
override fun onEvent(event: VkEvent) {
super.onEvent(event)
when (event) {
is ShowError -> showErrorSnackbar(event.errorDescription)
is ErrorEvent -> showErrorSnackbar(event.errorText)
is CaptchaEvent -> showCaptchaDialog(event.sid, event.image)
is ValidationEvent -> showValidationRequired(event.sid)
is SuccessAuth -> goToMain(event.haveAuthorized)
is SuccessAuth -> goToMain(event)
is CodeSent -> showValidationDialog()
is StartProgressEvent -> onProgressStarted()
@@ -89,11 +100,91 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
}
private fun prepareViews() {
prepareWebView()
prepareEmailEditText()
preparePasswordEditText()
prepareAuthButton()
}
@SuppressLint("SetJavaScriptEnabled")
private fun prepareWebView() {
with(binding.webView) {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
clearCache(true)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
parseAuthUrl(url)
}
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
val a = Jsoup.parse(url)
val b = 0
}
}
}
CookieManager.getInstance().apply {
removeAllCookies(null)
flush()
setAcceptCookie(true)
}
}
private fun launchWebView() {
binding.webView.loadUrl(
"https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" +
"display=mobile&scope=136297695&" +
"redirect_uri=${
URLEncoder.encode(
"https://oauth.vk.com/blank.html",
Charsets.UTF_8.toString()
)
}&response_type=token&v=${VKConstants.API_VERSION}"
)
}
private fun parseAuthUrl(url: String) {
if (url.isBlank()) return
if (url.startsWith("https://oauth.vk.com/blank.html")) {
if (url.contains("error")) {
Log.e("Fast::Login", "errorUrl: $url")
return
}
val authData = parseRedirectUrl(url)
if (authData == null) {
Log.e("Fast::Login", "errorUrl: $url")
return
}
val token = authData.first
UserConfig.fastToken = token
}
}
private fun parseRedirectUrl(url: String): Pair<String, Int>? {
val accessToken = extractPattern(url, "access_token=(.*?)&") ?: return null
val userId = extractPattern(url, "id=(\\d*)")?.toIntOrNull() ?: return null
return accessToken to userId
}
private fun extractPattern(string: String, pattern: String): String? {
val p = Pattern.compile(pattern)
val m = p.matcher(string)
return if (m.find()) {
m.group(1)
} else null
}
private fun prepareEmailEditText() {
binding.loginInput.addTextChangedListener {
if (!binding.loginLayout.error.isNullOrBlank()) binding.loginLayout.error = ""
@@ -293,8 +384,13 @@ class LoginFragment : BaseViewModelFragment<LoginViewModel>(R.layout.fragment_lo
snackbar.show()
}
private fun goToMain(haveAuthorized: Boolean) = lifecycleScope.launch {
if (haveAuthorized) delay(500)
private fun goToMain(event: SuccessAuth) = lifecycleScope.launch {
UserConfig.userId = event.userId
UserConfig.accessToken = event.vkToken
if (event.haveAuthorized) delay(500)
launchWebView()
findNavController().navigate(R.id.toMain)
}
@@ -1,11 +1,10 @@
package com.meloda.fast.screens.login
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.VKException
import com.meloda.fast.api.model.request.RequestAuthDirect
import com.meloda.fast.api.network.datasource.AuthDataSource
import com.meloda.fast.api.network.auth.AuthDataSource
import com.meloda.fast.api.network.auth.RequestAuthDirect
import com.meloda.fast.base.viewmodel.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
@@ -45,33 +44,37 @@ class LoginViewModel @Inject constructor(
return@makeJob
}
UserConfig.userId = it.userId
UserConfig.accessToken = it.accessToken
sendEvent(SuccessAuth())
sendEvent(
SuccessAuth(
userId = it.userId,
vkToken = it.accessToken
)
)
},
onError = {
if (it !is VKException) return@makeJob
if (it !is VKException) {
onError(it)
return@makeJob
}
// TODO: 9/27/2021 use `delay` parameter
twoFaCode?.let { sendEvent(CodeSent) }
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) }
}
)
}
fun sendSms(validationSid: String) = viewModelScope.launch {
makeJob({ dataSource.sendSms(validationSid) },
onAnswer = { sendEvent(CodeSent) },
onError = {},
onStart = {},
onEnd = {})
onAnswer = { sendEvent(CodeSent) }
)
}
}
data class ShowError(val errorDescription: String) : VKEvent()
object CodeSent : VkEvent()
object CodeSent : VKEvent()
data class SuccessAuth(val haveAuthorized: Boolean = true) : VKEvent()
data class SuccessAuth(
val haveAuthorized: Boolean = true,
val userId: Int,
val vkToken: String
) : VkEvent()
@@ -42,5 +42,4 @@ class MainFragment : BaseViewModelFragment<MainViewModel>(R.layout.fragment_main
}
}
}
@@ -15,9 +15,11 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isNotEmpty
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.core.view.updatePadding
import coil.load
import com.google.android.material.imageview.ShapeableImageView
import com.meloda.fast.R
import com.meloda.fast.api.UserConfig
import com.meloda.fast.api.VkUtils
import com.meloda.fast.api.model.VkGroup
import com.meloda.fast.api.model.VkMessage
@@ -30,9 +32,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 message: VkMessage,
private val profiles: Map<Int, VkUser>,
private val groups: Map<Int, VkGroup>
@@ -44,16 +48,28 @@ class AttachmentInflater constructor(
private val playColor = ContextCompat.getColor(context, R.color.a3_700)
private val playBackgroundColor = ContextCompat.getColor(context, R.color.a3_200)
var photoClickListener: ((url: String) -> Unit)? = null
fun setPhotoClickListener(unit: ((url: String) -> Unit)?): AttachmentInflater {
this.photoClickListener = unit
return this
}
fun inflate() {
if (message.attachments.isNullOrEmpty()) return
attachments = message.attachments!!
container.removeAllViews()
textContainer.removeAllViews()
if (attachments.size == 1) {
when (val attachment = attachments[0]) {
is VkSticker -> return sticker(attachment)
is VkWall -> return wall(attachment)
is VkVoiceMessage -> return voice(attachment)
is VkCall -> return call(attachment)
is VkGraffiti -> return graffiti(attachment)
is VkGift -> return gift(attachment)
}
}
@@ -82,7 +98,6 @@ class AttachmentInflater constructor(
is VkAudio -> audio(attachment)
is VkFile -> file(attachment)
is VkLink -> link(attachment)
is VkStory -> story(attachment)
else -> Log.e(
"Attachment inflater",
@@ -94,12 +109,15 @@ class AttachmentInflater constructor(
}
private fun photo(photo: VkPhoto) {
val size = photo.sizeOfType('m') ?: return
val size = photo.getSizeOrSmaller('y') ?: return
val newPhoto = ShapeableImageView(context).apply {
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(size.width).roundToInt(),
AndroidUtils.px(size.height).roundToInt()
// ViewGroup.LayoutParams.MATCH_PARENT,
size.width,
size.height
// AndroidUtils.px(size.width).roundToInt(),
// AndroidUtils.px(size.height).roundToInt()
)
shapeAppearanceModel =
@@ -110,6 +128,12 @@ class AttachmentInflater constructor(
scaleType = ImageView.ScaleType.CENTER_CROP
}
if (photoClickListener != null) {
newPhoto.setOnClickListener { photoClickListener?.invoke(size.url) }
} else {
newPhoto.setOnClickListener(null)
}
val spacer = Space(context).also {
it.layoutParams = LinearLayoutCompat.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -222,14 +246,7 @@ class AttachmentInflater constructor(
binding.caption.text = link.caption
binding.caption.isVisible = !link.caption.isNullOrBlank()
binding.preview.shapeAppearanceModel.toBuilder()
.setAllCornerSizes(40f)
.build()
.let {
binding.preview.shapeAppearanceModel = it
}
link.photo?.sizeOfType('m')?.let {
link.photo?.getMaxSize()?.let {
binding.preview.load(it.url) { crossfade(150) }
binding.preview.isVisible = true
return
@@ -245,8 +262,8 @@ class AttachmentInflater constructor(
with(binding.image) {
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(180).roundToInt(),
AndroidUtils.px(180).roundToInt()
AndroidUtils.px(140).roundToInt(),
AndroidUtils.px(140).roundToInt()
)
load(url) { crossfade(150) }
@@ -282,7 +299,7 @@ class AttachmentInflater constructor(
binding.avatar.isVisible = group != null || user != null
binding.avatar.shapeAppearanceModel.toBuilder()
.setAllCornerSizes(40f)
.setAllCornerSizes(AndroidUtils.px(20))
.build()
.let {
binding.avatar.shapeAppearanceModel = it
@@ -302,8 +319,91 @@ class AttachmentInflater constructor(
).format(wall.date * 1000L)
}
private fun story(story: VkStory) {
private fun voice(voiceMessage: VkVoiceMessage) {
val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, textContainer, true)
if (message.isOut)
binding.root.updatePadding(
bottom = AndroidUtils.px(6).roundToInt(),
left = AndroidUtils.px(6).roundToInt()
)
val waveform = IntArray(voiceMessage.waveform.size)
voiceMessage.waveform.forEachIndexed { index, i -> waveform[index] = i }
binding.waveform.sample = waveform
binding.waveform.maxProgress = 100f
binding.waveform.progress = 100f
binding.duration.text = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(voiceMessage.duration * 1000L)
}
private fun call(call: VkCall) {
val binding = ItemMessageAttachmentCallBinding.inflate(inflater, textContainer, true)
if (message.isOut)
binding.root.updatePadding(
bottom = AndroidUtils.px(5).roundToInt(),
left = AndroidUtils.px(6).roundToInt()
)
val callType =
context.getString(
if (call.initiatorId == UserConfig.userId) R.string.message_call_type_outgoing
else R.string.message_call_type_incoming
)
binding.type.text = callType
var callState =
context.getString(
if (call.state == "reached") R.string.message_call_state_ended
else if (call.state == "canceled_by_initiator") {
if (call.initiatorId == UserConfig.userId) R.string.message_call_state_cancelled
else R.string.message_call_state_missed
} else R.string.message_call_unknown
)
if (callState == context.getString(R.string.message_call_unknown)) callState = call.state
binding.state.text = callState
}
private fun graffiti(graffiti: VkGraffiti) {
val binding = ItemMessageAttachmentGraffitiBinding.inflate(inflater, container, true)
val url = graffiti.url
val heightCoefficient = graffiti.height / AndroidUtils.px(140)
with(binding.image) {
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(140).roundToInt(),
(graffiti.height / heightCoefficient).roundToInt()
)
load(url) { crossfade(150) }
}
}
private fun gift(gift: VkGift) {
val binding = ItemMessageAttachmentGiftBinding.inflate(inflater, container, true)
val url = gift.thumb256 ?: gift.thumb96 ?: gift.thumb48
with(binding.image) {
shapeAppearanceModel = shapeAppearanceModel.withCornerSize { AndroidUtils.px(12) }
layoutParams = LinearLayoutCompat.LayoutParams(
AndroidUtils.px(140).roundToInt(),
AndroidUtils.px(140).roundToInt()
)
load(url) { crossfade(150) }
}
}
}
@@ -5,6 +5,7 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
@@ -29,7 +30,9 @@ class MessagesHistoryAdapter constructor(
val conversation: VkConversation,
val profiles: HashMap<Int, VkUser> = hashMapOf(),
val groups: HashMap<Int, VkGroup> = hashMapOf()
) : BaseAdapter<VkMessage, MessagesHistoryAdapter.Holder>(context, values, COMPARATOR) {
) : BaseAdapter<VkMessage, MessagesHistoryAdapter.BasicHolder>(context, values, COMPARATOR) {
var avatarLongClickListener: ((position: Int) -> Unit)? = null
override fun getItemViewType(position: Int): Int {
when {
@@ -49,7 +52,7 @@ class MessagesHistoryAdapter constructor(
private fun isPositionHeader(position: Int) = position == 0
private fun isPositionFooter(position: Int) = position >= actualSize
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BasicHolder {
return when (viewType) {
// magick numbers is great!
HEADER -> Header(createEmptyView(60))
@@ -67,6 +70,21 @@ class MessagesHistoryAdapter constructor(
}
}
// override fun initListeners(itemView: View, position: Int) {
// if (itemView is AdapterView<*>) return
//
// itemView.setOnClickListener { onItemClickListener?.invoke(position, itemView) }
// itemView.setOnLongClickListener { itemLongClickListener.invoke(position) }
// }
val actualSize get() = values.size
override fun getItemCount(): Int {
if (actualSize == 0) return 2
return super.getItemCount() + 2
}
private fun createEmptyView(size: Int) = View(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -78,22 +96,22 @@ class MessagesHistoryAdapter constructor(
isFocusable = false
}
override fun onBindViewHolder(holder: Holder, position: Int) {
override fun onBindViewHolder(holder: BasicHolder, position: Int) {
if (holder is Header || holder is Footer) return
initListeners(holder.itemView, position)
holder.bind(position)
}
open inner class Holder(v: View = View(context)) : BaseHolder(v)
open inner class BasicHolder(v: View = View(context)) : BaseHolder(v)
inner class Header(v: View) : Holder(v)
inner class Header(v: View) : BasicHolder(v)
inner class Footer(v: View) : Holder(v)
inner class Footer(v: View) : BasicHolder(v)
inner class IncomingMessage(
private val binding: ItemMessageInBinding
) : Holder(binding.root) {
) : BasicHolder(binding.root) {
override fun bind(position: Int) {
val message = getItem(position)
@@ -103,33 +121,42 @@ class MessagesHistoryAdapter constructor(
MessagesPreparator(
context = context,
root = binding.root,
conversation = conversation,
message = message,
prevMessage = prevMessage,
nextMessage = nextMessage,
title = binding.title,
avatar = binding.avatar,
bubble = binding.bubble,
text = binding.text,
spacer = binding.spacer,
time = binding.time,
unread = binding.unread,
textContainer = binding.textContainer,
attachmentContainer = binding.attachmentContainer,
attachmentSpacer = binding.attachmentSpacer,
profiles = profiles,
groups = groups
).prepare()
).setPhotoClickListener {
Toast.makeText(context, "Photo url: $it", Toast.LENGTH_LONG).show()
}.prepare()
binding.avatar.setOnLongClickListener() {
avatarLongClickListener?.invoke(position)
true
}
}
}
inner class OutgoingMessage(
private val binding: ItemMessageOutBinding
) : Holder(binding.root) {
init {
binding.bubbleStroke.setOnClickListener { binding.bubble.performClick() }
}
) : BasicHolder(binding.root) {
override fun bind(position: Int) {
val message = getItem(position)
@@ -138,16 +165,17 @@ class MessagesHistoryAdapter constructor(
MessagesPreparator(
context = context,
root = binding.root,
conversation = conversation,
message = message,
prevMessage = prevMessage,
bubble = binding.bubble,
bubbleStroke = binding.bubbleStroke,
text = binding.text,
spacer = binding.spacer,
time = binding.time,
unread = binding.unread,
textContainer = binding.textContainer,
attachmentContainer = binding.attachmentContainer,
attachmentSpacer = binding.attachmentSpacer,
@@ -159,7 +187,7 @@ class MessagesHistoryAdapter constructor(
inner class ServiceMessage(
private val binding: ItemMessageServiceBinding
) : Holder(binding.root) {
) : BasicHolder(binding.root) {
private val youPrefix = context.getString(R.string.you_message_prefix)
@@ -198,7 +226,7 @@ class MessagesHistoryAdapter constructor(
binding.photo.isVisible = true
val size = attachment.sizeOfType('m') ?: return@let
val size = attachment.getSizeOrSmaller('y') ?: return@let
binding.photo.layoutParams = LinearLayoutCompat.LayoutParams(
size.width,
@@ -213,11 +241,39 @@ class MessagesHistoryAdapter constructor(
}
}
private val actualSize get() = values.size
fun removeMessageById(id: Int): Int? {
for (i in values.indices) {
val message = values[i]
if (message.id == id) {
values.removeAt(i)
return i
}
}
override fun getItemCount(): Int {
if (actualSize == 0) return 2
return super.getItemCount() + 2
return null
}
fun removeMessagesByIds(ids: List<Int>): List<Int> {
val positions = mutableListOf<Int>()
for (i in values.indices) {
val message = values[i]
if (ids.contains(message.id)) {
values.removeAt(i)
positions += i
}
}
return positions
}
fun searchMessageIndex(messageId: Int): Int? {
for (i in values.indices) {
val message = values[i]
if (message.id == messageId) return i
}
return null
}
companion object {
@@ -1,12 +1,16 @@
package com.meloda.fast.screens.messages
import android.graphics.Color
import android.content.res.ColorStateList
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.viewbinding.library.fragment.viewBinding
import android.widget.Toast
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
@@ -16,6 +20,8 @@ import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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
@@ -23,7 +29,8 @@ 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.base.viewmodel.VKEvent
import com.meloda.fast.base.viewmodel.VkEvent
import com.meloda.fast.databinding.DialogMessageDeleteBinding
import com.meloda.fast.databinding.FragmentMessagesHistoryBinding
import com.meloda.fast.extensions.TextViewExtensions.clear
import com.meloda.fast.util.AndroidUtils
@@ -32,6 +39,7 @@ import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
@AndroidEntryPoint
class MessagesHistoryFragment :
@@ -43,7 +51,7 @@ class MessagesHistoryFragment :
private val action = MutableLiveData<Action>()
private enum class Action {
RECORD, SEND
RECORD, SEND, EDIT, DELETE
}
private val user: VkUser? by lazy {
@@ -62,14 +70,19 @@ class MessagesHistoryFragment :
MessagesHistoryAdapter(requireContext(), mutableListOf(), conversation).also {
it.itemClickListener = this::onItemClick
it.itemLongClickListener = this::onItemLongClick
it.avatarLongClickListener = this::onAvatarLongClickListener
}
}
private var timestampTimer: Timer? = null
private lateinit var attachmentController: AttachmentPanelController
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
attachmentController = AttachmentPanelController().init()
val title = when {
conversation.isChat() -> conversation.title
conversation.isUser() -> user?.toString()
@@ -98,19 +111,7 @@ class MessagesHistoryFragment :
binding.status.text = status ?: "..."
val avatar = when {
conversation.isChat() -> conversation.photo200
conversation.isUser() -> user?.photo200
conversation.isGroup() -> group?.photo200
else -> null
}
binding.avatar.load(avatar) {
crossfade(false)
error(ColorDrawable(Color.RED))
}
binding.online.isVisible = user?.online == true
prepareAvatar()
prepareViews()
@@ -166,8 +167,14 @@ class MessagesHistoryFragment :
})
binding.message.doAfterTextChanged {
val newValue = if (it.toString().isNotBlank()) Action.SEND
else Action.RECORD
val canSend = it.toString().isNotBlank()
val newValue: Action =
when {
attachmentController.isEditing -> if (it.isNullOrBlank()) Action.DELETE else Action.EDIT
canSend -> Action.SEND
else -> Action.RECORD
}
if (action.value != newValue) action.value = newValue
}
@@ -192,52 +199,159 @@ class MessagesHistoryFragment :
Action.SEND -> {
binding.action.setImageResource(R.drawable.ic_round_send_24)
}
Action.EDIT -> {
binding.action.setImageResource(R.drawable.ic_round_done_24)
}
Action.DELETE -> {
binding.action.setImageResource(R.drawable.ic_trash_can_outline_24)
}
else -> return@observe
}
}
attachmentController.isPanelVisible.observe(viewLifecycleOwner) {
if (it) binding.message.setSelection(binding.message.text.toString().length)
val layoutParams = binding.refreshLayout.layoutParams as CoordinatorLayout.LayoutParams
layoutParams.bottomMargin =
if (it) (binding.attachmentPanel.height / 1.5).roundToInt() else 0
}
binding.attachmentPanel.setOnClickListener c@{
val message = attachmentController.message.value ?: return@c
val index = adapter.values.indexOf(message)
if (index == -1) return@c
binding.recyclerView.smoothScrollToPosition(index)
}
binding.dismissReply.setOnClickListener {
if (attachmentController.message.value != null)
attachmentController.message.value = null
}
}
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
}
binding.avatar.isVisible = avatar != null
if (avatar == null) {
binding.avatarPlaceholder.isVisible = true
if (conversation.ownerId == VKConstants.FAST_GROUP_ID) {
binding.placeholderBack.setImageDrawable(
ColorDrawable(
ContextCompat.getColor(requireContext(), R.color.a1_400)
)
)
binding.placeholder.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.a1_0))
binding.placeholder.setImageResource(R.drawable.ic_fast_logo)
binding.placeholder.setPadding(18)
} else {
binding.placeholderBack.setImageDrawable(
ColorDrawable(
ContextCompat.getColor(requireContext(), R.color.n1_50)
)
)
binding.placeholder.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.n2_500))
binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut)
binding.placeholder.setPadding(0)
binding.avatar.setImageDrawable(null)
}
} else {
binding.avatar.load(avatar) {
crossfade(200)
target {
binding.avatarPlaceholder.isVisible = false
binding.avatar.setImageDrawable(it)
}
}
}
binding.phantomIcon.isVisible = conversation.isPhantom
binding.online.isVisible = user?.online == true
binding.pin.isVisible = conversation.isPinned
}
private fun performAction() {
when (action.value) {
Action.RECORD -> {
}
Action.SEND -> {
val messageText = binding.message.text.toString().trim()
if (messageText.isBlank()) return
val date = System.currentTimeMillis()
val message = VkMessage(
id = -1,
text = messageText,
isOut = true,
peerId = conversation.id,
fromId = UserConfig.userId,
date = (date / 1000).toInt(),
randomId = 0,
replyMessage = attachmentController.message.value
)
adapter.add(message)
adapter.notifyItemInserted(adapter.actualSize - 1)
binding.recyclerView.smoothScrollToPosition(adapter.lastPosition)
binding.message.clear()
val replyMessage = attachmentController.message.value
attachmentController.message.value = null
viewModel.sendMessage(
peerId = conversation.id,
message = messageText,
randomId = 0,
replyTo = replyMessage?.id
) { message.id = it }
}
Action.EDIT -> {
val message = attachmentController.message.value ?: return
val messageText = binding.message.text.toString().trim()
attachmentController.message.value = null
viewModel.editMessage(
originalMessage = message,
peerId = conversation.id,
messageId = message.id,
message = messageText,
attachments = message.attachments
)
}
Action.DELETE -> attachmentController.message.value?.let {
showDeleteMessageDialog(it)
}
}
}
private fun performAction() {
if (action.value == Action.RECORD) {
} else if (action.value == Action.SEND) {
val messageText = binding.message.text.toString().trim()
if (messageText.isBlank()) return
val date = System.currentTimeMillis()
var message = VkMessage(
id = -1,
text = messageText,
isOut = true,
peerId = conversation.id,
fromId = UserConfig.userId,
date = (date / 1000).toInt(),
randomId = 0
)
adapter.add(message)
adapter.notifyDataSetChanged()
binding.recyclerView.smoothScrollToPosition(adapter.lastPosition)
binding.message.clear()
viewModel.sendMessage(
peerId = conversation.id,
message = messageText,
randomId = 0
) { message = message.copyMessage(id = it) }
}
}
override fun onEvent(event: VKEvent) {
override fun onEvent(event: VkEvent) {
super.onEvent(event)
when (event) {
is MessagesMarkAsImportant -> markMessagesAsImportant(event)
is MessagesLoaded -> refreshMessages(event)
is StartProgressEvent -> onProgressStarted()
is StopProgressEvent -> onProgressStopped()
is MessagesMarkAsImportant -> markMessagesAsImportant(event)
is MessagesLoaded -> refreshMessages(event)
is MessagesPin -> conversation.pinnedMessage = event.message
is MessagesUnpin -> conversation.pinnedMessage = null
is MessagesDelete -> deleteMessages(event)
is MessagesEdit -> editMessage(event)
}
}
@@ -283,17 +397,21 @@ class MessagesHistoryFragment :
private fun markMessagesAsImportant(event: MessagesMarkAsImportant) {
var changed = false
val positions = mutableListOf<Int>()
for (i in adapter.values.indices) {
val message = adapter.values[i]
message.important = event.important
if (event.messagesIds.contains(message.id)) {
if (!changed) changed = true
adapter.values[i] = message.copyMessage(
important = event.important
)
positions.add(i)
adapter.values[i] = message
}
}
if (changed) adapter.notifyDataSetChanged()
if (changed) positions.forEach { adapter.notifyItemChanged(it) }
}
private fun refreshMessages(event: MessagesLoaded) {
@@ -315,30 +433,235 @@ class MessagesHistoryFragment :
}
private fun onItemClick(position: Int) {
showOptionsDialog(position)
}
private fun onItemLongClick(position: Int) = true
private fun onAvatarLongClickListener(position: Int) {
val message = adapter.values[position]
val messageUser = VkUtils.getMessageUser(message, adapter.profiles)
val messageGroup = VkUtils.getMessageGroup(message, adapter.groups)
val title = VkUtils.getMessageTitle(message, messageUser, messageGroup)
Toast.makeText(requireContext(), title, Toast.LENGTH_SHORT).show()
}
private fun showOptionsDialog(position: Int) {
val message = adapter.values[position]
if (message.action != null) return
val important = if (message.important) "Unmark as important" else "Mark as important"
val time = getString(
R.string.time_format,
SimpleDateFormat(
"dd.MM.yyyy, HH:mm:ss",
Locale.getDefault()
).format(message.date * 1000L)
)
val params = arrayOf(important)
val important = getString(
if (message.important) R.string.message_context_action_unmark_as_important
else R.string.message_context_action_mark_as_important
)
val dialog = MaterialAlertDialogBuilder(requireContext())
.setItems(params) { _, which ->
if (which == 0) {
viewModel.markAsImportant(
val reply = getString(R.string.message_context_action_reply)
val isMessageAlreadyPinned = message.id == conversation.pinnedMessage?.id
val pin = getString(
if (isMessageAlreadyPinned) R.string.message_context_action_unpin
else R.string.message_context_action_pin
)
val edit = getString(R.string.message_context_action_edit)
val delete = getString(R.string.message_context_action_delete)
val params = mutableListOf(
important, reply
)
if (conversation.canChangePin) {
params += pin
}
if (message.canEdit()) {
params += edit
}
params += delete
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
)
edit -> {
attachmentController.isEditing = true
if (attachmentController.message.value != message)
attachmentController.message.value = message
}
delete -> showDeleteMessageDialog(message)
}
}.show()
}
private fun showPinMessageDialog(
peerId: Int,
messageId: Int?,
pin: Boolean
) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(
if (pin) R.string.confirm_pin_message
else R.string.confirm_unpin_message
)
.setPositiveButton(
if (pin) R.string.action_pin
else R.string.action_unpin
) { _, _ ->
viewModel.pinMessage(
peerId = peerId,
messageId = messageId,
pin = pin
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showDeleteMessageDialog(message: VkMessage) {
val binding = DialogMessageDeleteBinding.inflate(layoutInflater, null, false)
binding.check.setText(
if (message.isOut) R.string.message_delete_for_all
else R.string.message_mark_as_spam
)
binding.check.isEnabled =
(conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit())
if (conversation.id == UserConfig.userId) binding.check.isChecked = true
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.confirm_delete_message)
.setView(binding.root)
.setPositiveButton(R.string.action_delete) { _, _ ->
attachmentController.message.value = null
viewModel.deleteMessage(
peerId = conversation.id,
messagesIds = listOf(message.id),
isSpam = if (message.isOut) null else binding.check.isChecked,
deleteForAll = if (!binding.check.isEnabled) null else binding.check.isChecked
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteMessages(event: MessagesDelete) {
adapter.removeMessagesByIds(event.messagesIds).let {
it.forEach { index -> adapter.notifyItemRemoved(index) }
}
}
private fun editMessage(event: MessagesEdit) {
adapter.searchMessageIndex(event.message.id)?.let { index ->
adapter.values[index] = event.message
adapter.notifyItemChanged(index)
}
}
private inner class AttachmentPanelController {
val isPanelVisible = MutableLiveData(false)
val message = MutableLiveData<VkMessage?>()
var isEditing = false
fun init(): AttachmentPanelController {
message.observe(viewLifecycleOwner) { value ->
if (value != null) {
applyMessage(value)
} else {
clearMessage()
}
}
dialog.show()
message.value = null
return this
}
}
private fun applyMessage(message: VkMessage) {
showPanel()
private fun onItemLongClick(position: Int): Boolean {
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
}
binding.replyMessageTitle.text = title
binding.replyMessageText.text = message.text ?: "[no_message]"
if (isEditing) {
binding.message.setText(message.text ?: "[no_message]")
}
}
private fun clearMessage() {
hidePanel()
binding.replyMessageTitle.clear()
binding.replyMessageText.clear()
if (isEditing) {
isEditing = false
binding.message.clear()
}
}
private fun showPanel(duration: Long = 250) {
if (attachmentController.isPanelVisible.value == false)
attachmentController.isPanelVisible.value = true
binding.attachmentPanel.animate()
.translationY(0f)
.alpha(1f)
.setDuration(duration)
.withStartAction { binding.attachmentPanel.isVisible = true }
.start()
}
private fun hidePanel(duration: Long = 250) {
if (attachmentController.isPanelVisible.value == true)
attachmentController.isPanelVisible.value = false
binding.attachmentPanel.animate()
.alpha(0f)
.translationY(50f)
.setDuration(duration)
.withEndAction { binding.attachmentPanel.isVisible = false }
.start()
}
return true
}
}
@@ -6,33 +6,29 @@ 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.request.MessagesGetHistoryRequest
import com.meloda.fast.api.model.request.MessagesMarkAsImportantRequest
import com.meloda.fast.api.model.request.MessagesSendRequest
import com.meloda.fast.api.network.datasource.MessagesDataSource
import com.meloda.fast.api.model.attachments.VkAttachment
import com.meloda.fast.api.network.messages.*
import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import com.meloda.fast.base.viewmodel.VkEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MessagesHistoryViewModel @Inject constructor(
private val dataSource: MessagesDataSource
private val messages: MessagesDataSource
) : BaseViewModel() {
fun loadHistory(
peerId: Int
) = viewModelScope.launch {
makeJob({
dataSource.getHistory(
messages.getHistory(
MessagesGetHistoryRequest(
count = 30,
peerId = peerId,
extended = true,
fields = "${VKConstants.USER_FIELDS},${VKConstants.GROUP_FIELDS}"
fields = VKConstants.ALL_FIELDS
)
)
},
@@ -53,18 +49,18 @@ class MessagesHistoryViewModel @Inject constructor(
}
}
val messages = hashMapOf<Int, VkMessage>()
val hashMessages = hashMapOf<Int, VkMessage>()
response.items.forEach { baseMessage ->
baseMessage.asVkMessage().let { message -> messages[message.id] = message }
baseMessage.asVkMessage().let { message -> hashMessages[message.id] = message }
}
dataSource.storeMessages(messages.values.toList())
messages.store(hashMessages.values.toList())
val conversations = hashMapOf<Int, VkConversation>()
response.conversations?.let { baseConversations ->
baseConversations.forEach { baseConversation ->
baseConversation.asVkConversation(
messages[baseConversation.last_message_id]
hashMessages[baseConversation.last_message_id]
).let { conversation -> conversations[conversation.id] = conversation }
}
}
@@ -75,41 +71,33 @@ class MessagesHistoryViewModel @Inject constructor(
profiles = profiles,
groups = groups,
conversations = conversations,
messages = messages.values.toList()
messages = hashMessages.values.toList()
)
)
},
onError = {
val throwable = it
throw it
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) })
})
}
fun sendMessage(
peerId: Int,
message: String? = null,
randomId: Int = 0,
replyTo: Int? = null,
setId: ((messageId: Int) -> Unit)? = null
) = viewModelScope.launch {
makeJob(
{
dataSource.send(
messages.send(
MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message
message = message,
replyTo = replyTo
)
)
},
onAnswer = {
val response = it.response ?: return@makeJob
setId?.invoke(response)
},
onError = {
val throwable = it
val i = 0
})
}
@@ -118,7 +106,7 @@ class MessagesHistoryViewModel @Inject constructor(
important: Boolean
) = viewModelScope.launch {
makeJob({
dataSource.markAsImportant(
messages.markAsImportant(
MessagesMarkAsImportantRequest(
messagesIds = messagesIds,
important = important
@@ -133,13 +121,84 @@ class MessagesHistoryViewModel @Inject constructor(
important = important
)
)
},
onError = {
val throwable = it
val i = 0
})
}
fun pinMessage(
peerId: Int,
messageId: Int? = null,
conversationMessageId: Int? = null,
pin: Boolean
) = viewModelScope.launch {
if (pin) {
makeJob({
messages.pin(
MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
conversationMessageId = conversationMessageId
)
)
},
onAnswer = {
val response = it.response ?: return@makeJob
sendEvent(MessagesPin(response.asVkMessage()))
}
)
} else {
makeJob({ messages.unpin(MessagesUnPinMessageRequest(peerId = peerId)) },
onAnswer = {
println("Fast::MessagesHistoryViewModel::unPin::Response::${it.response}")
sendEvent(MessagesUnpin)
}
)
}
}
fun deleteMessage(
peerId: Int,
messagesIds: List<Int>? = null,
conversationsMessagesIds: List<Int>? = null,
isSpam: Boolean? = null,
deleteForAll: Boolean? = null
) = viewModelScope.launch {
makeJob({
messages.delete(
MessagesDeleteRequest(
peerId = peerId,
messagesIds = messagesIds,
conversationsMessagesIds = conversationsMessagesIds,
isSpam = isSpam,
deleteForAll = deleteForAll
)
)
}, onAnswer = { sendEvent(MessagesDelete(messagesIds = messagesIds ?: listOf())) })
}
fun editMessage(
originalMessage: VkMessage,
peerId: Int,
messageId: Int,
message: String? = null,
attachments: List<VkAttachment>? = null
) = viewModelScope.launch {
makeJob(
{
messages.edit(
MessagesEditRequest(
peerId = peerId,
messageId = messageId,
message = message,
attachments = attachments
)
)
},
onAnswer = {
originalMessage.text = message
sendEvent(MessagesEdit(originalMessage))
}
)
}
}
data class MessagesLoaded(
@@ -148,9 +207,23 @@ data class MessagesLoaded(
val messages: List<VkMessage>,
val profiles: HashMap<Int, VkUser>,
val groups: HashMap<Int, VkGroup>
) : VKEvent()
) : VkEvent()
data class MessagesMarkAsImportant(
val messagesIds: List<Int>,
val important: Boolean
) : VKEvent()
) : VkEvent()
data class MessagesPin(
val message: VkMessage
) : VkEvent()
object MessagesUnpin : VkEvent()
data class MessagesDelete(
val messagesIds: List<Int>
) : VkEvent()
data class MessagesEdit(
val message: VkMessage
) : VkEvent()
@@ -1,6 +1,7 @@
package com.meloda.fast.screens.messages
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.util.Log
import android.view.View
import android.widget.ImageView
@@ -8,9 +9,7 @@ import android.widget.Space
import android.widget.TextView
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import coil.load
import com.meloda.fast.R
import com.meloda.fast.api.VkUtils
@@ -20,7 +19,6 @@ 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.util.AndroidUtils
import com.meloda.fast.widget.BoundedLinearLayout
import java.text.SimpleDateFormat
import java.util.*
@@ -30,19 +28,21 @@ import kotlin.math.roundToInt
class MessagesPreparator constructor(
private val context: Context,
private val root: View? = null,
private val conversation: VkConversation,
private val message: VkMessage,
private val prevMessage: VkMessage? = null,
private val nextMessage: VkMessage? = null,
private val bubble: BoundedLinearLayout? = null,
private val bubbleStroke: View? = null,
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 time: TextView? = null,
private val textContainer: LinearLayoutCompat? = null,
private val attachmentContainer: LinearLayoutCompat? = null,
private val attachmentSpacer: Space? = null,
@@ -65,51 +65,129 @@ class MessagesPreparator constructor(
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background)
private val backgroundMiddleOut =
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle)
private val backgroundStrokeOut =
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_stroke)
private val backgroundMiddleStrokeOut =
ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle_stroke)
private val rootHighlightedColor =
ContextCompat.getColor(context, R.color.n2_100)
private var photoClickListener: ((url: String) -> Unit)? = null
fun setPhotoClickListener(unit: ((url: String) -> Unit)?): MessagesPreparator {
this.photoClickListener = unit
return this
}
fun prepare() {
val messageUser: VkUser? = if (message.isUser()) {
profiles[message.fromId]
} else null
val messageUser = VkUtils.getMessageUser(message, profiles)
val messageGroup = VkUtils.getMessageGroup(message, groups)
val messageGroup: VkGroup? = if (message.isGroup()) {
groups[message.fromId]
} else null
prepareRootBackground()
prepareTime()
prepareUnreadIndicator()
prepareSpacer()
prepareAttachments()
prepareAttachmentsSpacer()
prepareBubbleBackground()
prepareText()
prepareAvatar(
messageUser = messageUser,
messageGroup = messageGroup
)
if (message.isPeerChat()) {
val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message)
val nextSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(message, nextMessage)
val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
val change = (prevMessage?.date ?: 0) - message.date
Log.d(
"Fast::MessagesPreparator",
"text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $prevSenderDiff; fiveMinAgo: $fiveMinAgo; "
)
title?.isVisible = prevSenderDiff || fiveMinAgo
avatar?.visibility =
if (nextSenderDiff
|| (fiveMinAgo && prevSenderDiff)
|| !prevSenderDiff
|| nextMessage == null
) View.VISIBLE else View.INVISIBLE
} else {
title?.isVisible = false
avatar?.isVisible = false
}
if (title != null) {
val titleString = when {
message.isUser() && messageUser != null -> messageUser.firstName
message.isGroup() && messageGroup != null -> messageGroup.name
else -> null
}
title.text = titleString
}
}
private fun prepareRootBackground() {
if (root != null) {
root.background =
if (message.isSelected) ColorDrawable(rootHighlightedColor)
else null
}
}
private fun prepareTime() {
time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L)
}
private fun prepareUnreadIndicator() {
if (unread != null) {
unread.isVisible = message.isRead(conversation)
}
}
if (bubble != null && time != null) {
bubble.setOnClickListener { time.isVisible = !time.isVisible }
}
private fun prepareSpacer() {
spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
}
if (attachmentContainer != null) {
private fun prepareAttachments() {
if (attachmentContainer != null && textContainer != null) {
if (message.attachments.isNullOrEmpty()) {
attachmentContainer.isVisible = false
attachmentContainer.removeAllViews()
} else {
attachmentContainer.isVisible = true
AttachmentInflater(
context = context,
container = attachmentContainer,
textContainer = textContainer,
message = message,
groups = groups,
profiles = profiles
).inflate()
)
.setPhotoClickListener(photoClickListener)
.inflate()
}
}
}
private fun prepareAttachmentsSpacer() {
attachmentSpacer?.isVisible =
!message.attachments.isNullOrEmpty() && text?.isVisible == true
}
private fun prepareBubbleBackground() {
if (bubble != null) {
val padding =
AndroidUtils.px(if (!message.attachments.isNullOrEmpty()) 4 else 15).roundToInt()
bubble.setPadding(padding)
// TODO: 9/23/2021 use external function
bubble.background =
if (!message.attachments.isNullOrEmpty() && message.attachments!![0] is VkSticker) null
@@ -125,82 +203,29 @@ class MessagesPreparator constructor(
}
}
}
}
// TODO: 9/23/2021 use external function
bubbleStroke?.background =
if (bubble?.background == null) null else {
if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundStrokeOut
else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleStrokeOut
else backgroundStrokeOut
}
private fun prepareText() {
if (bubble != null && text != null) {
if (message.text == null) {
text.isVisible = false
bubble.isVisible = !message.attachments.isNullOrEmpty()
bubbleStroke?.isVisible = bubble.isVisible
} else {
text.isVisible = true
bubble.isVisible = true
bubbleStroke?.isVisible = true
text.text = VkUtils.prepareMessageText(message.text)
text.text = VkUtils.prepareMessageText(message.text ?: "")
}
}
}
private fun prepareAvatar(
messageUser: VkUser? = null,
messageGroup: VkGroup? = null
) {
if (avatar != null) {
val avatarUrl = when {
message.isUser() && messageUser != null && !messageUser.photo200.isNullOrBlank() -> messageUser.photo200
message.isGroup() && messageGroup != null && !messageGroup.photo200.isNullOrBlank() -> messageGroup.photo200
else -> null
}
val avatarUrl = VkUtils.getMessageAvatar(message, messageUser, messageGroup)
avatar.load(avatarUrl) { crossfade(100) }
}
spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
if (message.isPeerChat()) {
val fromDiffSender = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message)
val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message)
val change = (prevMessage?.date ?: 0) - message.date
Log.d(
"Fast::MessagesPreparator",
"text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $fromDiffSender; fiveMinAgo: $fiveMinAgo; "
)
title?.isVisible = fromDiffSender || fiveMinAgo
avatar?.isInvisible = fromDiffSender && fiveMinAgo
} else {
title?.isVisible = false
avatar?.isVisible = false
}
if (title != null) {
val titleString = when {
message.isUser() && messageUser != null -> messageUser.firstName
message.isGroup() && messageGroup != null -> messageGroup.name
else -> null
}
title.text = titleString
title.measure(0, 0)
if (bubble != null) {
if (title.isVisible) {
bubble.minimumWidth = title.measuredWidth + 60
} else {
bubble.minimumWidth = 0
}
}
}
attachmentSpacer?.isVisible =
!message.attachments.isNullOrEmpty() && text?.isVisible == true
time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L)
}
}
@@ -0,0 +1,48 @@
package com.meloda.fast.screens.photos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.fragment.app.viewModels
import com.meloda.fast.base.BaseViewModelFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PhotoViewFragment : BaseViewModelFragment<PhotoViewViewModel>() {
override val viewModel: PhotoViewViewModel by viewModels()
// private val photosList: MutableList<VkPhoto> = mutableListOf()
private var photoLink: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
photoLink = requireArguments().getString("photoLink")
// val list: List<*>? = Gson().fromJson(
// requireArguments().getString("photosList"),
// List::class.java
// )
//
// list?.forEach { if (it is VkPhoto) photosList.add(it) }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ImageView(requireContext())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
photoLink?.let { viewModel.loadImageFromUrl(it, requireView() as ImageView) }
}
}
@@ -0,0 +1,22 @@
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 kotlinx.coroutines.launch
class PhotoViewViewModel : BaseViewModel() {
fun loadImageFromUrl(
url: String,
imageView: ImageView
) = viewModelScope.launch {
imageView.load(url)
}
fun saveImageToLocalStorage(url: String) = viewModelScope.launch {
TODO("Not implemented")
}
}
@@ -1,9 +1,9 @@
package com.meloda.fast.service
import android.util.Log
import com.meloda.fast.api.model.request.MessagesGetLongPollServerRequest
import com.meloda.fast.api.network.datasource.MessagesDataSource
import com.meloda.fast.api.network.repo.LongPollRepo
import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest
import com.meloda.fast.api.network.messages.MessagesDataSource
import com.meloda.fast.api.network.longpoll.LongPollRepo
import kotlinx.coroutines.*
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
@@ -1,59 +0,0 @@
package com.meloda.fast.util
import java.util.stream.Collectors
object ArrayUtils {
@SafeVarargs
fun <T> asString(vararg array: T): String {
if (array.isEmpty()) {
return ""
}
val builder = StringBuilder(array.size * 12)
builder.append(array[0])
for (i in 1 until array.size) {
builder.append(',')
builder.append(array[i])
}
return builder.toString()
}
fun asString(array: IntArray): String {
if (array.isEmpty()) {
return ""
}
val builder = StringBuilder(array.size * 12)
builder.append(array[0])
for (i in 1 until array.size) {
builder.append(',')
builder.append(array[i])
}
return builder.toString()
}
fun <T> asString(arrayList: ArrayList<T>): String {
return ArrayList<String>().apply {
arrayList.forEach { add(it.toString()) }
}.stream().collect(Collectors.joining(","))
}
fun <T> asString(list: List<T>): String = asString(list.asArrayList())
fun <T> cut(arrayList: ArrayList<T>, offset: Int, count: Int): ArrayList<T> {
if (arrayList.isEmpty()) return arrayListOf()
var lastPosition = offset + count
if (lastPosition > arrayList.size) lastPosition = arrayList.size
return ArrayList(arrayList.subList(offset, lastPosition))
}
fun ByteArray?.isNullOrEmpty() = this == null || this.isEmpty()
fun <E> List<E>.asArrayList(): ArrayList<E> {
return ArrayList(this)
}
}
@@ -1,30 +0,0 @@
package com.meloda.fast.util
import android.content.Context
import android.graphics.Color
import androidx.annotation.ColorInt
import com.meloda.fast.R
object ColorUtils {
@ColorInt
fun getColorAccent(context: Context): Int {
return AndroidUtils.getThemeAttrColor(context, R.attr.colorAccent)
}
@ColorInt
fun getColorPrimary(context: Context): Int {
return AndroidUtils.getThemeAttrColor(context, R.attr.colorPrimary)
}
@JvmOverloads
fun darkenColor(color: Int, darkFactor: Float = 0.75f): Int {
var newColor = color
val hsv = FloatArray(3)
Color.colorToHSV(newColor, hsv)
hsv[2] *= darkFactor
newColor = Color.HSVToColor(hsv)
return newColor
}
}
@@ -1,55 +0,0 @@
package com.meloda.fast.util
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.widget.ImageView
object ImageUtils {
fun loadImage(image: String, imageView: ImageView, placeholder: Drawable?) {
if (image.isEmpty()) return
// if (imageView is SimpleDraweeView) {
// imageView.setImageURI(image)
// return
// }
//
// val picasso = Picasso.get()
// .load(image)
// .priority(Picasso.Priority.LOW)
// if (placeholder != null) picasso.placeholder(placeholder)
//
// picasso.into(imageView)
}
fun loadImage(image: String?, listener: OnLoadListener?) {
if (image.isNullOrEmpty()) return
// val picasso = Picasso.get()
// .load(image)
// .priority(Picasso.Priority.LOW)
//
// val target = object : Target {
// override fun onPrepareLoad(placeHolderDrawable: Drawable?) {
//
// }
//
// override fun onBitmapFailed(e: Exception, errorDrawable: Drawable?) {
// listener?.onError(e)
// }
//
// override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
// listener?.onLoad(bitmap)
// }
// }
// picasso.into(target)
}
interface OnLoadListener {
fun onLoad(bitmap: Bitmap)
fun onError(e: Exception)
}
}
@@ -1,15 +0,0 @@
package com.meloda.fast.util
object TextUtils {
fun getFirstLetterFromString(string: String): String {
for (i in string.indices) {
val char = string[i]
if (char.isLetter()) return char.toString()
}
return ""
}
}
@@ -7,6 +7,8 @@ import java.util.*
object TimeUtils {
const val ONE_DAY_IN_SECONDS = 86400
fun removeTime(date: Date): Long {
return Calendar.getInstance().apply {
time = date
@@ -1,49 +0,0 @@
package com.meloda.fast.util
import android.content.Context
import com.meloda.fast.util.ArrayUtils.isNullOrEmpty
import com.meloda.fast.R
import com.meloda.fast.io.BytesOutputStream
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
object Utils {
fun getLocalizedThrowable(context: Context, t: Throwable): String {
return context.getString(R.string.error, t.message.toString())
}
fun serialize(source: Any?): ByteArray? {
try {
val bos = BytesOutputStream()
val out = ObjectOutputStream(bos)
out.writeObject(source)
out.close()
return bos.byteArray
} catch (e: IOException) {
e.printStackTrace()
}
return null
}
fun deserialize(source: ByteArray?): Any? {
if (source.isNullOrEmpty()) {
return null
}
try {
val bis = ByteArrayInputStream(source)
val `in` = ObjectInputStream(bis)
val o = `in`.readObject()
`in`.close()
return o
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
}
@@ -10,12 +10,11 @@ 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.extensions.ContextExtensions.drawable
import com.meloda.fast.extensions.DrawableExtensions.tint
import com.meloda.fast.extensions.FloatExtensions.int
import com.meloda.fast.R
import com.meloda.fast.util.AndroidUtils
import kotlin.math.roundToInt
@Suppress("UNCHECKED_CAST")
class NoItemsView @JvmOverloads constructor(
@@ -44,7 +43,7 @@ class NoItemsView @JvmOverloads constructor(
private fun create() {
val a = context.obtainStyledAttributes(attrs, R.styleable.NoItemsView)
minimumWidth = AndroidUtils.px(256).int()
minimumWidth = AndroidUtils.px(256).roundToInt()
minimumHeight = minimumWidth
orientation = VERTICAL
@@ -53,8 +52,8 @@ class NoItemsView @JvmOverloads constructor(
noItemsPicture = ImageView(context)
val params = imageViewParams
params.height = AndroidUtils.px(64).int()
params.width = AndroidUtils.px(64).int()
params.height = AndroidUtils.px(64).roundToInt()
params.width = AndroidUtils.px(64).roundToInt()
noItemsPicture.layoutParams = params
@@ -73,10 +72,10 @@ class NoItemsView @JvmOverloads constructor(
noItemsTextView = TextView(context)
val textParams = textViewParams
textParams.width = AndroidUtils.px(256).int()
textParams.width = AndroidUtils.px(256).roundToInt()
if (noItemsDrawable != null) {
textParams.topMargin = AndroidUtils.px(8).int()
textParams.topMargin = AndroidUtils.px(8).roundToInt()
}
noItemsTextView.layoutParams = textParams
@@ -103,7 +102,7 @@ class NoItemsView @JvmOverloads constructor(
}
fun setNoItemsImage(@DrawableRes resId: Int) {
setNoItemsImage(context.drawable(resId))
setNoItemsImage(AppCompatResources.getDrawable(context, resId))
}
fun setNoItemsImage(drawable: Drawable?) {
@@ -111,7 +110,7 @@ class NoItemsView @JvmOverloads constructor(
}
fun setNoItemsImageTint(@ColorInt color: Int) {
noItemsPicture.drawable.tint(color)
noItemsPicture.drawable?.setTint(color)
}
fun setNoItemsText(@StringRes resId: Int) {
@@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="30dp"
android:topRightRadius="30dp" />
<solid android:color="@android:color/white" />
</shape>

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