From 7a99347841c7c8e37439113773013263198d9576 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Tue, 30 Aug 2022 19:49:52 +0300 Subject: [PATCH] Lot of global changes (#10) Global update --- app/build.gradle.kts | 154 +-- .../33.json | 582 +++++++++++ .../34.json | 600 +++++++++++ app/src/main/AndroidManifest.xml | 27 +- .../com/meloda/fast/activity/MainActivity.kt | 54 - .../kotlin/com/meloda/fast/api/ApiEvent.kt | 36 +- .../kotlin/com/meloda/fast/api/UserConfig.kt | 43 +- .../kotlin/com/meloda/fast/api/VKConstants.kt | 20 +- .../kotlin/com/meloda/fast/api/VKException.kt | 20 - .../kotlin/com/meloda/fast/api/VkUtils.kt | 150 ++- .../com/meloda/fast/api/base/ApiError.kt | 21 +- .../fast/api/{ => longpoll}/LongPollEvent.kt | 2 +- .../{ => longpoll}/LongPollUpdatesParser.kt | 129 ++- .../meloda/fast/api/model/VkConversation.kt | 14 +- .../com/meloda/fast/api/model/VkMessage.kt | 59 +- .../api/model/attachments/VkAttachment.kt | 7 +- .../fast/api/model/attachments/VkFile.kt | 4 +- .../fast/api/model/attachments/VkVideo.kt | 3 +- .../fast/api/model/attachments/VkWall.kt | 10 +- .../fast/api/model/base/BaseVkConversation.kt | 5 +- .../fast/api/model/base/BaseVkMessage.kt | 9 +- .../api/model/base/attachments/BaseVkFile.kt | 3 +- .../api/model/base/attachments/BaseVkVideo.kt | 3 +- .../api/model/base/attachments/BaseVkWall.kt | 22 +- .../com/meloda/fast/api/network/ApiErrors.kt | 78 ++ .../fast/api/network/AuthInterceptor.kt | 10 +- .../com/meloda/fast/api/network/ErrorCodes.kt | 51 - .../fast/api/network/ResultCallFactory.kt | 94 +- .../api/network/account/AccountDataSource.kt | 15 - .../fast/api/network/audio/AudiosRequests.kt | 2 + .../fast/api/network/audio/AudiosResponses.kt | 20 + .../fast/api/network/audio/AudiosUrls.kt | 11 + .../fast/api/network/auth/AuthDataSource.kt | 13 - .../meloda/fast/api/network/auth/AuthRepo.kt | 16 - .../conversations/ConversationsDataSource.kt | 22 - .../conversations/ConversationsRepo.kt | 31 - .../fast/api/network/files/FileRequests.kt | 2 + .../fast/api/network/files/FilesResponses.kt | 25 + .../fast/api/network/files/FilesUrls.kt | 11 + .../api/network/longpoll/LongPollRequests.kt | 6 +- .../network/messages/MessagesDataSource.kt | 50 - .../api/network/messages/MessagesRequest.kt | 8 +- .../fast/api/network/messages/MessagesUrls.kt | 1 + .../fast/api/network/ota/OtaResponses.kt | 8 + .../meloda/fast/api/network/ota/OtaUrls.kt | 7 + .../fast/api/network/photos/PhotoUrls.kt | 11 + .../fast/api/network/photos/PhotosRequests.kt | 16 + .../api/network/photos/PhotosResponses.kt | 18 + .../fast/api/network/users/UsersDataSource.kt | 16 - .../fast/api/network/videos/VideosRequests.kt | 2 + .../api/network/videos/VideosResponses.kt | 35 + .../fast/api/network/videos/VideosUrls.kt | 9 + .../com/meloda/fast/base/BaseFragment.kt | 35 + .../meloda/fast/base/BaseViewModelFragment.kt | 45 - ...ResourceManager.kt => ResourceProvider.kt} | 8 +- .../meloda/fast/base/adapter/BaseAdapter.kt | 113 ++- .../com/meloda/fast/base/adapter/BaseItem.kt | 3 - .../com/meloda/fast/base/adapter/Holders.kt | 5 +- .../fast/base/viewmodel/BaseViewModel.kt | 97 +- .../base/viewmodel/BaseViewModelFragment.kt | 34 + .../com/meloda/fast/base/viewmodel/Events.kt | 27 +- .../fast/base/viewmodel/ViewModelUtils.kt | 49 + .../com/meloda/fast/common/AppConstants.kt | 7 + .../com/meloda/fast/common/AppGlobal.kt | 22 +- .../com/meloda/fast/common/AppSettings.kt | 2 +- .../kotlin/com/meloda/fast/common/Screens.kt | 41 +- .../com/meloda/fast/common/TimeManager.kt | 98 -- .../com/meloda/fast/common/UpdateManager.kt | 100 ++ .../account/AccountApi.kt} | 11 +- .../meloda/fast/data/account/AccountsDao.kt | 18 + .../fast/data/account/AccountsRepository.kt | 17 + .../com/meloda/fast/data/audios/AudiosApi.kt | 28 + .../fast/data/audios/AudiosRepository.kt | 21 + .../com/meloda/fast/data/auth/AuthApi.kt | 19 + .../meloda/fast/data/auth/AuthRepository.kt | 14 + .../data/conversations/ConversationsApi.kt | 33 + .../conversations}/ConversationsDao.kt | 4 +- .../conversations/ConversationsRepository.kt | 25 + .../com/meloda/fast/data/files/FilesApi.kt | 33 + .../meloda/fast/data/files/FilesRepository.kt | 30 + .../dao => data/groups}/GroupsDao.kt | 2 +- .../fast/data/groups/GroupsRepository.kt | 6 + .../longpoll/LongPollApi.kt} | 8 +- .../messages/MessagesApi.kt} | 31 +- .../dao => data/messages}/MessagesDao.kt | 2 +- .../fast/data/messages/MessagesRepository.kt | 65 ++ .../kotlin/com/meloda/fast/data/ota/OtaApi.kt | 26 + .../com/meloda/fast/data/photos/PhotosApi.kt | 33 + .../fast/data/photos/PhotosRepository.kt | 18 + .../UsersRepo.kt => data/users/UsersApi.kt} | 9 +- .../{database/dao => data/users}/UsersDao.kt | 2 +- .../meloda/fast/data/users/UsersRepository.kt | 17 + .../com/meloda/fast/data/videos/VideosApi.kt | 26 + .../fast/data/videos/VideosRepository.kt | 13 + .../com/meloda/fast/database/AppDatabase.kt | 28 +- .../com/meloda/fast/database/Converters.kt | 31 +- .../kotlin/com/meloda/fast/di/DataModule.kt | 103 ++ .../com/meloda/fast/di/DatabaseModule.kt | 24 +- .../com/meloda/fast/di/NetworkModule.kt | 171 ++-- .../kotlin/com/meloda/fast/extensions/Ext.kt | 104 +- .../com/meloda/fast/extensions/GlideExt.kt | 31 +- .../com/meloda/fast/model/AppAccount.kt | 15 + .../com/meloda/fast/model/ListModels.kt | 2 +- .../com/meloda/fast/model/UpdateItem.kt | 35 + .../fast/receiver/DownloadManagerReceiver.kt | 15 + .../meloda/fast/receiver/MinuteReceiver.kt | 14 - .../conversations/ConversationsAdapter.kt | 200 ++-- .../conversations/ConversationsFragment.kt | 312 ++++-- .../ConversationsResourceManager.kt | 19 - .../ConversationsResourceProvider.kt | 25 + .../conversations/ConversationsViewModel.kt | 103 +- .../fast/screens/login/LoginFragment.kt | 217 ++-- .../fast/screens/login/LoginViewModel.kt | 78 +- .../meloda/fast/screens/main/MainActivity.kt | 350 +++++++ .../meloda/fast/screens/main/MainFragment.kt | 27 +- .../meloda/fast/screens/main/MainViewModel.kt | 55 +- .../screens/messages/AttachmentInflater.kt | 145 ++- .../screens/messages/AttachmentsAdapter.kt | 231 +++++ .../messages/ForwardedMessagesFragment.kt | 98 ++ .../messages/MessagesHistoryAdapter.kt | 172 +++- .../messages/MessagesHistoryFragment.kt | 953 +++++++++++++----- .../messages/MessagesHistoryViewModel.kt | 468 ++++++++- .../screens/messages/MessagesPreparator.kt | 219 ++-- .../fast/screens/photos/PhotoViewFragment.kt | 2 +- .../fast/screens/photos/PhotoViewViewModel.kt | 8 +- .../screens/settings/SettingsPrefsFragment.kt | 109 ++ .../screens/settings/SettingsRootFragment.kt | 39 + .../fast/screens/updates/UpdateState.kt | 5 + .../fast/screens/updates/UpdatesFragment.kt | 362 +++++++ .../updates/UpdatesResourceProvider.kt | 10 + .../fast/screens/updates/UpdatesViewModel.kt | 50 + ...gesUpdateService.kt => LongPollService.kt} | 86 +- .../com/meloda/fast/service/OnlineService.kt | 69 +- .../com/meloda/fast/util/AndroidUtils.kt | 65 +- .../com/meloda/fast/util/KeyboardUtils.kt | 16 - .../meloda/fast/util/NotificationsUtils.kt | 64 ++ .../kotlin/com/meloda/fast/util/TimeUtils.kt | 17 +- .../kotlin/com/meloda/fast/util/ViewUtils.kt | 40 + .../meloda/fast/view/BoundedLinearLayout.kt | 62 ++ .../fast/{widget => view}/CircleImageView.kt | 40 +- .../meloda/fast/view/SpaceItemDecoration.kt | 26 + .../meloda/fast/widget/BoundedFrameLayout.kt | 64 -- .../meloda/fast/widget/BoundedLinearLayout.kt | 61 -- .../com/meloda/fast/widget/NoItemsView.kt | 138 --- .../fast/widget/RoundedCornerLayout.java | 79 -- .../meloda/fast/widget/RoundedFrameLayout.kt | 95 -- .../meloda/fast/widget/ScrollingTextView.kt | 28 - .../com/meloda/fast/widget/WrapTextView.kt | 56 - .../ic_notification_new_message.xml | 15 + .../ic_notification_new_message.png | Bin 0 -> 373 bytes .../ic_notification_new_message.png | Bin 0 -> 243 bytes .../res/drawable-v21/ic_fast_lightning.xml | 124 --- .../res/drawable-v21/ic_message_outline.xml | 10 - .../ic_notification_new_message.png | Bin 0 -> 484 bytes .../ic_notification_new_message.png | Bin 0 -> 711 bytes .../main/res/drawable/ic_close_in_circle.xml | 7 + .../ic_launcher_foreground_splash.xml | 26 + .../ic_message_in_background_middle.xml | 6 +- .../ic_message_out_background_middle.xml | 6 +- .../drawable/ic_message_panel_background.xml | 6 +- app/src/main/res/drawable/ic_online_pc.xml | 2 +- .../drawable/ic_outline_emoji_emotions_24.xml | 18 + .../res/drawable/ic_round_access_time_24.xml | 9 + .../ic_round_add_circle_outline_24.xml | 9 + .../drawable/ic_round_bookmark_border_24.xml | 9 + .../main/res/drawable/ic_round_close_24.xml | 9 + .../res/drawable/ic_round_done_all_24.xml | 9 + .../drawable/ic_round_emoji_emotions_24.xml | 9 + .../drawable/ic_round_error_outline_24.xml | 9 + .../main/res/drawable/ic_round_group_24.xml | 9 + .../ic_round_keyboard_arrow_down_24.xml | 9 + .../main/res/drawable/ic_round_mail_24.xml | 9 + .../res/drawable/ic_round_mic_none_24.xml | 5 + .../res/drawable/ic_round_settings_24.xml | 9 + .../main/res/drawable/ic_round_star_24.xml | 9 + ...ad_indicator_on_attachments_background.xml | 9 + app/src/main/res/layout/activity_main.xml | 43 +- app/src/main/res/layout/dialog_captcha.xml | 17 +- app/src/main/res/layout/dialog_fast_login.xml | 21 + app/src/main/res/layout/dialog_validation.xml | 16 +- app/src/main/res/layout/drawer_header.xml | 29 + .../res/layout/fragment_conversations.xml | 37 +- .../layout/fragment_forwarded_messages.xml | 24 + app/src/main/res/layout/fragment_login.xml | 67 +- .../res/layout/fragment_messages_history.xml | 494 +++++---- .../res/layout/fragment_settings_root.xml | 21 + app/src/main/res/layout/fragment_updates.xml | 95 ++ app/src/main/res/layout/item_conversation.xml | 25 +- .../layout/item_message_attachment_audio.xml | 4 +- .../layout/item_message_attachment_call.xml | 4 +- .../layout/item_message_attachment_file.xml | 4 +- .../item_message_attachment_forwards.xml | 14 + .../layout/item_message_attachment_geo.xml | 54 + .../layout/item_message_attachment_gift.xml | 4 +- .../item_message_attachment_graffiti.xml | 4 +- .../layout/item_message_attachment_link.xml | 6 +- .../layout/item_message_attachment_photo.xml | 2 +- .../layout/item_message_attachment_reply.xml | 49 + .../layout/item_message_attachment_video.xml | 2 +- .../layout/item_message_attachment_voice.xml | 4 +- .../item_message_attachment_wall_post.xml | 6 +- app/src/main/res/layout/item_message_in.xml | 182 ++-- app/src/main/res/layout/item_message_out.xml | 148 ++- .../layout/item_uploaded_attachment_audio.xml | 55 + .../layout/item_uploaded_attachment_file.xml | 73 ++ .../layout/item_uploaded_attachment_photo.xml | 36 + .../layout/item_uploaded_attachment_video.xml | 73 ++ .../res/layout/toolbar_menu_item_avatar.xml | 21 + .../main/res/menu/activity_main_bottom.xml | 12 +- .../main/res/menu/activity_main_drawer.xml | 29 + .../main/res/menu/fragment_conversations.xml | 11 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../mipmap-anydpi-v26/ic_launcher_monet.xml | 6 + .../ic_launcher_monet_splash.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 1 + app/src/main/res/values-v27/themes.xml | 12 + app/src/main/res/values-v31/monet_colors.xml | 51 + .../res/values/ic_launcher_background.xml | 2 +- app/src/main/res/values/strings.xml | 45 +- app/src/main/res/values/styles.xml | 12 - app/src/main/res/values/themes.xml | 19 +- app/src/main/res/xml/preferences.xml | 79 ++ app/src/main/res/xml/provider_paths.xml | 5 +- build.gradle.kts | 4 +- gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 59821 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 286 ++++-- gradlew.bat | 43 +- ota_alpha.json | 12 + settings.gradle.kts | 116 ++- 230 files changed, 9172 insertions(+), 3157 deletions(-) create mode 100644 app/schemas/com.meloda.fast.database.AppDatabase/33.json create mode 100644 app/schemas/com.meloda.fast.database.AppDatabase/34.json delete mode 100644 app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/VKException.kt rename app/src/main/kotlin/com/meloda/fast/api/{ => longpoll}/LongPollEvent.kt (94%) rename app/src/main/kotlin/com/meloda/fast/api/{ => longpoll}/LongPollUpdatesParser.kt (62%) create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt rename app/src/main/kotlin/com/meloda/fast/base/{ResourceManager.kt => ResourceProvider.kt} (62%) delete mode 100644 app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt rename app/src/main/kotlin/com/meloda/fast/{api/network/account/AccountRepo.kt => data/account/AccountApi.kt} (59%) create mode 100644 app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt rename app/src/main/kotlin/com/meloda/fast/{database/dao => data/conversations}/ConversationsDao.kt (76%) create mode 100644 app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt rename app/src/main/kotlin/com/meloda/fast/{database/dao => data/groups}/GroupsDao.kt (93%) create mode 100644 app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt rename app/src/main/kotlin/com/meloda/fast/{api/network/longpoll/LongPollRepo.kt => data/longpoll/LongPollApi.kt} (63%) rename app/src/main/kotlin/com/meloda/fast/{api/network/messages/MessagesRepo.kt => data/messages/MessagesApi.kt} (50%) rename app/src/main/kotlin/com/meloda/fast/{database/dao => data/messages}/MessagesDao.kt (94%) create mode 100644 app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt rename app/src/main/kotlin/com/meloda/fast/{api/network/users/UsersRepo.kt => data/users/UsersApi.kt} (61%) rename app/src/main/kotlin/com/meloda/fast/{database/dao => data/users}/UsersDao.kt (93%) create mode 100644 app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/di/DataModule.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt rename app/src/main/kotlin/com/meloda/fast/service/{MessagesUpdateService.kt => LongPollService.kt} (62%) delete mode 100644 app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt create mode 100644 app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt rename app/src/main/kotlin/com/meloda/fast/{widget => view}/CircleImageView.kt (56%) create mode 100644 app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt delete mode 100644 app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_notification_new_message.png create mode 100644 app/src/main/res/drawable-mdpi/ic_notification_new_message.png delete mode 100644 app/src/main/res/drawable-v21/ic_fast_lightning.xml delete mode 100644 app/src/main/res/drawable-v21/ic_message_outline.xml create mode 100644 app/src/main/res/drawable-xhdpi/ic_notification_new_message.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png create mode 100644 app/src/main/res/drawable/ic_close_in_circle.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_splash.xml create mode 100644 app/src/main/res/drawable/ic_outline_emoji_emotions_24.xml create mode 100644 app/src/main/res/drawable/ic_round_access_time_24.xml create mode 100644 app/src/main/res/drawable/ic_round_add_circle_outline_24.xml create mode 100644 app/src/main/res/drawable/ic_round_bookmark_border_24.xml create mode 100644 app/src/main/res/drawable/ic_round_close_24.xml create mode 100644 app/src/main/res/drawable/ic_round_done_all_24.xml create mode 100644 app/src/main/res/drawable/ic_round_emoji_emotions_24.xml create mode 100644 app/src/main/res/drawable/ic_round_error_outline_24.xml create mode 100644 app/src/main/res/drawable/ic_round_group_24.xml create mode 100644 app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml create mode 100644 app/src/main/res/drawable/ic_round_mail_24.xml create mode 100644 app/src/main/res/drawable/ic_round_mic_none_24.xml create mode 100644 app/src/main/res/drawable/ic_round_settings_24.xml create mode 100644 app/src/main/res/drawable/ic_round_star_24.xml create mode 100644 app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml create mode 100644 app/src/main/res/layout/dialog_fast_login.xml create mode 100644 app/src/main/res/layout/drawer_header.xml create mode 100644 app/src/main/res/layout/fragment_forwarded_messages.xml create mode 100644 app/src/main/res/layout/fragment_settings_root.xml create mode 100644 app/src/main/res/layout/fragment_updates.xml create mode 100644 app/src/main/res/layout/item_message_attachment_forwards.xml create mode 100644 app/src/main/res/layout/item_message_attachment_geo.xml create mode 100644 app/src/main/res/layout/item_message_attachment_reply.xml create mode 100644 app/src/main/res/layout/item_uploaded_attachment_audio.xml create mode 100644 app/src/main/res/layout/item_uploaded_attachment_file.xml create mode 100644 app/src/main/res/layout/item_uploaded_attachment_photo.xml create mode 100644 app/src/main/res/layout/item_uploaded_attachment_video.xml create mode 100644 app/src/main/res/layout/toolbar_menu_item_avatar.xml create mode 100644 app/src/main/res/menu/activity_main_drawer.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml create mode 100644 app/src/main/res/values-v27/themes.xml create mode 100644 app/src/main/res/values-v31/monet_colors.xml create mode 100644 app/src/main/res/xml/preferences.xml create mode 100644 ota_alpha.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf381ee8..f3fa953a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,12 +1,17 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -val login: String = gradleLocalProperties(rootDir).getProperty("vkLogin") -val password: String = gradleLocalProperties(rootDir).getProperty("vkPassword") +import com.android.build.gradle.internal.api.BaseVariantOutputImpl val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage") val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint") +val msAppCenterToken: String = + gradleLocalProperties(rootDir).getProperty("msAppCenterAppToken", null) +val otaSecretCode: String = gradleLocalProperties(rootDir).getProperty("otaSecretCode") + +val majorVersion = 1 +val minorVersion = 6 +val patchVersion = 4 + plugins { id("com.android.application") id("kotlin-android") @@ -16,15 +21,23 @@ plugins { } android { - compileSdk = 31 - buildToolsVersion = "31.0.0" + namespace = "com.meloda.fast" + + compileSdk = 32 + + applicationVariants.all { + outputs.all { + (this as BaseVariantOutputImpl).outputFileName = + "${name}-${versionName}-${versionCode}.apk" + } + } defaultConfig { applicationId = "com.meloda.fast" minSdk = 23 - targetSdk = 30 + targetSdk = 32 versionCode = 1 - versionName = "1.0" + versionName = "alpha" javaCompileOptions { annotationProcessorOptions { @@ -35,24 +48,27 @@ android { buildTypes { getByName("debug") { - buildConfigField("String", "vkLogin", login) - buildConfigField("String", "vkPassword", password) - buildConfigField("String", "sdkPackage", sdkPackage) buildConfigField("String", "sdkFingerprint", sdkFingerprint) + + buildConfigField("String", "msAppCenterAppToken", msAppCenterToken) + + buildConfigField("String", "otaSecretCode", otaSecretCode) + + versionNameSuffix = "_${getVersionName()}" } getByName("release") { isMinifyEnabled = false - buildConfigField("String", "vkLogin", login) - buildConfigField("String", "vkPassword", password) - buildConfigField("String", "sdkPackage", sdkPackage) buildConfigField("String", "sdkFingerprint", sdkFingerprint) + buildConfigField("String", "msAppCenterAppToken", msAppCenterToken) + + buildConfigField("String", "otaSecretCode", otaSecretCode) + proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } @@ -62,74 +78,88 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } + kotlinOptions { + freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn") + } + buildFeatures { viewBinding = true } } -kapt { - correctErrorTypes = true +fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion" - //use this shit if you don't want have hilt errors - javacOptions { - option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true") - } -} +val currentTime get() = (System.currentTimeMillis() / 1000).toInt() dependencies { - // Cicerone - Navigation - implementation("com.github.terrakok:cicerone:7.1") + implementation(kotlin("reflect", "1.6.10")) - implementation("androidx.constraintlayout:constraintlayout:2.1.3") + implementation(libs.androidx.core) - implementation("com.github.massoudss:waveformSeekBar:3.1.0") + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.lifecycle.common.java8) - implementation("androidx.core:core-splashscreen:1.0.0-beta02") + implementation(libs.androidx.splashScreen) - implementation("androidx.work:work-runtime-ktx:2.7.1") + implementation(libs.androidx.dataStore) - implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation(libs.androidx.appCompat) - implementation("androidx.paging:paging-runtime-ktx:3.1.1") + implementation(libs.androidx.activity) - implementation("androidx.appcompat:appcompat:1.4.1") - implementation("com.google.android.material:material:1.6.0-beta01") - implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01") - implementation("androidx.recyclerview:recyclerview:1.2.1") - implementation("androidx.cardview:cardview:1.0.0") - implementation("androidx.fragment:fragment-ktx:1.4.1") + implementation(libs.androidx.fragment) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") + implementation(libs.androidx.preference) - implementation("androidx.room:room-ktx:2.4.2") - implementation("androidx.room:room-runtime:2.4.2") - kapt("androidx.room:room-compiler:2.4.2") + implementation(libs.androidx.swipeRefreshLayout) - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.1") - implementation("androidx.lifecycle:lifecycle-common-java8:2.4.1") + implementation(libs.androidx.recyclerView) - implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.2") - implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2") - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation(libs.androidx.cardView) - implementation("com.google.dagger:hilt-android:2.39.1") - kapt("com.google.dagger:hilt-android-compiler:2.39.1") + implementation(libs.androidx.constraintLayout) - implementation("com.github.yogacp:android-viewbinding:1.0.4") + implementation(libs.androidx.room) + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) - implementation("io.coil-kt:coil:1.4.0") + implementation(libs.cicerone) - implementation("com.google.code.gson:gson:2.8.8") - implementation("org.jsoup:jsoup:1.14.3") - implementation("ch.acra:acra:4.11.1") + implementation(libs.waveformSeekBar) - implementation("com.github.bumptech.glide:glide:4.13.0") - kapt("com.github.bumptech.glide:compiler:4.13.0") + implementation(libs.glide) + kapt(libs.glide.compiler) + + implementation(libs.kPermissions) + implementation(libs.kPermissions.coroutines) + + implementation(libs.appCenter.analytics) + implementation(libs.appCenter.crashes) + + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + implementation(libs.retrofit) + implementation(libs.retrofit.gson.converter) + + implementation(libs.okhttp3) + implementation(libs.okhttp3.interceptor) + + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + implementation(libs.viewBindingDelegate) + + implementation(libs.google.gson) + + implementation(libs.google.guava) + + implementation(libs.google.material) + + implementation(libs.jsoup) + + implementation(libs.chucker) } \ No newline at end of file diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/33.json b/app/schemas/com.meloda.fast.database.AppDatabase/33.json new file mode 100644 index 00000000..135b8b4d --- /dev/null +++ b/app/schemas/com.meloda.fast.database.AppDatabase/33.json @@ -0,0 +1,582 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "ab075cc511743c47de441d484159b088", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fastToken", + "columnName": "fastToken", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callInProgress", + "columnName": "callInProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPhantom", + "columnName": "isPhantom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConversationMessageId", + "columnName": "lastConversationMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inRead", + "columnName": "inRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outRead", + "columnName": "outRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedUnread", + "columnName": "isMarkedUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "canChangePin", + "columnName": "canChangePin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "majorId", + "columnName": "majorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minorId", + "columnName": "minorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedMessage.id", + "columnName": "pinnedMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.text", + "columnName": "pinnedMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.isOut", + "columnName": "pinnedMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.peerId", + "columnName": "pinnedMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.fromId", + "columnName": "pinnedMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.date", + "columnName": "pinnedMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.randomId", + "columnName": "pinnedMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.action", + "columnName": "pinnedMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMemberId", + "columnName": "pinnedMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionText", + "columnName": "pinnedMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionConversationMessageId", + "columnName": "pinnedMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMessage", + "columnName": "pinnedMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.important", + "columnName": "pinnedMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.forwards", + "columnName": "pinnedMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.attachments", + "columnName": "pinnedMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.replyMessage", + "columnName": "pinnedMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.geo", + "columnName": "pinnedMessage_geo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.id", + "columnName": "lastMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.text", + "columnName": "lastMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.isOut", + "columnName": "lastMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.peerId", + "columnName": "lastMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.fromId", + "columnName": "lastMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.date", + "columnName": "lastMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.randomId", + "columnName": "lastMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.action", + "columnName": "lastMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMemberId", + "columnName": "lastMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionText", + "columnName": "lastMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionConversationMessageId", + "columnName": "lastMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMessage", + "columnName": "lastMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.important", + "columnName": "lastMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.forwards", + "columnName": "lastMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.attachments", + "columnName": "lastMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.replyMessage", + "columnName": "lastMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.geo", + "columnName": "lastMessage_geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isOut", + "columnName": "isOut", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromId", + "columnName": "fromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionMemberId", + "columnName": "actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionText", + "columnName": "actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionConversationMessageId", + "columnName": "actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionMessage", + "columnName": "actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "important", + "columnName": "important", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwards", + "columnName": "forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyMessage", + "columnName": "replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "geo", + "columnName": "geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "online", + "columnName": "online", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeenStatus", + "columnName": "lastSeenStatus", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "screenName", + "columnName": "screenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab075cc511743c47de441d484159b088')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/34.json b/app/schemas/com.meloda.fast.database.AppDatabase/34.json new file mode 100644 index 00000000..52e135b7 --- /dev/null +++ b/app/schemas/com.meloda.fast.database.AppDatabase/34.json @@ -0,0 +1,600 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "2c202b1fce1b5f6c6ab0da756e0590a6", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fastToken", + "columnName": "fastToken", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_updateTime` INTEGER, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_updateTime` INTEGER, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callInProgress", + "columnName": "callInProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPhantom", + "columnName": "isPhantom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConversationMessageId", + "columnName": "lastConversationMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inRead", + "columnName": "inRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outRead", + "columnName": "outRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedUnread", + "columnName": "isMarkedUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "canChangePin", + "columnName": "canChangePin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "majorId", + "columnName": "majorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minorId", + "columnName": "minorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedMessage.id", + "columnName": "pinnedMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.text", + "columnName": "pinnedMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.isOut", + "columnName": "pinnedMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.peerId", + "columnName": "pinnedMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.fromId", + "columnName": "pinnedMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.date", + "columnName": "pinnedMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.randomId", + "columnName": "pinnedMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.action", + "columnName": "pinnedMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMemberId", + "columnName": "pinnedMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionText", + "columnName": "pinnedMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionConversationMessageId", + "columnName": "pinnedMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.actionMessage", + "columnName": "pinnedMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.updateTime", + "columnName": "pinnedMessage_updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.important", + "columnName": "pinnedMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.forwards", + "columnName": "pinnedMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.attachments", + "columnName": "pinnedMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.replyMessage", + "columnName": "pinnedMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinnedMessage.geo", + "columnName": "pinnedMessage_geo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.id", + "columnName": "lastMessage_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.text", + "columnName": "lastMessage_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.isOut", + "columnName": "lastMessage_isOut", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.peerId", + "columnName": "lastMessage_peerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.fromId", + "columnName": "lastMessage_fromId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.date", + "columnName": "lastMessage_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.randomId", + "columnName": "lastMessage_randomId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.action", + "columnName": "lastMessage_action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMemberId", + "columnName": "lastMessage_actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionText", + "columnName": "lastMessage_actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionConversationMessageId", + "columnName": "lastMessage_actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.actionMessage", + "columnName": "lastMessage_actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.updateTime", + "columnName": "lastMessage_updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.important", + "columnName": "lastMessage_important", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastMessage.forwards", + "columnName": "lastMessage_forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.attachments", + "columnName": "lastMessage_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.replyMessage", + "columnName": "lastMessage_replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastMessage.geo", + "columnName": "lastMessage_geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isOut", + "columnName": "isOut", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromId", + "columnName": "fromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionMemberId", + "columnName": "actionMemberId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionText", + "columnName": "actionText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionConversationMessageId", + "columnName": "actionConversationMessageId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionMessage", + "columnName": "actionMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updateTime", + "columnName": "updateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "important", + "columnName": "important", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwards", + "columnName": "forwards", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyMessage", + "columnName": "replyMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "geo", + "columnName": "geo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "online", + "columnName": "online", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeenStatus", + "columnName": "lastSeenStatus", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "screenName", + "columnName": "screenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photo200", + "columnName": "photo200", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2c202b1fce1b5f6c6ab0da756e0590a6')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c427ab88..a388ef3c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,18 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + android:usesCleartextTraffic="true" + tools:replace="android:allowBackup" + tools:ignore="DataExtractionRules"> + @@ -29,7 +41,12 @@ + + diff --git a/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt deleted file mode 100644 index 86bf5af8..00000000 --- a/app/src/main/kotlin/com/meloda/fast/activity/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.meloda.fast.activity - -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import com.github.terrakok.cicerone.NavigatorHolder -import com.github.terrakok.cicerone.Router -import com.github.terrakok.cicerone.androidx.AppNavigator -import com.github.terrakok.cicerone.androidx.FragmentScreen -import com.meloda.fast.R -import com.meloda.fast.base.BaseActivity -import com.meloda.fast.common.Screens -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class MainActivity : BaseActivity(R.layout.activity_main) { - - private val navigator = object : AppNavigator(this, R.id.root_fragment_container) { - override fun setupFragmentTransaction( - screen: FragmentScreen, - fragmentTransaction: FragmentTransaction, - currentFragment: Fragment?, - nextFragment: Fragment - ) { -// fragmentTransaction.setCustomAnimations( -// R.anim.activity_open_enter, R.anim.activity_close_exit, -// R.anim.activity_close_enter, R.anim.activity_open_exit -// ) - } - } - - @Inject - lateinit var navigatorHolder: NavigatorHolder - - @Inject - lateinit var router: Router - - override fun onResumeFragments() { - navigatorHolder.setNavigator(navigator) - super.onResumeFragments() - } - - override fun onPause() { - navigatorHolder.removeNavigator() - super.onPause() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - router.newRootScreen(Screens.Main()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt index 06d942e6..cee1e1b5 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt @@ -1,24 +1,24 @@ package com.meloda.fast.api enum class ApiEvent(val value: Int) { - MESSAGE_SET_FLAGS(2), - MESSAGE_CLEAR_FLAGS(3), - MESSAGE_NEW(4), - MESSAGE_EDIT(5), - MESSAGE_READ_INCOMING(6), - MESSAGE_READ_OUTGOING(7), - FRIEND_ONLINE(8), - FRIEND_OFFLINE(9), - MESSAGES_DELETED(13), - PIN_UNPIN_CONVERSATION(20), - PRIVATE_TYPING(61), - CHAT_TYPING(62), - ONE_MORE_TYPING(63), - VOICE_RECORDING(64), - PHOTO_UPLOADING(65), - VIDEO_UPLOADING(66), - FILE_UPLOADING(67), - UNREAD_COUNT_UPDATE(80) + MessageSetFlags(2), + MessageClearFlags(3), + MessageNew(4), + MessageEdit(5), + MessageReadIncoming(6), + MessageReadOutgoing(7), + FriendOnline(8), + FriendOffline(9), + MessagesDeleted(13), + PinUnpinConversation(20), + PrivateTyping(61), + ChatTyping(62), + OneMoreTyping(63), + VoiceRecording(64), + PhotoUploading(65), + VideoUploading(66), + FileUploading(67), + UnreadCountUpdate(80) ; companion object { diff --git a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt index 402862de..d693636a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt @@ -1,45 +1,46 @@ package com.meloda.fast.api +import androidx.core.content.edit import androidx.lifecycle.MutableLiveData import com.meloda.fast.api.model.VkUser import com.meloda.fast.common.AppGlobal +import com.meloda.fast.model.AppAccount object UserConfig { - private const val FAST_TOKEN = "fast_token" - private const val TOKEN = "token" - private const val USER_ID = "user_id" + private const val ARG_CURRENT_USER_ID = "current_user_id" const val FAST_APP_ID = "6964679" + private val preferences get() = AppGlobal.preferences + + var currentUserId: Int = -1 + get() = preferences.getInt(ARG_CURRENT_USER_ID, -1) + set(value) { + field = value + preferences.edit { putInt(ARG_CURRENT_USER_ID, value) } + } + var userId: Int = -1 - get() = AppGlobal.preferences.getInt(USER_ID, -1) - set(value) { - field = value - AppGlobal.preferences.edit().putInt(USER_ID, value).apply() - } - var accessToken: String = "" - get() = AppGlobal.preferences.getString(TOKEN, "") ?: "" - set(value) { - field = value - AppGlobal.preferences.edit().putString(TOKEN, value).apply() - } + var fastToken: String? = "" - var fastToken: String = "" - get() = AppGlobal.preferences.getString(FAST_TOKEN, "") ?: "" - set(value) { - field = value - AppGlobal.preferences.edit().putString(FAST_TOKEN, value).apply() - } + fun parse(account: AppAccount) { + this.userId = account.userId + this.accessToken = account.accessToken + this.fastToken = account.fastToken + } fun clear() { + currentUserId = -1 accessToken = "" fastToken = "" userId = -1 } - fun isLoggedIn() = userId > 0 && accessToken.isNotBlank() + fun isLoggedIn(): Boolean { + return currentUserId > 0 && userId > 0 && accessToken.isNotBlank() + } val vkUser = MutableLiveData(null) diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt index 325148f8..3f8dd237 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt @@ -12,7 +12,7 @@ object VKConstants { const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS" - const val API_VERSION = "5.132" + const val API_VERSION = "5.189" const val LP_VERSION = 10 const val VK_APP_ID = "2274003" @@ -53,22 +53,4 @@ object VKConstants { VkVoiceMessage::class.java, VkWidget::class.java ) - - val separatedFromTextAttachments = listOf>( - VkPhoto::class.java, - VkVideo::class.java, - VkSticker::class.java, - VkStory::class.java, - VkWidget::class.java, - VkGroupCall::class.java, - VkGroupCall::class.java, - VkCurator::class.java, - VkEvent::class.java, - VkGift::class.java, - VkGraffiti::class.java, - VkPoll::class.java, - VkWall::class.java, - VkWallReply::class.java, - VkLink::class.java - ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKException.kt b/app/src/main/kotlin/com/meloda/fast/api/VKException.kt deleted file mode 100644 index 353c8520..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/VKException.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.meloda.fast.api - -import org.json.JSONObject -import java.io.IOException - -open class VKException( - var url: String = "", - var code: Int = -1, - var description: String = "", - var error: String -) : IOException(description) { - - // TODO: 10-Oct-21 remove this - var json: JSONObject? = null - - override fun toString(): String { - return "error: $error; description: $description;" - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt index e357eb5d..0beda05a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt @@ -6,7 +6,9 @@ import android.graphics.drawable.Drawable import android.text.SpannableString import android.text.style.StyleSpan import androidx.core.content.ContextCompat +import com.google.gson.Gson import com.meloda.fast.R +import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage @@ -14,7 +16,10 @@ import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem +import com.meloda.fast.api.network.* +import com.meloda.fast.extensions.orDots +@Suppress("MemberVisibilityCanBePrivate") object VkUtils { fun attachmentToString( @@ -44,12 +49,12 @@ object VkUtils { fun getMessageUser(message: VkMessage, profiles: Map): VkUser? { return (if (!message.isUser()) null - else profiles[message.fromId]).also { message.user.value = it } + else profiles[message.fromId]).also { message.user = it } } fun getMessageGroup(message: VkMessage, groups: Map): VkGroup? { return (if (!message.isGroup()) null - else groups[message.fromId]).also { message.group.value = it } + else groups[message.fromId]).also { message.group = it } } fun getMessageAvatar( @@ -66,9 +71,19 @@ object VkUtils { fun getMessageTitle( message: VkMessage, - messageUser: VkUser?, - messageGroup: VkGroup? + defMessageUser: VkUser? = null, + defMessageGroup: VkGroup? = null, + profiles: Map? = null, + groups: Map? = null ): String? { + val messageUser: VkUser? = + defMessageUser ?: if (profiles == null) null + else profiles[message.fromId] + + val messageGroup: VkGroup? = + defMessageGroup ?: if (groups == null) null + else groups[message.fromId] + return when { message.isUser() -> messageUser?.fullName message.isGroup() -> messageGroup?.name @@ -78,12 +93,12 @@ object VkUtils { fun getConversationUser(conversation: VkConversation, profiles: Map): VkUser? { return (if (!conversation.isUser()) null - else profiles[conversation.id]).also { conversation.user.value = it } + else profiles[conversation.id]).also { conversation.user.postValue(it) } } fun getConversationGroup(conversation: VkConversation, groups: Map): VkGroup? { return (if (!conversation.isGroup()) null - else groups[conversation.id]).also { conversation.group.value = it } + else groups[conversation.id]).also { conversation.group.postValue(it) } } fun getConversationAvatar( @@ -92,7 +107,7 @@ object VkUtils { conversationGroup: VkGroup? ): String? { return when { - conversation.ownerId == VKConstants.FAST_GROUP_ID -> null + conversation.isAccount() -> null conversation.isUser() -> conversationUser?.photo200 conversation.isGroup() -> conversationGroup?.photo200 conversation.isChat() -> conversation.photo200 @@ -100,6 +115,53 @@ object VkUtils { } } + fun getConversationTitle( + context: Context, + conversation: VkConversation, + defConversationUser: VkUser? = null, + defConversationGroup: VkGroup? = null, + profiles: Map? = null, + groups: Map? = null + ): String? { + val conversationUser: VkUser? = + defConversationUser ?: if (profiles == null) null + else getConversationUser(conversation, profiles) + + val conversationGroup: VkGroup? = + defConversationGroup ?: if (groups == null) null + else getConversationGroup(conversation, groups) + + return when { + conversation.isAccount() -> context.getString(R.string.favorites) + conversation.isChat() -> conversation.title + conversation.isUser() -> conversationUser?.fullName + conversation.isGroup() -> conversationGroup?.name + else -> null + } + } + + fun getConversationUserGroup( + conversation: VkConversation, + profiles: Map, + groups: Map + ): Pair { + val user: VkUser? = getConversationUser(conversation, profiles) + val group: VkGroup? = getConversationGroup(conversation, groups) + + return user to group + } + + fun getMessageUserGroup( + message: VkMessage, + profiles: Map, + groups: Map + ): Pair { + val user: VkUser? = getMessageUser(message, profiles) + val group: VkGroup? = getMessageGroup(message, groups) + + return user to group + } + fun prepareMessageText(text: String, forConversations: Boolean? = null): String { return text.apply { if (forConversations == true) replace("\n", "") @@ -231,6 +293,7 @@ object VkUtils { messageUser: VkUser? = null, messageGroup: VkGroup? = null ): SpannableString? { + @Suppress("REDUNDANT_ELSE_IN_WHEN") return when (message.getPreparedAction()) { VkMessage.Action.CHAT_CREATE -> { val text = message.actionText ?: return null @@ -245,12 +308,14 @@ object VkUtils { val spanText = context.getString(R.string.message_action_chat_created, prefix, text) + val startIndex = spanText.indexOf(text, startIndex = prefix.length) + SpannableString(spanText).also { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) it.setSpan( StyleSpan(Typeface.BOLD), - spanText.indexOf(text, startIndex = prefix.length), - text.length, 0 + startIndex, + startIndex + text.length, 0 ) } } @@ -329,7 +394,7 @@ object VkUtils { } else { val prefix = if (message.fromId == UserConfig.userId) youPrefix - else messageUser?.toString() ?: messageGroup?.toString() ?: "..." + else messageUser?.toString() ?: messageGroup?.toString().orDots() val postfix = if (memberId == UserConfig.userId) youPrefix.lowercase() @@ -374,7 +439,7 @@ object VkUtils { } } else { val prefix = if (message.fromId == UserConfig.userId) youPrefix - else messageUser?.toString() ?: messageGroup?.toString() ?: "..." + else messageUser?.toString() ?: messageGroup?.toString().orDots() val postfix = if (memberId == UserConfig.userId) youPrefix.lowercase() @@ -410,6 +475,20 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + val spanText = + context.getString(R.string.message_action_chat_user_joined_by_call, prefix) + + SpannableString(spanText).also { + it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) + } + } VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -520,8 +599,8 @@ object VkUtils { } fun getAttachmentText(context: Context, message: VkMessage): String? { - message.geoType?.let { - return when (it) { + message.geo?.let { + return when (it.type) { "point" -> context.getString(R.string.message_geo_point) else -> context.getString(R.string.message_geo) } @@ -551,14 +630,14 @@ object VkUtils { } fun getAttachmentConversationIcon(context: Context, message: VkMessage): Drawable? { - message.geoType?.let { - return ContextCompat.getDrawable(context, R.drawable.ic_map_marker) - } - - if (message.attachments.isNullOrEmpty()) return null - return message.attachments?.let { attachments -> if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) { + message.geo?.let { + return ContextCompat.getDrawable(context, R.drawable.ic_map_marker) + } + + if (attachments.isEmpty()) return null + getAttachmentTypeByClass(attachments[0])?.let { getAttachmentIconByType( context, @@ -683,4 +762,37 @@ object VkUtils { else -> attachmentType.value } } + + fun getApiError(gson: Gson, errorString: String?): ApiAnswer.Error { + try { + val defaultError = gson.fromJson(errorString, ApiError::class.java) + + val error: ApiError = + when (defaultError.error) { + VkErrorCodes.UserAuthorizationFailed.toString() -> { + val authorizationError = + gson.fromJson(errorString, AuthorizationError::class.java) + + authorizationError + } + VkErrors.NeedValidation -> { + val validationError = + gson.fromJson(errorString, ValidationRequiredError::class.java) + + validationError + } + VkErrors.NeedCaptcha -> { + val captchaRequiredError = + gson.fromJson(errorString, CaptchaRequiredError::class.java) + + captchaRequiredError + } + else -> defaultError + } + + return ApiAnswer.Error(error) + } catch (e: Exception) { + return ApiAnswer.Error(ApiError(throwable = e)) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt index 5161ae6d..677a22bc 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt @@ -1,11 +1,18 @@ package com.meloda.fast.api.base +import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import com.meloda.fast.api.VKException +import okio.IOException -data class ApiError( - @SerializedName("error_code") - val errorCode: Int, - @SerializedName("error_msg") - override var message: String -) : VKException(error = message, code = errorCode) +open class ApiError( + @SerializedName("error", alternate = ["error_code"]) + val error: String? = null, + @SerializedName("error_msg", alternate = ["error_description"]) + open val errorMessage: String? = null, + val throwable: Throwable? = null +) : IOException() { + + override fun toString(): String { + return Gson().toJson(this) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt similarity index 94% rename from app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt rename to app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt index b10257c0..2d117e92 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/LongPollEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.api +package com.meloda.fast.api.longpoll import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage diff --git a/app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt rename to app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt index 8889e4d8..f419b3de 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/LongPollUpdatesParser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt @@ -1,31 +1,27 @@ -package com.meloda.fast.api +package com.meloda.fast.api.longpoll import android.util.Log import com.google.gson.JsonArray +import com.meloda.fast.api.ApiEvent +import com.meloda.fast.api.VKConstants import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.network.Answer -import com.meloda.fast.api.network.messages.MessagesDataSource +import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.messages.MessagesGetByIdRequest import com.meloda.fast.base.viewmodel.VkEventCallback +import com.meloda.fast.data.messages.MessagesRepository import kotlinx.coroutines.* import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -@Suppress("UNCHECKED_CAST") -class LongPollUpdatesParser( - private val messagesDataSource: MessagesDataSource -) : CoroutineScope { - - companion object { - private const val TAG = "LongPollUpdatesParser" - } +@Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") +class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) : CoroutineScope { private val job = SupervisorJob() private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.d(TAG, "error: $throwable") + Log.d("LongPollUpdatesParser", "error: $throwable") throwable.printStackTrace() } @@ -36,51 +32,51 @@ class LongPollUpdatesParser( mutableMapOf() fun parseNextUpdate(event: JsonArray) { - val eventType: ApiEvent? = - try { - ApiEvent.parse(event[0].asInt) - } catch (e: Exception) { - null - } + val eventId = event[0].asInt + val eventType: ApiEvent? = ApiEvent.parse(eventId) - if (eventType != null) { - println("$TAG: $eventType: $event") - } else { - println("$TAG: unknown event: $event") + if (eventType == null) { + Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") + return } when (eventType) { - ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) - ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) - ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) - ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event) - ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event) - ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event) - ApiEvent.FRIEND_ONLINE -> parseFriendOnline(eventType, event) - ApiEvent.FRIEND_OFFLINE -> parseFriendOffline(eventType, event) - ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event) -// ApiEvent.PIN_UNPIN_CONVERSATION -> TODO() -// ApiEvent.TYPING -> TODO() -// ApiEvent.VOICE_RECORDING -> TODO() -// ApiEvent.PHOTO_UPLOADING -> TODO() -// ApiEvent.VIDEO_UPLOADING -> TODO() -// ApiEvent.FILE_UPLOADING -> TODO() -// ApiEvent.UNREAD_COUNT_UPDATE -> TODO() + ApiEvent.MessageSetFlags -> parseMessageSetFlags(eventType, event) + ApiEvent.MessageClearFlags -> parseMessageClearFlags(eventType, event) + ApiEvent.MessageNew -> parseMessageNew(eventType, event) + ApiEvent.MessageEdit -> parseMessageEdit(eventType, event) + ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event) + ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event) + ApiEvent.FriendOnline -> parseFriendOnline(eventType, event) + ApiEvent.FriendOffline -> parseFriendOffline(eventType, event) + ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event) + ApiEvent.PinUnpinConversation -> onNewEvent(eventType, event) + ApiEvent.PrivateTyping -> onNewEvent(eventType, event) + ApiEvent.ChatTyping -> onNewEvent(eventType, event) + ApiEvent.OneMoreTyping -> onNewEvent(eventType, event) + ApiEvent.VoiceRecording -> onNewEvent(eventType, event) + ApiEvent.PhotoUploading -> onNewEvent(eventType, event) + ApiEvent.VideoUploading -> onNewEvent(eventType, event) + ApiEvent.FileUploading -> onNewEvent(eventType, event) + ApiEvent.UnreadCountUpdate -> onNewEvent(eventType, event) } } + private fun onNewEvent(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event") + } + private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessageClearFlags(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessageNew(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") - + Log.d("LongPollUpdatesParser", "$eventType: $event") val messageId = event[1].asInt launch { @@ -90,7 +86,7 @@ class LongPollUpdatesParser( messageId ) - listenersMap[ApiEvent.MESSAGE_NEW]?.let { + listenersMap[ApiEvent.MessageNew]?.let { it.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent(newMessageEvent) @@ -100,8 +96,7 @@ class LongPollUpdatesParser( } private fun parseMessageEdit(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") - + Log.d("LongPollUpdatesParser", "$eventType: $event") val messageId = event[1].asInt launch { @@ -111,7 +106,7 @@ class LongPollUpdatesParser( messageId ) - listenersMap[ApiEvent.MESSAGE_EDIT]?.let { + listenersMap[ApiEvent.MessageEdit]?.let { it.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent(editedMessageEvent) @@ -121,11 +116,12 @@ class LongPollUpdatesParser( } private fun parseMessageReadIncoming(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt launch { - listenersMap[ApiEvent.MESSAGE_READ_INCOMING]?.let { listeners -> + listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners -> listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent( @@ -140,11 +136,12 @@ class LongPollUpdatesParser( } private fun parseMessageReadOutgoing(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt launch { - listenersMap[ApiEvent.MESSAGE_READ_OUTGOING]?.let { listeners -> + listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners -> listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback) .onEvent( @@ -159,22 +156,22 @@ class LongPollUpdatesParser( } private fun parseFriendOnline(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseFriendOffline(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) { -// println("$TAG: $eventType: $event") + Log.d("LongPollUpdatesParser", "$eventType: $event") } private suspend fun loadNormalMessage(eventType: ApiEvent, messageId: Int) = coroutineScope { suspendCoroutine { launch { - val normalMessageResponse = messagesDataSource.getById( + val normalMessageResponse = messagesRepository.getById( MessagesGetByIdRequest( messagesIds = listOf(messageId), extended = true, @@ -182,17 +179,19 @@ class LongPollUpdatesParser( ) ) - if (normalMessageResponse !is Answer.Success) { - (normalMessageResponse as Answer.Error).throwable.let { throw it } + if (!normalMessageResponse.isSuccessful()) { + normalMessageResponse.error.throwable?.run { throw this } } - val messagesResponse = normalMessageResponse.data.response ?: return@launch + val messagesResponse = + (normalMessageResponse as? ApiAnswer.Success)?.data?.response + ?: return@launch val messagesList = messagesResponse.items if (messagesList.isEmpty()) return@launch val normalMessage = messagesList[0].asVkMessage() - messagesDataSource.store(listOf(normalMessage)) + messagesRepository.store(listOf(normalMessage)) val profiles = hashMapOf() messagesResponse.profiles?.forEach { baseUser -> @@ -205,13 +204,13 @@ class LongPollUpdatesParser( } val resumeValue: LongPollEvent? = when (eventType) { - ApiEvent.MESSAGE_NEW -> + ApiEvent.MessageNew -> LongPollEvent.VkMessageNewEvent( normalMessage, profiles, groups ) - ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(normalMessage) + ApiEvent.MessageEdit -> LongPollEvent.VkMessageEditEvent(normalMessage) else -> null } @@ -221,7 +220,7 @@ class LongPollUpdatesParser( } - fun registerListener(eventType: ApiEvent, listener: VkEventCallback) { + private fun registerListener(eventType: ApiEvent, listener: VkEventCallback) { listenersMap.let { map -> map[eventType] = (map[eventType] ?: mutableListOf()).also { it.add(listener) @@ -230,7 +229,7 @@ class LongPollUpdatesParser( } fun onMessageIncomingRead(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_READ_INCOMING, listener) + registerListener(ApiEvent.MessageReadIncoming, listener) } fun onMessageIncomingRead(block: (LongPollEvent.VkMessageReadIncomingEvent) -> Unit) { @@ -238,7 +237,7 @@ class LongPollUpdatesParser( } fun onMessageOutgoingRead(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_READ_OUTGOING, listener) + registerListener(ApiEvent.MessageReadOutgoing, listener) } fun onMessageOutgoingRead(block: (LongPollEvent.VkMessageReadOutgoingEvent) -> Unit) { @@ -246,7 +245,7 @@ class LongPollUpdatesParser( } fun onNewMessage(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_NEW, listener) + registerListener(ApiEvent.MessageNew, listener) } fun onNewMessage(block: (LongPollEvent.VkMessageNewEvent) -> Unit) { @@ -254,7 +253,7 @@ class LongPollUpdatesParser( } fun onMessageEdited(listener: VkEventCallback) { - registerListener(ApiEvent.MESSAGE_EDIT, listener) + registerListener(ApiEvent.MessageEdit, listener) } fun onMessageEdited(block: (LongPollEvent.VkMessageEditEvent) -> Unit) { @@ -266,9 +265,7 @@ class LongPollUpdatesParser( } } -internal inline fun assembleEventCallback( - crossinline block: (R) -> Unit -): VkEventCallback { +internal inline fun assembleEventCallback(crossinline block: (R) -> Unit): VkEventCallback { return object : VkEventCallback { override fun onEvent(event: R) = block.invoke(event) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt index c46bba0d..a5c7118a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt @@ -5,6 +5,7 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import com.meloda.fast.api.UserConfig import com.meloda.fast.model.SelectableItem import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -25,10 +26,11 @@ data class VkConversation( var outRead: Int, var isMarkedUnread: Boolean, var lastMessageId: Int, - var unreadCount: Int?, + var unreadCount: Int, var membersCount: Int?, - var isPinned: Boolean, var canChangePin: Boolean, + var majorId: Int, + var minorId: Int, @Embedded(prefix = "pinnedMessage_") var pinnedMessage: VkMessage? = null, @@ -49,9 +51,13 @@ data class VkConversation( fun isUser() = type == "user" fun isGroup() = type == "group" - fun isInUnread() = inRead < lastMessageId - fun isOutUnread() = outRead < lastMessageId + fun isInUnread() = inRead - lastMessageId < 0 + fun isOutUnread() = outRead - lastMessageId < 0 fun isUnread() = isInUnread() || isOutUnread() + fun isAccount() = id == UserConfig.userId + + fun isPinned() = majorId > 0 + } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt index 513c5b31..e4b8e8fc 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt @@ -1,12 +1,12 @@ package com.meloda.fast.api.model -import androidx.lifecycle.MutableLiveData import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.model.SelectableItem import com.meloda.fast.util.TimeUtils import kotlinx.parcelize.IgnoredOnParcel @@ -14,7 +14,7 @@ import kotlinx.parcelize.Parcelize @Entity(tableName = "messages") @Parcelize -data class VkMessage( +data class VkMessage constructor( @PrimaryKey(autoGenerate = false) var id: Int, var text: String? = null, @@ -28,21 +28,29 @@ data class VkMessage( val actionText: String? = null, val actionConversationMessageId: Int? = null, val actionMessage: String? = null, - val geoType: String? = null, + + var updateTime: Int? = null, + var important: Boolean = false, var forwards: List? = null, var attachments: List? = null, - var replyMessage: VkMessage? = null + var replyMessage: VkMessage? = null, + + val geo: BaseVkMessage.Geo? = null, ) : SelectableItem(id) { @Ignore @IgnoredOnParcel - val user = MutableLiveData() + var user: VkUser? = null @Ignore @IgnoredOnParcel - val group = MutableLiveData() + var group: VkGroup? = null + + @Ignore + @IgnoredOnParcel + var state: State = State.Sent fun isPeerChat() = peerId > 2_000_000_000 @@ -51,8 +59,11 @@ data class VkMessage( fun isGroup() = fromId < 0 fun isRead(conversation: VkConversation) = - if (isOut) conversation.outRead - id >= 0 - else conversation.inRead - id >= 0 + if (isOut) { + conversation.outRead - id >= 0 + } else { + conversation.inRead - id >= 0 + } fun getPreparedAction(): Action? { if (action == null) return null @@ -61,10 +72,27 @@ data class VkMessage( fun canEdit() = fromId == UserConfig.userId && - (attachments == null || !VKConstants.restrictedToEditAttachments.contains( - attachments!![0].javaClass - )) && - (System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.ONE_DAY_IN_SECONDS) + (attachments == null || + !VKConstants.restrictedToEditAttachments.contains( + requireNotNull(attachments).first().javaClass + )) && + (System.currentTimeMillis() / 1000 - date.toLong() < TimeUtils.OneDayInSeconds) + + fun hasAttachments(): Boolean = !attachments.isNullOrEmpty() + + fun hasReply(): Boolean = replyMessage != null + + fun hasForwards(): Boolean = !forwards.isNullOrEmpty() + + fun hasGeo(): Boolean = geo != null + + fun isUpdated(): Boolean = updateTime != null && requireNotNull(updateTime) > 0 + + fun isSending(): Boolean = state == State.Sending + + fun isError(): Boolean = state == State.Error + + fun isSent(): Boolean = state == State.Sent enum class Action(val value: String) { CHAT_CREATE("chat_create"), @@ -78,14 +106,17 @@ data class VkMessage( CHAT_KICK_USER("chat_kick_user"), CHAT_SCREENSHOT("chat_screenshot"), - // TODO: 9/11/2021 catch this shit CHAT_INVITE_USER_BY_CALL("chat_invite_user_by_call"), CHAT_INVITE_USER_BY_CALL_LINK("chat_invite_user_by_call_join_link"), CHAT_STYLE_UPDATE("conversation_style_update"); companion object { - fun parse(value: String) = values().first { it.value == value } + fun parse(value: String?): Action? = values().firstOrNull { it.value == value } } } + enum class State { + Sending, Sent, Error + } + } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt index 2662b547..c8fee250 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt @@ -1,10 +1,15 @@ package com.meloda.fast.api.model.attachments import android.os.Parcelable +import com.meloda.fast.model.DataItem +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize -open class VkAttachment : Parcelable { +open class VkAttachment : DataItem(), Parcelable { + + @IgnoredOnParcel + override val dataItemId: Int = -1 open fun asString(withAccessKey: Boolean = true) = "" diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt index 19e7f7b1..e1ccb61b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt @@ -1,6 +1,7 @@ package com.meloda.fast.api.model.attachments import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.model.base.attachments.BaseVkFile import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -12,7 +13,8 @@ data class VkFile( val ext: String, val size: Int, val url: String, - val accessKey: String? + val accessKey: String?, + val preview: BaseVkFile.Preview? ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt index d70f29ba..f3061a55 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt @@ -12,7 +12,8 @@ data class VkVideo( val ownerId: Int, val images: List, val firstFrames: List?, - val accessKey: String? + val accessKey: String?, + val title: String ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt index c660472e..68ae06be 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt @@ -12,12 +12,12 @@ data class VkWall( val date: Int, val text: String, val attachments: List?, - val comments: Int, - val likes: Int, - val reposts: Int, - val views: Int, + val comments: Int?, + val likes: Int?, + val reposts: Int?, + val views: Int?, val isFavorite: Boolean, - val accessKey: String + val accessKey: String? ) : VkAttachment() { @IgnoredOnParcel diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt index 8c7177f2..96e09aee 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt @@ -37,10 +37,11 @@ data class BaseVkConversation( outRead = out_read, isMarkedUnread = is_marked_unread, lastMessageId = last_message_id, - unreadCount = unread_count, + unreadCount = unread_count ?: 0, membersCount = chat_settings?.members_count, ownerId = chat_settings?.owner_id, - isPinned = sort_id.major_id > 0, + majorId = sort_id.major_id, + minorId = sort_id.minor_id, canChangePin = chat_settings?.acl?.can_change_pin == true ).apply { this.lastMessage = lastMessage diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt index 48d8bf61..46a5b7fa 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkMessage.kt @@ -24,7 +24,8 @@ data class BaseVkMessage( val geo: Geo?, val action: Action?, val ttl: Int, - val reply_message: BaseVkMessage? + val reply_message: BaseVkMessage?, + val update_time: Int? ) : Parcelable { fun asVkMessage() = VkMessage( @@ -40,8 +41,9 @@ data class BaseVkMessage( actionText = action?.text, actionConversationMessageId = action?.conversation_message_id, actionMessage = action?.message, - geoType = geo?.type, - important = important + geo = geo, + important = important, + updateTime = update_time ).also { it.attachments = VkUtils.parseAttachments(attachments) it.forwards = VkUtils.parseForwards(fwd_messages) @@ -55,7 +57,6 @@ data class BaseVkMessage( val place: Place ) : Parcelable { - @Parcelize data class Coordinates(val latitude: Float, val longitude: Float) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt index 8c09507f..edc8b96d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkFile.kt @@ -27,7 +27,8 @@ data class BaseVkFile( ext = ext, url = url, size = size, - accessKey = access_key + accessKey = access_key, + preview = preview ) @Parcelize diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt index 15c3bcc4..0cce54de 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkVideo.kt @@ -43,7 +43,8 @@ data class BaseVkVideo( ownerId = owner_id, images = image.map { it.asVideoImage() }, firstFrames = first_frame, - accessKey = access_key + accessKey = access_key, + title = title ) @Parcelize diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt index dd831744..e1dee465 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/attachments/BaseVkWall.kt @@ -12,14 +12,14 @@ data class BaseVkWall( val date: Int, val text: String, val attachments: List?, - val post_source: PostSource, - val comments: Comments, - val likes: Likes, - val reposts: Reposts, - val views: Views, + val post_source: PostSource?, + val comments: Comments?, + val likes: Likes?, + val reposts: Reposts?, + val views: Views?, val is_favorite: Boolean, - val donut: Donut, - val access_key: String, + val donut: Donut?, + val access_key: String?, val short_text_rate: Double ) : Parcelable { @@ -30,10 +30,10 @@ data class BaseVkWall( date = date, text = text, attachments = attachments, - comments = comments.count, - likes = likes.count, - reposts = reposts.count, - views = views.count, + comments = comments?.count, + likes = likes?.count, + reposts = reposts?.count, + views = views?.count, isFavorite = is_favorite, accessKey = access_key ) diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt new file mode 100644 index 00000000..9b760e70 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt @@ -0,0 +1,78 @@ +package com.meloda.fast.api.network + +import com.google.gson.annotations.SerializedName +import com.meloda.fast.api.base.ApiError + +@Suppress("unused") +object VkErrorCodes { + const val UnknownError = 1 + const val AppDisabled = 2 + const val UnknownMethod = 3 + const val InvalidSignature = 4 + const val UserAuthorizationFailed = 5 + const val TooManyRequests = 6 + const val NoRights = 7 + const val BadRequest = 8 + const val TooManySimilarActions = 9 + const val InternalServerError = 10 + const val InTestMode = 11 + const val ExecuteCodeCompileError = 12 + const val ExecuteCodeRuntimeError = 13 + const val CaptchaNeeded = 14 + const val AccessDenied = 15 + const val RequiresRequestsOverHttps = 16 + const val ValidationRequired = 17 + const val UserBannedOrDeleted = 18 + const val ActionProhibited = 20 + const val ActionAllowedOnlyForStandalone = 21 + const val MethodOff = 23 + const val ConfirmationRequired = 24 + const val ParameterIsNotSpecified = 100 + const val IncorrectAppId = 101 + const val OutOfLimits = 103 + const val IncorrectUserId = 113 + const val IncorrectTimestamp = 150 + const val AccessToAlbumDenied = 200 + const val AccessToAudioDenied = 201 + const val AccessToGroupDenied = 203 + const val AlbumIsFull = 300 + const val ActionDenied = 500 + const val PermissionDenied = 600 + const val CannotSendMessageBlackList = 900 + const val CannotSendMessageGroup = 901 + const val InvalidDocId = 1150 + const val InvalidDocTitle = 1152 + const val AccessToDocDenied = 1153 +} + +@Suppress("unused") +object VkErrors { + const val Unknown = "unknown_error" + + const val NeedValidation = "need_validation" + const val NeedCaptcha = "need_captcha" + const val InvalidRequest = "invalid_request" + +} + +class AuthorizationError : ApiError() + +data class ValidationRequiredError( + @SerializedName("validation_type") + val validationType: String, + @SerializedName("validation_sid") + val validationSid: String, + @SerializedName("phone_mask") + val phoneMask: String, + @SerializedName("redirect_uri") + val redirectUri: String, + @SerializedName("validation_resend") + val validationResend: String +) : ApiError() + +data class CaptchaRequiredError( + @SerializedName("captcha_sid") + val captchaSid: String, + @SerializedName("captcha_img") + val captchaImg: String +) : ApiError() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt index 1099a9cd..1c740012 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt @@ -11,16 +11,20 @@ class AuthInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val builder = chain.request().url.newBuilder() - .addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8")) + val url = builder.build().toUrl().toString() - if (!builder.build().toUrl().toString().contains(AccountUrls.SetOnline)) + if (!url.contains("upload.php")) { + builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8")) + } + + if (!url.contains(AccountUrls.SetOnline) && !url.contains("upload.php")) { UserConfig.accessToken.let { if (it.isNotBlank()) builder.addQueryParameter("access_token", URLEncoder.encode(it, "utf-8")) } + } - // TODO: 9/29/2021 crash on timeout return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build()) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt deleted file mode 100644 index fcf53a0d..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ErrorCodes.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.meloda.fast.api.network - -object VkErrorCodes { - const val UNKNOWN_ERROR = 1 - const val APP_DISABLED = 2 - const val UNKNOWN_METHOD = 3 - const val INVALID_SIGNATURE = 4 - const val USER_AUTHORIZATION_FAILED = 5 - const val TOO_MANY_REQUESTS = 6 - const val NO_RIGHTS = 7 - const val BAD_REQUEST = 8 - const val TOO_MANY_SIMILAR_ACTIONS = 9 - const val INTERNAL_SERVER_ERROR = 10 - const val IN_TEST_MODE = 11 - const val EXECUTE_CODE_COMPILE_ERROR = 12 - const val EXECUTE_CODE_RUNTIME_ERROR = 13 - const val CAPTCHA_NEEDED = 14 - const val ACCESS_DENIED = 15 - const val REQUIRES_REQUESTS_OVER_HTTPS = 16 - const val VALIDATION_REQUIRED = 17 - const val USER_BANNED_OR_DELETED = 18 - const val ACTION_PROHIBITED = 20 - const val ACTION_ALLOWED_ONLY_FOR_STANDALONE = 21 - const val METHOD_OFF = 23 - const val CONFIRMATION_REQUIRED = 24 - const val PARAMETER_IS_NOT_SPECIFIED = 100 - const val INCORRECT_APP_ID = 101 - const val OUT_OF_LIMITS = 103 - const val INCORRECT_USER_ID = 113 - const val INCORRECT_TIMESTAMP = 150 - const val ACCESS_TO_ALBUM_DENIED = 200 - const val ACCESS_TO_AUDIO_DENIED = 201 - const val ACCESS_TO_GROUP_DENIED = 203 - const val ALBUM_IS_FULL = 300 - const val ACTION_DENIED = 500 - const val PERMISSION_DENIED = 600 - const val CANNOT_SEND_MESSAGE_BLACK_LIST = 900 - const val CANNOT_SEND_MESSAGE_GROUP = 901 - const val INVALID_DOC_ID = 1150 - const val INVALID_DOC_TITLE = 1152 - const val ACCESS_TO_DOC_DENIED = 1153 -} - -object VkErrors { - const val UNKNOWN = "unknown_error" - - const val NEED_VALIDATION = "need_validation" - const val NEED_CAPTCHA = "need_captcha" - const val INVALID_REQUEST = "invalid_request" - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt index 70c5a758..db06da8d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt @@ -1,15 +1,18 @@ +@file:Suppress("UNCHECKED_CAST") + package com.meloda.fast.api.network -import com.meloda.fast.api.VKException +import com.google.gson.Gson +import com.meloda.fast.api.VkUtils import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.base.ApiResponse import okhttp3.Request -import okio.IOException import okio.Timeout -import org.json.JSONObject import retrofit2.* import java.lang.reflect.ParameterizedType import java.lang.reflect.Type +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract class ResultCallFactory : CallAdapter.Factory() { override fun get( @@ -21,7 +24,7 @@ class ResultCallFactory : CallAdapter.Factory() { if (rawReturnType == Call::class.java) { if (returnType is ParameterizedType) { val callInnerType: Type = getParameterUpperBound(0, returnType) - if (getRawType(callInnerType) == Answer::class.java) { + if (getRawType(callInnerType) == ApiAnswer::class.java) { if (callInnerType is ParameterizedType) { val resultInnerType = getParameterUpperBound(0, callInnerType) return ResultCallAdapter(resultInnerType) @@ -55,16 +58,16 @@ internal abstract class CallDelegate(protected val proxy: Call) : C abstract fun cloneImpl(): Call } -private class ResultCallAdapter(private val type: Type) : CallAdapter>> { +private class ResultCallAdapter(private val type: Type) : CallAdapter>> { override fun responseType() = type - override fun adapt(call: Call): Call> = ResultCall(call) + override fun adapt(call: Call): Call> = ResultCall(call) } -internal class ResultCall(proxy: Call) : CallDelegate>(proxy) { +internal class ResultCall(proxy: Call) : CallDelegate>(proxy) { - override fun enqueueImpl(callback: Callback>) { + override fun enqueueImpl(callback: Callback>) { proxy.enqueue(ResultCallback(this, callback)) } @@ -74,25 +77,34 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) private class ResultCallback( private val proxy: ResultCall, - private val callback: Callback> + private val callback: Callback> ) : Callback { - override fun onResponse(call: Call, response: Response) { - var isVkException = true + val gson = Gson() - val result: Answer = + override fun onResponse(call: Call, response: Response) { + val result: ApiAnswer = if (response.isSuccessful) { val baseBody = response.body() - if (baseBody !is ApiResponse<*>) Answer.Success(baseBody as T) - else { - val body = baseBody as ApiResponse<*> - if (body.error != null) { - Answer.Error(body.error) - } else Answer.Success(body as T) + if (baseBody !is ApiResponse<*>) { + ApiAnswer.Success(baseBody as T) + } else { + val body = baseBody as? ApiResponse<*> + if (body?.error != null) { + VkUtils.getApiError(gson, gson.toJson(body.error)) + } else { + ApiAnswer.Success(body as T) + } } - } else Answer.Error(IOException(response.errorBody()?.string() ?: "")) + } else { + val errorBodyString = response.errorBody()?.string() - if (result is Answer.Error && isVkException) if (checkErrors(call, result)) return + VkUtils.getApiError(gson, errorBodyString) + } + + if (checkErrors(call, result)) { + return + } callback.onResponse(proxy, Response.success(result)) } @@ -100,30 +112,21 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) override fun onFailure(call: Call, error: Throwable) { callback.onResponse( proxy, - Response.success(Answer.Error(throwable = error)) + Response.success(ApiAnswer.Error(ApiError(throwable = error))) ) } - private fun checkErrors(call: Call, result: Answer.Error): Boolean { - if (result.throwable is ApiError) { - onFailure(call, result.throwable) - return true + private fun checkErrors(call: Call, result: ApiAnswer<*>): Boolean { + if (!result.isSuccessful()) { + result.error.throwable?.run { + onFailure(call, this) + return true + } + } else { + return false } - val json = JSONObject(result.throwable.message ?: "{}") - - return if (json.has("error")) { - val error = json.optString("error", "") - val description = json.optString("error_description", "") - - val exception = VKException( - error = error, - description = description, - ).also { it.json = json } - - onFailure(call, exception) - true - } else false + return false } } @@ -132,9 +135,16 @@ internal class ResultCall(proxy: Call) : CallDelegate>(proxy) } } -sealed class Answer { +sealed class ApiAnswer { - data class Success(val data: T) : Answer() - data class Error(val throwable: Throwable) : Answer() + data class Success(val data: T) : ApiAnswer() + data class Error(val error: ApiError) : ApiAnswer() + @OptIn(ExperimentalContracts::class) + fun isSuccessful(): Boolean { + contract { + returns(false) implies (this@ApiAnswer is Error) + } + return this is Success + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt deleted file mode 100644 index 6da02c69..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountDataSource.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.meloda.fast.api.network.account - -import javax.inject.Inject - -class AccountDataSource @Inject constructor( - private val repo: AccountRepo -) { - - - suspend fun setOnline(params: AccountSetOnlineRequest) = repo.setOnline(params.map) - - suspend fun setOffline(params: AccountSetOfflineRequest) = repo.setOffline(params.map) - - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt new file mode 100644 index 00000000..dfa5fbf3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.audio + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt new file mode 100644 index 00000000..a0a39180 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosResponses.kt @@ -0,0 +1,20 @@ +package com.meloda.fast.api.network.audio + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AudiosGetUploadServerResponse( + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class AudiosUploadResponse( + val redirect: String, + val server: Int, + val audio: String?, + val hash: String, + val error: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt new file mode 100644 index 00000000..094f32fa --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/audio/AudiosUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.audio + +import com.meloda.fast.api.network.VkUrls + +object AudiosUrls { + + const val GetUploadServer = "${VkUrls.API}/audio.getUploadServer" + + const val Save = "${VkUrls.API}/audio.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt deleted file mode 100644 index d17da88e..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthDataSource.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.meloda.fast.api.network.auth - -import javax.inject.Inject - -class AuthDataSource @Inject constructor( - private val repo: AuthRepo -) { - - suspend fun auth(params: AuthDirectRequest) = repo.auth(params.map) - - suspend fun sendSms(validationSid: String) = repo.sendSms(validationSid) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt deleted file mode 100644 index 320a9d04..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthRepo.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.api.network.auth - -import com.meloda.fast.api.network.Answer -import retrofit2.http.GET -import retrofit2.http.Query -import retrofit2.http.QueryMap - -interface AuthRepo { - - @GET(AuthUrls.DirectAuth) - suspend fun auth(@QueryMap param: Map): Answer - - @GET(AuthUrls.SendSms) - suspend fun sendSms(@Query("sid") validationSid: String): Answer - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt deleted file mode 100644 index 838a5a8f..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsDataSource.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.meloda.fast.api.network.conversations - -import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.database.dao.ConversationsDao -import javax.inject.Inject - -class ConversationsDataSource @Inject constructor( - private val repo: ConversationsRepo, - private val dao: ConversationsDao -) { - - suspend fun get(params: ConversationsGetRequest) = repo.get(params.map) - - suspend fun delete(params: ConversationsDeleteRequest) = repo.delete(params.map) - - suspend fun pin(params: ConversationsPinRequest) = repo.pin(params.map) - - suspend fun unpin(params: ConversationsUnpinRequest) = repo.unpin(params.map) - - suspend fun store(conversations: List) = dao.insert(conversations) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt deleted file mode 100644 index cb007e99..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsRepo.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.meloda.fast.api.network.conversations - -import com.meloda.fast.api.base.ApiResponse -import com.meloda.fast.api.network.Answer -import retrofit2.http.FieldMap -import retrofit2.http.FormUrlEncoded -import retrofit2.http.POST - -interface ConversationsRepo { - - @FormUrlEncoded - @POST(ConversationsUrls.Get) - suspend fun get(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Delete) - suspend fun delete(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Pin) - suspend fun pin(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.Unpin) - suspend fun unpin(@FieldMap params: Map): Answer> - - @FormUrlEncoded - @POST(ConversationsUrls.ReorderPinned) - suspend fun reorderPinned(@FieldMap params: Map): Answer> - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt new file mode 100644 index 00000000..a94949af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FileRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.files + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt new file mode 100644 index 00000000..abef98ae --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesResponses.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.api.network.files + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import com.meloda.fast.api.model.base.attachments.BaseVkFile +import com.meloda.fast.api.model.base.attachments.BaseVkVoiceMessage +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FilesGetMessagesUploadServerResponse( + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class FilesUploadFileResponse(val file: String?, val error: String?) : Parcelable + +@Parcelize +data class FilesSaveFileResponse( + val type: String, + @SerializedName("doc") + val file: BaseVkFile?, + @SerializedName("audio_message") + val voiceMessage: BaseVkVoiceMessage? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt new file mode 100644 index 00000000..1282e234 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/files/FilesUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.files + +import com.meloda.fast.api.network.VkUrls + +object FilesUrls { + + const val GetMessagesUploadServer = "${VkUrls.API}/docs.getMessagesUploadServer" + + const val Save = "${VkUrls.API}/docs.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt index d44cfe91..8b4aa48d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRequests.kt @@ -9,7 +9,8 @@ data class LongPollGetUpdatesRequest( val key: String, val ts: Int, val wait: Int, - val mode: Int + val mode: Int, + val version: Int ) : Parcelable { val map @@ -18,7 +19,8 @@ data class LongPollGetUpdatesRequest( "key" to key, "ts" to ts.toString(), "wait" to wait.toString(), - "mode" to mode.toString() + "mode" to mode.toString(), + "version" to version.toString() ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt deleted file mode 100644 index 15955175..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesDataSource.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.meloda.fast.api.network.messages - -import com.meloda.fast.api.model.VkMessage -import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.database.dao.MessagesDao -import javax.inject.Inject - -class MessagesDataSource @Inject constructor( - private val messagesRepo: MessagesRepo, - private val messagesDao: MessagesDao, - private val longPollRepo: LongPollRepo -) { - - suspend fun store(messages: List) = messagesDao.insert(messages) - - suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) - - suspend fun getHistory(params: MessagesGetHistoryRequest) = - messagesRepo.getHistory(params.map) - - suspend fun send(params: MessagesSendRequest) = - messagesRepo.send(params.map) - - suspend fun markAsImportant(params: MessagesMarkAsImportantRequest) = - messagesRepo.markAsImportant(params.map) - - suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) = - messagesRepo.getLongPollServer(params.map) - - suspend fun pin(params: MessagesPinMessageRequest) = - messagesRepo.pin(params.map) - - suspend fun unpin(params: MessagesUnPinMessageRequest) = - messagesRepo.unpin(params.map) - - suspend fun delete(params: MessagesDeleteRequest) = - messagesRepo.delete(params.map) - - suspend fun edit(params: MessagesEditRequest) = - messagesRepo.edit(params.map) - - suspend fun getLongPollUpdates( - serverUrl: String, - params: LongPollGetUpdatesRequest - ) = longPollRepo.getResponse(serverUrl, params.map) - - suspend fun getById(params: MessagesGetByIdRequest) = - messagesRepo.getById(params.map) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt index 4801203c..5e619b27 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt @@ -41,7 +41,8 @@ data class MessagesSendRequest( val stickerId: Int? = null, val disableMentions: Boolean? = null, val dontParseLinks: Boolean? = null, - val silent: Boolean? = null + val silent: Boolean? = null, + val attachments: List? = null ) : Parcelable { val map @@ -57,6 +58,11 @@ data class MessagesSendRequest( disableMentions?.let { this["disable_mentions"] = it.intString } dontParseLinks?.let { this["dont_parse_links"] = it.intString } silent?.let { this["silent"] = it.toString() } + attachments?.let { + this["attachment"] = it.joinToString(separator = ",") { attachment -> + attachment.asString(true) + } + } } } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt index bb75b0eb..66c3acd2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt @@ -14,5 +14,6 @@ object MessagesUrls { const val Delete = "${VkUrls.API}/messages.delete" const val Edit = "${VkUrls.API}/messages.edit" const val GetById = "${VkUrls.API}/messages.getById" + const val MarkAsRead = "${VkUrls.API}/messages.markAsRead" } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt new file mode 100644 index 00000000..fd0c6961 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaResponses.kt @@ -0,0 +1,8 @@ +package com.meloda.fast.api.network.ota + +import android.os.Parcelable +import com.meloda.fast.model.UpdateItem +import kotlinx.parcelize.Parcelize + +@Parcelize +data class OtaGetLatestReleaseResponse(val release: UpdateItem?) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt new file mode 100644 index 00000000..473d0ea9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ota/OtaUrls.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.api.network.ota + +object OtaUrls { + + const val GetActualUrl = + "https://raw.githubusercontent.com/melod1n/ota-server/master/ngrok_url.json" +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt new file mode 100644 index 00000000..05b897e5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotoUrls.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.api.network.photos + +import com.meloda.fast.api.network.VkUrls + +object PhotoUrls { + + const val GetMessagesUploadServer = "${VkUrls.API}/photos.getMessagesUploadServer" + + const val SaveMessagePhoto = "${VkUrls.API}/photos.saveMessagesPhoto" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt new file mode 100644 index 00000000..8e457ff0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosRequests.kt @@ -0,0 +1,16 @@ +package com.meloda.fast.api.network.photos + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PhotosSaveMessagePhotoRequest( + val photo: String, val server: Int, val hash: String +) : Parcelable { + val map + get() = mapOf( + "photo" to photo, + "server" to server.toString(), + "hash" to hash + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt new file mode 100644 index 00000000..c9a31973 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/photos/PhotosResponses.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.api.network.photos + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PhotosGetMessagesUploadServerResponse( + @SerializedName("album_id") + val albumId: Int, + @SerializedName("upload_url") + val uploadUrl: String +) : Parcelable + +@Parcelize +data class PhotosUploadPhotoResponse( + val server: Int, val photo: String, val hash: String +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt b/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt deleted file mode 100644 index 86a7b88a..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersDataSource.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.api.network.users - -import com.meloda.fast.api.model.VkUser -import com.meloda.fast.database.dao.UsersDao -import javax.inject.Inject - -class UsersDataSource @Inject constructor( - private val repo: UsersRepo, - private val dao: UsersDao -) { - - suspend fun getById(params: UsersGetRequest) = repo.getById(params.map) - - suspend fun storeUsers(users: List) = dao.insert(users) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt new file mode 100644 index 00000000..1f196662 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosRequests.kt @@ -0,0 +1,2 @@ +package com.meloda.fast.api.network.videos + diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt new file mode 100644 index 00000000..86264318 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosResponses.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.api.network.videos + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VideosSaveResponse( + @SerializedName("access_key") + val accessKey: String, + val description: String, + @SerializedName("owner_id") + val ownerId: Int, + val title: String, + @SerializedName("upload_url") + val uploadUrl: String, + @SerializedName("video_id") + val videoId: Int +) : Parcelable { + +} + +@Parcelize +data class VideosUploadResponse( + @SerializedName("video_hash") + val hash: String?, + val size: Int, + @SerializedName("direct_link") + val directLink: String, + @SerializedName("owner_id") + val ownerId: Int, + @SerializedName("video_id") + val videoId: Int, + val error: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt new file mode 100644 index 00000000..c2cc9308 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/network/videos/VideosUrls.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.api.network.videos + +import com.meloda.fast.api.network.VkUrls + +object VideosUrls { + + const val Save = "${VkUrls.API}/video.save" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt index 879bbeeb..867e7485 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt @@ -1,7 +1,10 @@ package com.meloda.fast.base +import android.os.Bundle +import android.view.View import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment +import com.meloda.fast.screens.main.MainActivity abstract class BaseFragment : Fragment { @@ -9,4 +12,36 @@ abstract class BaseFragment : Fragment { constructor(@LayoutRes resId: Int) : super(resId) + protected var shouldNavBarShown: Boolean = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (arguments == null) arguments = Bundle() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + (requireActivity() as? MainActivity)?.run { + toggleNavBarVisibility(shouldNavBarShown) + } + } + + val activityRouter + get() = run { + if (requireActivity() is MainActivity) { + (requireActivity() as MainActivity).router + } else { + null + } + } + + fun requireActivityRouter() = requireNotNull(activityRouter) + + fun startProgress() = toggleProgress(true) + fun stopProgress() = toggleProgress(false) + + protected open fun toggleProgress(isProgressing: Boolean) {} + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt deleted file mode 100644 index 40ddc1f0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseViewModelFragment.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.meloda.fast.base - -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.annotation.LayoutRes -import androidx.lifecycle.lifecycleScope -import com.meloda.fast.R -import com.meloda.fast.activity.MainActivity -import com.meloda.fast.api.UserConfig -import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.IllegalTokenEvent -import com.meloda.fast.base.viewmodel.VkEvent -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach - -abstract class BaseViewModelFragment : BaseFragment { - - constructor() : super() - - constructor(@LayoutRes resId: Int) : super(resId) - - protected abstract val viewModel: VM - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - viewModel.tasksEvent.onEach { onEvent(it) }.collect() - } - } - - protected open fun onEvent(event: VkEvent) { - if (event is IllegalTokenEvent) { - Toast.makeText( - requireContext(), R.string.authorization_failed, Toast.LENGTH_LONG - ).show() - - UserConfig.clear() - requireActivity().finishAffinity() - requireActivity().startActivity(Intent(requireContext(), MainActivity::class.java)) - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt b/app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt rename to app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt index f0dcf582..f2ab5f99 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/ResourceManager.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/ResourceProvider.kt @@ -1,12 +1,14 @@ package com.meloda.fast.base import android.content.Context +import android.graphics.drawable.Drawable import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat -abstract class ResourceManager(protected val context: Context) { +abstract class ResourceProvider(protected val context: Context) { protected fun getString(@StringRes resId: Int): String { return context.getString(resId) @@ -17,4 +19,8 @@ abstract class ResourceManager(protected val context: Context) { return ContextCompat.getColor(context, resId) } + protected fun getDrawable(@DrawableRes resId: Int): Drawable? { + return ContextCompat.getDrawable(context, resId) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt index 4455cb34..0aea22ec 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt @@ -1,25 +1,26 @@ package com.meloda.fast.base.adapter -import android.annotation.SuppressLint import android.content.Context +import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.AdapterView +import android.widget.Filter +import android.widget.Filterable import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import com.meloda.fast.model.DataItem -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlin.properties.Delegates @Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST") -@SuppressLint("NotifyDataSetChanged") abstract class BaseAdapter, VH : BaseHolder> constructor( var context: Context, diffUtil: DiffUtil.ItemCallback, preAddedValues: List = emptyList(), -) : ListAdapter(diffUtil) { +) : ListAdapter(diffUtil), Filterable { + + private var valuesFilter: ValuesFilter? = null protected val adapterScope = CoroutineScope(Dispatchers.Default) private val cleanList = mutableListOf() @@ -29,13 +30,19 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( var itemClickListener: ((position: Int) -> Unit)? = null var itemLongClickListener: ((position: Int) -> Boolean)? = null + private val listForSave = mutableListOf() + + var isSearching: Boolean by Delegates.observable(false) { _, _, _ -> + updateSearchingState() + } + init { cleanList.addAll(preAddedValues) addAll(preAddedValues) } fun cloneCurrentList(): MutableList { - return ArrayList(currentList) + return currentList.toMutableList() } open fun destroy() {} @@ -142,6 +149,11 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( return currentList.indexOf(item) } + fun searchIndexOf(item: T): Int? { + val index = indexOf(item) + return if (index == -1) null else index + } + val indices get() = currentList.indices operator fun get(position: Int): T { @@ -161,9 +173,8 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( fun isEmpty() = currentList.isEmpty() fun isNotEmpty() = currentList.isNotEmpty() - @SuppressLint("NotifyDataSetChanged") fun refreshList() { - notifyDataSetChanged() + notifyItemRangeChanged(0, itemCount) } fun updateCleanList(list: List?) { @@ -201,4 +212,86 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( } val lastPosition get() = currentList.lastIndex + + private fun updateSearchingState() { + Log.d("BaseAdapter", "updateSearchingState: $isSearching") + + cleanList.clear() + + if (isSearching) { + listForSave.clear() + listForSave += cloneCurrentList() + } else { + setItems(listForSave, commitCallback = { + listForSave.clear() + }) + } + } + + open fun filter(query: String) { + if (cleanList.isEmpty()) { + cleanList.addAll(listForSave) + } + + val newList = mutableListOf() + + setItems(emptyList(), commitCallback = { + if (query.isEmpty()) { + newList.addAll(cleanList) + } else { + for (item in cleanList) { + if (onQueryItem(item, query)) { + newList.add(item) + } + } + } + + setItems(newList) + }) + } + + open fun onQueryItem(item: T, query: String): Boolean { + return false + } + + override fun getFilter(): Filter { + if (valuesFilter == null) { + valuesFilter = ValuesFilter() + } + + return requireNotNull(valuesFilter) + } + + private inner class ValuesFilter : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + val results = FilterResults() + + if (isEmpty()) return results + + if (!constraint.isNullOrEmpty()) { + val filteredList = mutableListOf() + for (item in listForSave) { + if (onQueryItem(item, constraint.toString())) { + filteredList.add(item) + } + } + results.count = filteredList.size + results.values = filteredList + } else { + results.count = listForSave.size + results.values = listForSave + } + + return results + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + val items = results.values as? List + setItems(items) + } + } + + override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { + super.onCurrentListChanged(previousList, currentList) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt deleted file mode 100644 index 7b1630b1..00000000 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.meloda.fast.base.adapter - -abstract class BaseItem \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt index af7860d1..df2f0d63 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/Holders.kt @@ -2,7 +2,6 @@ package com.meloda.fast.base.adapter import android.view.View import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding abstract class BaseHolder(v: View) : RecyclerView.ViewHolder(v) { @@ -12,6 +11,4 @@ abstract class BaseHolder(v: View) : RecyclerView.ViewHolder(v) { open fun bind(position: Int, payloads: MutableList?) {} -} - -abstract class BindingHolder(protected val binding: B) : BaseHolder(binding.root) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt index 8ce65c7e..7388207c 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt @@ -2,15 +2,19 @@ package com.meloda.fast.base.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.meloda.fast.api.VKException import com.meloda.fast.api.base.ApiError -import com.meloda.fast.api.network.Answer -import com.meloda.fast.api.network.VkErrorCodes -import com.meloda.fast.api.network.VkErrors +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.AuthorizationError +import com.meloda.fast.api.network.CaptchaRequiredError +import com.meloda.fast.api.network.ValidationRequiredError +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +@Suppress("MemberVisibilityCanBePrivate") abstract class BaseViewModel : ViewModel() { var unknownErrorDefaultText: String = "" @@ -18,19 +22,47 @@ abstract class BaseViewModel : ViewModel() { protected val tasksEventChannel = Channel() val tasksEvent = tasksEventChannel.receiveAsFlow() + protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + viewModelScope.launch { onException(throwable) } + } + + fun launch(block: suspend CoroutineScope.() -> Unit): Job { + return viewModelScope.launch(exceptionHandler, block = block) + } + + protected suspend fun makeSuspendJob( + job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {}, + onStart: (suspend () -> Unit)? = null, + onEnd: (suspend () -> Unit)? = null, + onError: (suspend (Throwable) -> Unit)? = null + ): ApiAnswer { + onStart?.invoke() ?: onStart() + val response = job() + + when (response) { + is ApiAnswer.Success -> onAnswer(response.data) + is ApiAnswer.Error -> { + onError?.invoke(response.error) ?: checkErrors(response.error) + } + } + + onEnd?.invoke() + + return response + } + protected fun makeJob( - job: suspend () -> Answer, + job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {}, onStart: (suspend () -> Unit)? = null, onEnd: (suspend () -> Unit)? = null, onError: (suspend (Throwable) -> Unit)? = null - ) = viewModelScope.launch { + ): Job = viewModelScope.launch { onStart?.invoke() ?: onStart() when (val response = job()) { - is Answer.Success -> onAnswer(response.data) - is Answer.Error -> { - checkErrors(response.throwable) - onError?.invoke(response.throwable) ?: onError(response.throwable) + is ApiAnswer.Success -> onAnswer(response.data) + is ApiAnswer.Error -> { + onError?.invoke(response.error) ?: checkErrors(response.error) } } }.also { @@ -41,6 +73,10 @@ abstract class BaseViewModel : ViewModel() { } } + protected open suspend fun onException(throwable: Throwable) { + checkErrors(throwable) + } + protected suspend fun onStart() { sendEvent(StartProgressEvent) } @@ -49,37 +85,24 @@ abstract class BaseViewModel : ViewModel() { sendEvent(StopProgressEvent) } - protected suspend fun onError(throwable: Throwable) { - sendEvent(ErrorEvent(throwable.message ?: unknownErrorDefaultText)) - } - protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event) - private suspend fun checkErrors(throwable: Throwable) { + protected suspend fun checkErrors(throwable: Throwable) { when (throwable) { - is ApiError -> { - when (throwable.errorCode) { - VkErrorCodes.USER_AUTHORIZATION_FAILED -> { - sendEvent(IllegalTokenEvent) - } - } + is AuthorizationError -> { + sendEvent(AuthorizationErrorEvent) } - is VKException -> { - when (throwable.error) { - VkErrors.NEED_CAPTCHA -> { - val json = throwable.json ?: return - sendEvent( - CaptchaEvent( - sid = json.optString("captcha_sid"), - image = json.optString("captcha_img") - ) - ) - } - VkErrors.NEED_VALIDATION -> { - val json = throwable.json ?: return - sendEvent(ValidationEvent(sid = json.optString("validation_sid"))) - } - } + is ValidationRequiredError -> { + sendEvent(ValidationRequiredEvent(throwable.validationSid)) + } + is CaptchaRequiredError -> { + sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg)) + } + is ApiError -> { + sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText)) + } + else -> { + sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText)) } } } diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt new file mode 100644 index 00000000..de979b7b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt @@ -0,0 +1,34 @@ +package com.meloda.fast.base.viewmodel + +import android.os.Bundle +import android.view.View +import androidx.annotation.LayoutRes +import androidx.lifecycle.lifecycleScope +import com.meloda.fast.base.BaseFragment +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +abstract class BaseViewModelFragment : BaseFragment { + + constructor() : super() + + constructor(@LayoutRes resId: Int) : super(resId) + + protected abstract val viewModel: VM + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + subscribeToViewModel(viewModel) + } + + protected open fun onEvent(event: VkEvent) { + ViewModelUtils.parseEvent(this, event) + } + + protected fun subscribeToViewModel(viewModel: T) { + lifecycleScope.launch { + viewModel.tasksEvent.collect { onEvent(it) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt index 71b2bad2..b958c069 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt @@ -1,22 +1,17 @@ package com.meloda.fast.base.viewmodel -data class ShowDialogInfoEvent( - val title: String? = null, - val message: String, - val positiveBtn: String? = null, - val negativeBtn: String? = null -) : VkEvent() - -data class ErrorEvent(val errorText: String) : VkEvent() - -object IllegalTokenEvent : VkEvent() -data class CaptchaEvent(val sid: String, val image: String) : VkEvent() -data class ValidationEvent(val sid: String) : VkEvent() - -object StartProgressEvent : VkEvent() -object StopProgressEvent : VkEvent() - abstract class VkEvent +abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent() +abstract class VkProgressEvent : VkEvent() + +open class ErrorTextEvent(override val errorText: String) : VkErrorEvent() + +object AuthorizationErrorEvent : VkErrorEvent() +data class CaptchaRequiredEvent(val sid: String, val image: String) : VkErrorEvent() +data class ValidationRequiredEvent(val sid: String) : VkErrorEvent() + +object StartProgressEvent : VkProgressEvent() +object StopProgressEvent : VkProgressEvent() interface VkEventCallback { fun onEvent(event: T) diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt new file mode 100644 index 00000000..3bf4c2cc --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt @@ -0,0 +1,49 @@ +package com.meloda.fast.base.viewmodel + +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.screens.main.MainActivity +import com.meloda.fast.util.ViewUtils.showErrorDialog + +object ViewModelUtils { + + @Suppress("MemberVisibilityCanBePrivate") + fun parseEvent(activity: FragmentActivity, event: VkEvent) { + when (event) { + is AuthorizationErrorEvent -> { + Toast.makeText( + activity, R.string.authorization_failed, Toast.LENGTH_LONG + ).show() + + UserConfig.clear() + activity.finishAffinity() + activity.startActivity(Intent(activity, MainActivity::class.java)) + } + + is VkErrorEvent -> { + event.errorText?.run { + activity.showErrorDialog(this) + } + } + } + } + + fun parseEvent(fragment: Fragment, event: VkEvent) { + if (event is VkProgressEvent) { + if (fragment is BaseFragment) { + if (event is StartProgressEvent) { + fragment.startProgress() + } else if (event is StopProgressEvent) { + fragment.stopProgress() + } + } + } else { + parseEvent(fragment.requireActivity(), event) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt b/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt new file mode 100644 index 00000000..62d545a7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/common/AppConstants.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.common + +object AppConstants { + + const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt index e334c772..5b821eb1 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt @@ -1,6 +1,7 @@ package com.meloda.fast.common import android.app.Application +import android.app.DownloadManager import android.content.ClipboardManager import android.content.Context import android.content.SharedPreferences @@ -12,10 +13,9 @@ import android.view.inputmethod.InputMethodManager import androidx.core.content.pm.PackageInfoCompat import androidx.preference.PreferenceManager import androidx.room.Room -import com.meloda.fast.BuildConfig import com.meloda.fast.database.AppDatabase import dagger.hilt.android.HiltAndroidApp -import org.acra.ACRA +import kotlin.math.roundToInt import kotlin.math.sqrt @HiltAndroidApp @@ -26,6 +26,7 @@ class AppGlobal : Application() { lateinit var inputMethodManager: InputMethodManager lateinit var connectivityManager: ConnectivityManager lateinit var clipboardManager: ClipboardManager + lateinit var downloadManager: DownloadManager lateinit var preferences: SharedPreferences lateinit var resources: Resources @@ -37,11 +38,13 @@ class AppGlobal : Application() { lateinit var packageManager: PackageManager var versionName = "" - var versionCode = 0L + var versionCode = 0 var screenWidth = 0 var screenHeight = 0 + var screenWidth80 = 0 + val Instance get() = instance } @@ -49,19 +52,15 @@ class AppGlobal : Application() { super.onCreate() instance = this - if (!BuildConfig.DEBUG) { - ACRA.init(this) - } - appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache") - .fallbackToDestructiveMigration() +// .fallbackToDestructiveMigration() .build() preferences = PreferenceManager.getDefaultSharedPreferences(this) val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES) versionName = info.versionName - versionCode = PackageInfoCompat.getLongVersionCode(info) + versionCode = PackageInfoCompat.getLongVersionCode(info).toInt() Companion.resources = resources Companion.packageName = packageName @@ -70,6 +69,8 @@ class AppGlobal : Application() { screenWidth = resources.displayMetrics.widthPixels screenHeight = resources.displayMetrics.heightPixels + screenWidth80 = (screenWidth * 0.8).roundToInt() + val density = resources.displayMetrics.density val densityDpi = resources.displayMetrics.densityDpi val densityScaled = resources.displayMetrics.scaledDensity @@ -82,11 +83,12 @@ class AppGlobal : Application() { Log.i( "Fast::DeviceInfo", - "width: $screenWidth; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" + "width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" ) inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt index c4039eb1..4f0bcc26 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.Job object AppSettings { - val keyIsMultilineEnabled = booleanPreferencesKey("isMultilineEnabled") + val keyUseNavigationDrawer = booleanPreferencesKey("use_nav_drawer") } diff --git a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt index 3060bb55..f2411903 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt @@ -1,17 +1,50 @@ package com.meloda.fast.common -import android.os.Bundle import com.github.terrakok.cicerone.androidx.FragmentScreen +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.model.UpdateItem import com.meloda.fast.screens.conversations.ConversationsFragment import com.meloda.fast.screens.login.LoginFragment import com.meloda.fast.screens.main.MainFragment +import com.meloda.fast.screens.messages.ForwardedMessagesFragment import com.meloda.fast.screens.messages.MessagesHistoryFragment +import com.meloda.fast.screens.settings.SettingsRootFragment +import com.meloda.fast.screens.updates.UpdatesFragment @Suppress("FunctionName") object Screens { fun Main() = FragmentScreen { MainFragment() } - fun Login() = FragmentScreen { LoginFragment() } + + fun Login( + getFastToken: Boolean = false + ) = FragmentScreen { + LoginFragment.newInstance(getFastToken) + } + fun Conversations() = FragmentScreen { ConversationsFragment() } - fun MessagesHistory(bundle: Bundle) = - FragmentScreen { MessagesHistoryFragment.newInstance(bundle) } + + fun MessagesHistory( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) } + + fun ForwardedMessages( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) = FragmentScreen { + ForwardedMessagesFragment.newInstance( + conversation, messages, profiles, groups + ) + } + + fun Updates(updateItem: UpdateItem? = null) = + FragmentScreen { UpdatesFragment.newInstance(updateItem) } + + fun Settings() = FragmentScreen { SettingsRootFragment() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt b/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt deleted file mode 100644 index a1a914e0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/common/TimeManager.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.meloda.fast.common - -import android.content.Context -import android.content.IntentFilter -import com.meloda.fast.receiver.MinuteReceiver -import java.util.* - -object TimeManager { - - var currentHour = 0 - var currentMinute = 0 - var currentSecond = 0 - - private val onHourChangeListeners: ArrayList = ArrayList() - private val onMinuteChangeListeners: ArrayList = ArrayList() - private val onSecondChangeListeners: ArrayList = ArrayList() - private val onTimeChangeListeners: ArrayList = ArrayList() - - fun init(context: Context) { - context.registerReceiver(MinuteReceiver(), IntentFilter("android.intent.action.TIME_TICK")) - - addOnMinuteChangeListener(minuteChangeListener) - } - - private var minuteChangeListener = object : OnMinuteChangeListener { - override fun onMinuteChange(currentMinute: Int) { - TimeManager.currentMinute = currentMinute - } - } - - fun destroy() { - removeOnMinuteChangeListener(minuteChangeListener) - } - - fun broadcastMinute() { - for (onMinuteChangeListener in onMinuteChangeListeners) { - onMinuteChangeListener.onMinuteChange(0) - } - } - - val isMorning = currentHour in 7..11 - - val isAfternoon = currentHour in 12..16 - - val isEvening = currentHour in 17..22 - - val isNight = currentHour == 23 || currentHour < 6 && currentHour > -1 - - fun addOnHourChangeListener(onHourChangeListeners: OnHourChangeListener) { - TimeManager.onHourChangeListeners.add(onHourChangeListeners) - } - - fun removeOnHourChangeListener(onHourChangeListener: OnHourChangeListener?) { - onHourChangeListeners.remove(onHourChangeListener) - } - - fun addOnMinuteChangeListener(onMinuteChangeListener: OnMinuteChangeListener) { - onMinuteChangeListeners.add(onMinuteChangeListener) - } - - fun removeOnMinuteChangeListener(onMinuteChangeListener: OnMinuteChangeListener?) { - onMinuteChangeListeners.remove(onMinuteChangeListener) - } - - fun addOnSecondChangeListener(onSecondChangeListener: OnSecondChangeListener) { - onSecondChangeListeners.add(onSecondChangeListener) - } - - fun removeOnSecondChangeListener(onSecondChangeListener: OnSecondChangeListener?) { - onSecondChangeListeners.remove(onSecondChangeListener) - } - - fun addOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener) { - onTimeChangeListeners.add(onTimeChangeListener) - } - - fun removeOnTimeChangeListener(onTimeChangeListener: OnTimeChangeListener?) { - onTimeChangeListeners.remove(onTimeChangeListener) - } - - interface OnHourChangeListener { - fun onHourChange(currentHour: Int) - } - - interface OnMinuteChangeListener { - fun onMinuteChange(currentMinute: Int) - } - - interface OnSecondChangeListener { - fun onSecondChange(currentSecond: Int) - } - - interface OnTimeChangeListener { - fun onHourChange(currentHour: Int) - fun onMinuteChange(currentMinute: Int) - fun onSecondChange(currentSecond: Int) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt new file mode 100644 index 00000000..930aab68 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt @@ -0,0 +1,100 @@ +package com.meloda.fast.common + +import androidx.lifecycle.MutableLiveData +import com.meloda.fast.BuildConfig +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse +import com.meloda.fast.data.ota.OtaApi +import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.model.UpdateActualUrl +import com.meloda.fast.model.UpdateItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URLEncoder +import kotlin.coroutines.CoroutineContext + +class UpdateManager(private val repo: OtaApi) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + companion object { + val newUpdate = MutableLiveData(null) + val updateError = MutableLiveData(null) + + var otaBaseUrl: String? = null + private set + } + + private var listener: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null + + private fun getActualUrl() = launch { + val job: suspend () -> ApiAnswer = { repo.getActualUrl() } + + when (val jobResponse = job()) { + is ApiAnswer.Success -> { + val item = jobResponse.data + otaBaseUrl = item.url + + getLatestRelease() + } + is ApiAnswer.Error -> { + otaBaseUrl = null + val throwable = jobResponse.error.throwable + listener?.invoke(null, throwable) + + withContext(Dispatchers.Main) { + updateError.setIfNotEquals(throwable) + } + } + } + } + + private fun getLatestRelease() = launch { + val url = "$otaBaseUrl/releases-latest" + + val job: suspend () -> ApiAnswer> = { + repo.getLatestRelease(url = url, secretCode = getOtaSecret()) + } + + withContext(Dispatchers.Main) { + when (val jobResponse = job()) { + is ApiAnswer.Success -> { + val response = jobResponse.data.response ?: return@withContext + val latestRelease = response.release + + if (latestRelease != null && + (AppGlobal.versionName + .split("_") + .getOrNull(1) != latestRelease.versionName || + AppGlobal.versionCode < latestRelease.versionCode) + ) { + newUpdate.setIfNotEquals(latestRelease) + listener?.invoke(latestRelease, null) + } else { + newUpdate.setIfNotEquals(null) + listener?.invoke(null, null) + } + } + + is ApiAnswer.Error -> { + val throwable = jobResponse.error.throwable + updateError.setIfNotEquals(throwable) + listener?.invoke(null, throwable) + } + } + } + } + + private fun getOtaSecret(): String { + return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8") + } + + fun checkUpdates(block: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null) = launch { + this@UpdateManager.listener = block + getActualUrl() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt similarity index 59% rename from app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt index 8abb02b7..bbd182e2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/account/AccountRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountApi.kt @@ -1,17 +1,18 @@ -package com.meloda.fast.api.network.account +package com.meloda.fast.data.account import com.meloda.fast.api.base.ApiResponse -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.account.AccountUrls import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.QueryMap -interface AccountRepo { +interface AccountApi { @GET(AccountUrls.SetOnline) - suspend fun setOnline(@QueryMap params: Map): Answer> + suspend fun setOnline(@QueryMap params: Map): ApiAnswer> @POST(AccountUrls.SetOffline) - suspend fun setOffline(@QueryMap params: Map): Answer> + suspend fun setOffline(@QueryMap params: Map): ApiAnswer> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt new file mode 100644 index 00000000..ed138f4e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.data.account + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.meloda.fast.model.AppAccount + +@Dao +interface AccountsDao { + + @Query("SELECT * FROM accounts") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(values: List) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt new file mode 100644 index 00000000..cf427fab --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsRepository.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.data.account + +import com.meloda.fast.api.network.account.AccountSetOfflineRequest +import com.meloda.fast.api.network.account.AccountSetOnlineRequest + +class AccountsRepository( + private val accountApi: AccountApi, + private val accountsDao: AccountsDao +) { + + suspend fun setOnline(params: AccountSetOnlineRequest) = accountApi.setOnline(params.map) + + suspend fun setOffline(params: AccountSetOfflineRequest) = accountApi.setOffline(params.map) + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt new file mode 100644 index 00000000..1a3a33d3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosApi.kt @@ -0,0 +1,28 @@ +package com.meloda.fast.data.audios + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.model.base.attachments.BaseVkAudio +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.audio.AudiosGetUploadServerResponse +import com.meloda.fast.api.network.audio.AudiosUploadResponse +import com.meloda.fast.api.network.audio.AudiosUrls +import okhttp3.MultipartBody +import retrofit2.http.* + +interface AudiosApi { + + @POST(AudiosUrls.GetUploadServer) + suspend fun getUploadServer(): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(AudiosUrls.Save) + suspend fun save(@FieldMap map: Map): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt new file mode 100644 index 00000000..5a6a145e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/audios/AudiosRepository.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.data.audios + +import okhttp3.MultipartBody + +class AudiosRepository( + private val audiosApi: AudiosApi +) { + + suspend fun getUploadServer() = audiosApi.getUploadServer() + + suspend fun upload(url: String, file: MultipartBody.Part) = audiosApi.upload(url, file) + + suspend fun save(server: Int, audio: String, hash: String) = audiosApi.save( + mapOf( + "server" to server.toString(), + "audio" to audio, + "hash" to hash + ) + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt new file mode 100644 index 00000000..feb0bf98 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthApi.kt @@ -0,0 +1,19 @@ +package com.meloda.fast.data.auth + +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.auth.AuthDirectResponse +import com.meloda.fast.api.network.auth.AuthUrls +import com.meloda.fast.api.network.auth.SendSmsResponse +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.QueryMap + +interface AuthApi { + + @GET(AuthUrls.DirectAuth) + suspend fun auth(@QueryMap param: Map): ApiAnswer + + @GET(AuthUrls.SendSms) + suspend fun sendSms(@Query("sid") validationSid: String): ApiAnswer + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt new file mode 100644 index 00000000..9042d9e5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/auth/AuthRepository.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.data.auth + +import com.meloda.fast.api.network.auth.AuthDirectRequest + +class AuthRepository( + private val authApi: AuthApi +) { + + suspend fun auth(params: AuthDirectRequest) = authApi.auth(params.map) + + suspend fun sendSms(validationSid: String) = authApi.sendSms(validationSid) + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt new file mode 100644 index 00000000..02b939e9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.conversations + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.conversations.ConversationsGetResponse +import com.meloda.fast.api.network.conversations.ConversationsUrls +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface ConversationsApi { + + @FormUrlEncoded + @POST(ConversationsUrls.Get) + suspend fun get(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Delete) + suspend fun delete(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Pin) + suspend fun pin(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.Unpin) + suspend fun unpin(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(ConversationsUrls.ReorderPinned) + suspend fun reorderPinned(@FieldMap params: Map): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt similarity index 76% rename from app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt index 218dcd15..57a1aa5e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/ConversationsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.conversations import androidx.room.Dao import androidx.room.Insert @@ -15,6 +15,4 @@ interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(values: List) - suspend fun insert(values: Array) = insert(values.toList()) - } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt new file mode 100644 index 00000000..c9161d2e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.data.conversations + +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.network.conversations.ConversationsDeleteRequest +import com.meloda.fast.api.network.conversations.ConversationsGetRequest +import com.meloda.fast.api.network.conversations.ConversationsPinRequest +import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest +import kotlinx.coroutines.sync.Mutex + +class ConversationsRepository( + private val conversationsApi: ConversationsApi, + private val conversationsDao: ConversationsDao +) { + + suspend fun get(params: ConversationsGetRequest) = conversationsApi.get(params.map) + + suspend fun delete(params: ConversationsDeleteRequest) = conversationsApi.delete(params.map) + + suspend fun pin(params: ConversationsPinRequest) = conversationsApi.pin(params.map) + + suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map) + + suspend fun store(conversations: List) = conversationsDao.insert(conversations) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt b/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt new file mode 100644 index 00000000..73cdad7e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/files/FilesApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.files + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.files.FilesGetMessagesUploadServerResponse +import com.meloda.fast.api.network.files.FilesSaveFileResponse +import com.meloda.fast.api.network.files.FilesUploadFileResponse +import com.meloda.fast.api.network.files.FilesUrls +import okhttp3.MultipartBody +import retrofit2.http.* + +interface FilesApi { + + @FormUrlEncoded + @POST(FilesUrls.GetMessagesUploadServer) + suspend fun getUploadServer( + @FieldMap map: Map + ): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(FilesUrls.Save) + suspend fun save( + @FieldMap map: Map + ): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt new file mode 100644 index 00000000..ec894800 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/files/FilesRepository.kt @@ -0,0 +1,30 @@ +package com.meloda.fast.data.files + +import com.google.gson.annotations.SerializedName +import okhttp3.MultipartBody + +class FilesRepository( + private val filesApi: FilesApi +) { + + enum class FileType(val value: String) { + @SerializedName("doc") + File("doc"), + + @SerializedName("audio_message") + VoiceMessage("audio_message") + } + + suspend fun getMessagesUploadServer(peerId: Int, type: FileType) = + filesApi.getUploadServer( + mapOf( + "peer_id" to peerId.toString(), + "type" to type.value + ) + ) + + suspend fun uploadFile(url: String, file: MultipartBody.Part) = filesApi.upload(url, file) + + suspend fun saveMessageFile(file: String) = filesApi.save(mapOf("file" to file)) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt similarity index 93% rename from app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt index 963c7b22..87d7ae6e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/GroupsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.groups import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt new file mode 100644 index 00000000..50ba5c0d --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/groups/GroupsRepository.kt @@ -0,0 +1,6 @@ +package com.meloda.fast.data.groups + +class GroupsRepository( + private val groupsDao: GroupsDao +) { +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt similarity index 63% rename from app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt index 14416913..a2ab6688 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/longpoll/LongPollRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/longpoll/LongPollApi.kt @@ -1,17 +1,17 @@ -package com.meloda.fast.api.network.longpoll +package com.meloda.fast.data.longpoll import com.google.gson.JsonObject -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer import retrofit2.http.GET import retrofit2.http.QueryMap import retrofit2.http.Url -interface LongPollRepo { +interface LongPollApi { @GET suspend fun getResponse( @Url serverUrl: String, @QueryMap params: Map - ): Answer + ): ApiAnswer } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt similarity index 50% rename from app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt index 3d61be52..e8faab88 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt @@ -1,49 +1,56 @@ -package com.meloda.fast.api.network.messages +package com.meloda.fast.data.messages import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.model.base.BaseVkLongPoll import com.meloda.fast.api.model.base.BaseVkMessage -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.messages.MessagesGetByIdResponse +import com.meloda.fast.api.network.messages.MessagesGetHistoryResponse +import com.meloda.fast.api.network.messages.MessagesUrls import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST -interface MessagesRepo { +interface MessagesApi { @FormUrlEncoded @POST(MessagesUrls.GetHistory) - suspend fun getHistory(@FieldMap params: Map): Answer> + suspend fun getHistory(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Send) - suspend fun send(@FieldMap params: Map): Answer> + suspend fun send(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.MarkAsImportant) - suspend fun markAsImportant(@FieldMap params: Map): Answer>> + suspend fun markAsImportant(@FieldMap params: Map): ApiAnswer>> @FormUrlEncoded @POST(MessagesUrls.GetLongPollServer) - suspend fun getLongPollServer(@FieldMap params: Map): Answer> + suspend fun getLongPollServer(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Pin) - suspend fun pin(@FieldMap params: Map): Answer> + suspend fun pin(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Unpin) - suspend fun unpin(@FieldMap params: Map): Answer> + suspend fun unpin(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Delete) - suspend fun delete(@FieldMap params: Map): Answer> + suspend fun delete(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.Edit) - suspend fun edit(@FieldMap params: Map): Answer> + suspend fun edit(@FieldMap params: Map): ApiAnswer> @FormUrlEncoded @POST(MessagesUrls.GetById) - suspend fun getById(@FieldMap params: Map): Answer> + suspend fun getById(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(MessagesUrls.MarkAsRead) + suspend fun markAsRead(@FieldMap params: Map): ApiAnswer> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt similarity index 94% rename from app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt index 98fc7f94..0953ae5e 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/MessagesDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.messages import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt new file mode 100644 index 00000000..f3d9c653 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt @@ -0,0 +1,65 @@ +package com.meloda.fast.data.messages + +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.api.network.messages.* + +class MessagesRepository( + private val messagesApi: MessagesApi, + private val messagesDao: MessagesDao, + private val longPollApi: LongPollApi +) { + + suspend fun store(messages: List) = messagesDao.insert(messages) + + suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) + + suspend fun getHistory(params: MessagesGetHistoryRequest) = + messagesApi.getHistory(params.map) + + suspend fun send(params: MessagesSendRequest) = + messagesApi.send(params.map) + + suspend fun markAsImportant(params: MessagesMarkAsImportantRequest) = + messagesApi.markAsImportant(params.map) + + suspend fun getLongPollServer(params: MessagesGetLongPollServerRequest) = + messagesApi.getLongPollServer(params.map) + + suspend fun pin(params: MessagesPinMessageRequest) = + messagesApi.pin(params.map) + + suspend fun unpin(params: MessagesUnPinMessageRequest) = + messagesApi.unpin(params.map) + + suspend fun delete(params: MessagesDeleteRequest) = + messagesApi.delete(params.map) + + suspend fun edit(params: MessagesEditRequest) = + messagesApi.edit(params.map) + + suspend fun getLongPollUpdates( + serverUrl: String, + params: LongPollGetUpdatesRequest + ) = longPollApi.getResponse(serverUrl, params.map) + + suspend fun getById(params: MessagesGetByIdRequest) = + messagesApi.getById(params.map) + + suspend fun markAsRead( + peerId: Int, + messagesIds: List? = null, + startMessageId: Int? = null + ) = messagesApi.markAsRead( + mutableMapOf("peer_id" to peerId.toString()).apply { + messagesIds?.let { + this["message_ids"] = messagesIds.joinToString { it.toString() } + } + startMessageId?.let { + this["start_message_id"] = it.toString() + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt b/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt new file mode 100644 index 00000000..68ea9737 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/ota/OtaApi.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.data.ota + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse +import com.meloda.fast.api.network.ota.OtaUrls +import com.meloda.fast.model.UpdateActualUrl +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query +import retrofit2.http.Url + +interface OtaApi { + + @GET(OtaUrls.GetActualUrl) + suspend fun getActualUrl(): ApiAnswer + + @GET + suspend fun getLatestRelease( + @Url url: String, + @Query("productId") productId: Int = 28, + @Query("branchId") branchId: Int = 10, + @Header("Secret-Code") secretCode: String + ): ApiAnswer> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt new file mode 100644 index 00000000..863388e0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosApi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.data.photos + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.model.base.attachments.BaseVkPhoto +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.photos.PhotoUrls +import com.meloda.fast.api.network.photos.PhotosGetMessagesUploadServerResponse +import com.meloda.fast.api.network.photos.PhotosUploadPhotoResponse +import okhttp3.MultipartBody +import retrofit2.http.* + +interface PhotosApi { + + @FormUrlEncoded + @POST(PhotoUrls.GetMessagesUploadServer) + suspend fun getUploadServer( + @FieldMap map: Map + ): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part photo: MultipartBody.Part + ): ApiAnswer + + @FormUrlEncoded + @POST(PhotoUrls.SaveMessagePhoto) + suspend fun save( + @FieldMap map: Map + ): ApiAnswer>> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt new file mode 100644 index 00000000..e143c7a6 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/photos/PhotosRepository.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.data.photos + +import com.meloda.fast.api.network.photos.PhotosSaveMessagePhotoRequest +import okhttp3.MultipartBody + +class PhotosRepository( + private val photosApi: PhotosApi +) { + + suspend fun getMessagesUploadServer(peerId: Int) = + photosApi.getUploadServer(mapOf("peer_id" to peerId.toString())) + + suspend fun uploadPhoto(url: String, photo: MultipartBody.Part) = photosApi.upload(url, photo) + + suspend fun saveMessagePhoto(body: PhotosSaveMessagePhotoRequest) = + photosApi.save(body.map) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt similarity index 61% rename from app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt rename to app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt index 229e13c1..7574536a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/users/UsersRepo.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersApi.kt @@ -1,18 +1,19 @@ -package com.meloda.fast.api.network.users +package com.meloda.fast.data.users import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.model.base.BaseVkUser -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.users.UsersUrls import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST -interface UsersRepo { +interface UsersApi { @FormUrlEncoded @POST(UsersUrls.GetById) suspend fun getById( @FieldMap params: Map? - ): Answer>> + ): ApiAnswer>> } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt similarity index 93% rename from app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt rename to app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt index 0d7801b1..3d74af1d 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/dao/UsersDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersDao.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.database.dao +package com.meloda.fast.data.users import androidx.room.Dao import androidx.room.Insert diff --git a/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt new file mode 100644 index 00000000..f41f0b17 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/users/UsersRepository.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.data.users + +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.network.users.UsersGetRequest + +class UsersRepository( + private val usersApi: UsersApi, + private val usersDao: UsersDao +) { + + suspend fun getById(params: UsersGetRequest) = usersApi.getById(params.map) + + suspend fun storeUsers(users: List) { + usersDao.insert(users) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt new file mode 100644 index 00000000..1b244480 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosApi.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.data.videos + +import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.network.ApiAnswer +import com.meloda.fast.api.network.videos.VideosSaveResponse +import com.meloda.fast.api.network.videos.VideosUploadResponse +import com.meloda.fast.api.network.videos.VideosUrls +import okhttp3.MultipartBody +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Url + +interface VideosApi { + + @POST(VideosUrls.Save) + suspend fun save(): ApiAnswer> + + @Multipart + @POST + suspend fun upload( + @Url url: String, + @Part file: MultipartBody.Part + ): ApiAnswer + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt new file mode 100644 index 00000000..0b9f2866 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/data/videos/VideosRepository.kt @@ -0,0 +1,13 @@ +package com.meloda.fast.data.videos + +import okhttp3.MultipartBody + +class VideosRepository( + private val videosApi: VideosApi +) { + + suspend fun save() = videosApi.save() + + suspend fun upload(url: String, file: MultipartBody.Part) = videosApi.upload(url, file) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt index d3db872b..a2640d66 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt @@ -1,5 +1,6 @@ package com.meloda.fast.database +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -7,27 +8,34 @@ import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.database.dao.ConversationsDao -import com.meloda.fast.database.dao.GroupsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.data.conversations.ConversationsDao +import com.meloda.fast.data.groups.GroupsDao +import com.meloda.fast.data.messages.MessagesDao +import com.meloda.fast.data.users.UsersDao +import com.meloda.fast.model.AppAccount @Database( entities = [ + AppAccount::class, VkConversation::class, VkMessage::class, VkUser::class, VkGroup::class ], - version = 28, - exportSchema = false, + version = 34, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 33, to = 34) + ] ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { - abstract fun conversationsDao(): ConversationsDao - abstract fun messagesDao(): MessagesDao - abstract fun usersDao(): UsersDao - abstract fun groupsDao(): GroupsDao + abstract val accountsDao: AccountsDao + abstract val conversationsDao: ConversationsDao + abstract val messagesDao: MessagesDao + abstract val usersDao: UsersDao + abstract val groupsDao: GroupsDao } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt index 400d3633..73472b37 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.gson.Gson import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.base.BaseVkMessage import org.json.JSONObject @Suppress("UnnecessaryVariable") @@ -13,6 +14,24 @@ class Converters { private const val CACHE_SEPARATOR = "fastkruta228355" } + @TypeConverter + fun fromGeoToString(geo: BaseVkMessage.Geo?): String? { + if (geo == null) return null + + val string = Gson().toJson(geo) + + return string + } + + @TypeConverter + fun fromStringToGeo(string: String?): BaseVkMessage.Geo? { + if (string == null) return null + + val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java) + + return geo + } + @TypeConverter fun fromListVkMessageToString(messages: List?): String? { if (messages == null) return null @@ -49,7 +68,9 @@ class Converters { fun fromStringToVkMessage(string: String?): VkMessage? { if (string == null) return null - return Gson().fromJson(string, VkMessage::class.java) + val message = Gson().fromJson(string, VkMessage::class.java) + + return message } @TypeConverter @@ -82,7 +103,9 @@ class Converters { fun fromVkAttachmentToString(attachment: VkAttachment?): String? { if (attachment == null) return null - return Gson().toJson(attachment) + val string = Gson().toJson(attachment) + + return string } @TypeConverter @@ -91,6 +114,8 @@ class Converters { val className = JSONObject(string).optString("className") - return Gson().fromJson(string, Class.forName(className)) as VkAttachment? + val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment? + + return attachment } } diff --git a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt new file mode 100644 index 00000000..890f763f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt @@ -0,0 +1,103 @@ +package com.meloda.fast.di + +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.data.account.AccountApi +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.data.account.AccountsRepository +import com.meloda.fast.data.audios.AudiosApi +import com.meloda.fast.data.audios.AudiosRepository +import com.meloda.fast.data.auth.AuthApi +import com.meloda.fast.data.auth.AuthRepository +import com.meloda.fast.data.conversations.ConversationsApi +import com.meloda.fast.data.conversations.ConversationsDao +import com.meloda.fast.data.conversations.ConversationsRepository +import com.meloda.fast.data.files.FilesApi +import com.meloda.fast.data.files.FilesRepository +import com.meloda.fast.data.groups.GroupsDao +import com.meloda.fast.data.groups.GroupsRepository +import com.meloda.fast.data.messages.MessagesApi +import com.meloda.fast.data.messages.MessagesDao +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.photos.PhotosApi +import com.meloda.fast.data.photos.PhotosRepository +import com.meloda.fast.data.users.UsersApi +import com.meloda.fast.data.users.UsersDao +import com.meloda.fast.data.users.UsersRepository +import com.meloda.fast.data.videos.VideosApi +import com.meloda.fast.data.videos.VideosRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object DataModule { + + @Singleton + @Provides + fun provideConversationsRepository( + conversationsApi: ConversationsApi, + conversationsDao: ConversationsDao + ): ConversationsRepository = ConversationsRepository(conversationsApi, conversationsDao) + + @Singleton + @Provides + fun provideMessagesRepository( + messagesApi: MessagesApi, + messagesDao: MessagesDao, + longPollApi: LongPollApi + ): MessagesRepository = MessagesRepository(messagesApi, messagesDao, longPollApi) + + @Singleton + @Provides + fun provideUsersRepository( + usersApi: UsersApi, + usersDao: UsersDao + ): UsersRepository = UsersRepository(usersApi, usersDao) + + @Singleton + @Provides + fun provideGroupsRepository( + groupsDao: GroupsDao + ): GroupsRepository = GroupsRepository(groupsDao) + + @Singleton + @Provides + fun provideAuthRepository( + authApi: AuthApi + ): AuthRepository = AuthRepository(authApi) + + @Singleton + @Provides + fun provideAccountsRepository( + accountApi: AccountApi, + accountsDao: AccountsDao + ): AccountsRepository = AccountsRepository(accountApi, accountsDao) + + @Singleton + @Provides + fun providePhotosRepository( + photosApi: PhotosApi + ): PhotosRepository = PhotosRepository(photosApi) + + @Singleton + @Provides + fun provideVideosRepository( + videosApi: VideosApi + ): VideosRepository = VideosRepository(videosApi) + + @Singleton + @Provides + fun provideAudiosRepository( + audiosApi: AudiosApi + ): AudiosRepository = AudiosRepository(audiosApi) + + @Singleton + @Provides + fun provideFilesRepository( + filesApi: FilesApi + ): FilesRepository = FilesRepository(filesApi) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt index 8b3900d9..af8962e8 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt @@ -1,11 +1,12 @@ package com.meloda.fast.di import com.meloda.fast.common.AppGlobal +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.data.conversations.ConversationsDao +import com.meloda.fast.data.groups.GroupsDao +import com.meloda.fast.data.messages.MessagesDao +import com.meloda.fast.data.users.UsersDao import com.meloda.fast.database.AppDatabase -import com.meloda.fast.database.dao.ConversationsDao -import com.meloda.fast.database.dao.GroupsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,22 +24,27 @@ object DatabaseModule { @Provides @Singleton - fun provideUsersDao(appDatabase: AppDatabase): UsersDao = - appDatabase.usersDao() + fun provideAccountsDao(appDatabase: AppDatabase): AccountsDao = + appDatabase.accountsDao @Provides @Singleton fun provideConversationsDao(appDatabase: AppDatabase): ConversationsDao = - appDatabase.conversationsDao() + appDatabase.conversationsDao @Provides @Singleton fun provideMessagesDao(appDatabase: AppDatabase): MessagesDao = - appDatabase.messagesDao() + appDatabase.messagesDao + + @Provides + @Singleton + fun provideUsersDao(appDatabase: AppDatabase): UsersDao = + appDatabase.usersDao @Provides @Singleton fun provideGroupsDao(appDatabase: AppDatabase): GroupsDao = - appDatabase.groupsDao() + appDatabase.groupsDao } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt index f94afeb4..ecb81be5 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt @@ -1,24 +1,27 @@ package com.meloda.fast.di +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.ChuckerInterceptor import com.google.gson.Gson import com.google.gson.GsonBuilder -import com.meloda.fast.api.LongPollUpdatesParser +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.network.AuthInterceptor import com.meloda.fast.api.network.ResultCallFactory -import com.meloda.fast.api.network.account.AccountDataSource -import com.meloda.fast.api.network.account.AccountRepo -import com.meloda.fast.api.network.auth.AuthDataSource -import com.meloda.fast.api.network.auth.AuthRepo -import com.meloda.fast.api.network.conversations.ConversationsDataSource -import com.meloda.fast.api.network.conversations.ConversationsRepo -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.api.network.messages.MessagesDataSource -import com.meloda.fast.api.network.messages.MessagesRepo -import com.meloda.fast.api.network.users.UsersDataSource -import com.meloda.fast.api.network.users.UsersRepo -import com.meloda.fast.database.dao.ConversationsDao -import com.meloda.fast.database.dao.MessagesDao -import com.meloda.fast.database.dao.UsersDao +import com.meloda.fast.api.network.VkUrls +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.data.account.AccountApi +import com.meloda.fast.data.audios.AudiosApi +import com.meloda.fast.data.auth.AuthApi +import com.meloda.fast.data.conversations.ConversationsApi +import com.meloda.fast.data.files.FilesApi +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.data.messages.MessagesApi +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.ota.OtaApi +import com.meloda.fast.data.photos.PhotosApi +import com.meloda.fast.data.users.UsersApi +import com.meloda.fast.data.videos.VideosApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,18 +37,68 @@ import javax.inject.Singleton @Module object NetworkModule { + /* + + val chuckerCollector = ChuckerCollector( + context = this, + // Toggles visibility of the notification + showNotification = true, + // Allows to customize the retention period of collected data + retentionPeriod = RetentionManager.Period.ONE_HOUR +) + +// Create the Interceptor +val chuckerInterceptor = ChuckerInterceptor.Builder(context) + // The previously created Collector + .collector(chuckerCollector) + // The max body content length in bytes, after this responses will be truncated. + .maxContentLength(250_000L) + // List of headers to replace with ** in the Chucker UI + .redactHeaders("Auth-Token", "Bearer") + // Read the whole response body even when the client does not consume the response completely. + // This is useful in case of parsing errors or when the response body + // is closed before being read like in Retrofit with Void and Unit types. + .alwaysReadResponseBody(true) + // Use decoder when processing request and response bodies. When multiple decoders are installed they + // are applied in an order they were added. + .addBodyDecoder(decoder) + // Controls Android shortcut creation. Available in SNAPSHOTS versions only at the moment + .createShortcut(true) + .build() + */ + @Singleton @Provides - fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(20, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - }).build() + fun provideChuckerCollector(): ChuckerCollector = + ChuckerCollector(AppGlobal.Instance) + + @Singleton + @Provides + fun provideChuckerInterceptor( + chuckerCollector: ChuckerCollector + ): ChuckerInterceptor = + ChuckerInterceptor.Builder(AppGlobal.Instance) + .collector(chuckerCollector) + .build() + + @Singleton + @Provides + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + chuckerInterceptor: ChuckerInterceptor + ): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor(chuckerInterceptor) + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + ).build() @Singleton @Provides @@ -59,7 +112,7 @@ object NetworkModule { client: OkHttpClient, gson: Gson ): Retrofit = Retrofit.Builder() - .baseUrl("https://api.vk.com/") + .baseUrl("${VkUrls.API}/") .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(ResultCallFactory()) .client(client) @@ -71,73 +124,67 @@ object NetworkModule { @Provides @Singleton - fun provideAuthRepo(retrofit: Retrofit): AuthRepo = - retrofit.create(AuthRepo::class.java) + fun provideAuthApi(retrofit: Retrofit): AuthApi = + retrofit.create(AuthApi::class.java) @Provides @Singleton - fun provideConversationsRepo(retrofit: Retrofit): ConversationsRepo = - retrofit.create(ConversationsRepo::class.java) + fun provideConversationsApi(retrofit: Retrofit): ConversationsApi = + retrofit.create(ConversationsApi::class.java) @Provides @Singleton - fun provideUsersRepo(retrofit: Retrofit): UsersRepo = - retrofit.create(UsersRepo::class.java) + fun provideUsersApi(retrofit: Retrofit): UsersApi = + retrofit.create(UsersApi::class.java) @Provides @Singleton - fun provideMessagesRepo(retrofit: Retrofit): MessagesRepo = - retrofit.create(MessagesRepo::class.java) + fun provideMessagesApi(retrofit: Retrofit): MessagesApi = + retrofit.create(MessagesApi::class.java) @Provides @Singleton - fun provideLongPollRepo(retrofit: Retrofit): LongPollRepo = - retrofit.create(LongPollRepo::class.java) + fun provideLongPollApi(retrofit: Retrofit): LongPollApi = + retrofit.create(LongPollApi::class.java) @Provides @Singleton - fun provideAuthDataSource( - repo: AuthRepo - ): AuthDataSource = AuthDataSource(repo) + fun provideLongPollUpdatesParser(messagesRepository: MessagesRepository): LongPollUpdatesParser = + LongPollUpdatesParser(messagesRepository) @Provides @Singleton - fun provideUsersDataSource( - repo: UsersRepo, - dao: UsersDao - ): UsersDataSource = UsersDataSource(repo, dao) + fun provideAccountApi(retrofit: Retrofit): AccountApi = + retrofit.create(AccountApi::class.java) @Provides @Singleton - fun provideConversationsDataSource( - repo: ConversationsRepo, - dao: ConversationsDao - ): ConversationsDataSource = ConversationsDataSource(repo, dao) + fun provideOtaApi(retrofit: Retrofit): OtaApi = + retrofit.create(OtaApi::class.java) @Provides @Singleton - fun provideMessagesDataSource( - messagesRepo: MessagesRepo, - messagesDao: MessagesDao, - longPollRepo: LongPollRepo - ): MessagesDataSource = MessagesDataSource( - messagesRepo = messagesRepo, - messagesDao = messagesDao, - longPollRepo = longPollRepo - ) + fun provideUpdateManager(otaApi: OtaApi): UpdateManager = + UpdateManager(otaApi) @Provides @Singleton - fun provideLongPollUpdatesParser(messagesDataSource: MessagesDataSource): LongPollUpdatesParser = - LongPollUpdatesParser(messagesDataSource) + fun providePhotosApi(retrofit: Retrofit): PhotosApi = + retrofit.create(PhotosApi::class.java) @Provides @Singleton - fun provideAccountRepo(retrofit: Retrofit): AccountRepo = - retrofit.create(AccountRepo::class.java) + fun provideVideosApi(retrofit: Retrofit): VideosApi = + retrofit.create(VideosApi::class.java) @Provides @Singleton - fun provideAccountDataSource(repo: AccountRepo): AccountDataSource = - AccountDataSource(repo) + fun provideAudiosApi(retrofit: Retrofit): AudiosApi = + retrofit.create(AudiosApi::class.java) + + @Provides + @Singleton + fun provideFilesApi(retrofit: Retrofit): FilesApi = + retrofit.create(FilesApi::class.java) + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt index 3a5ebdbc..1f2eb77b 100644 --- a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt +++ b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt @@ -2,17 +2,27 @@ package com.meloda.fast.extensions import android.animation.ValueAnimator import android.content.res.Resources -import android.os.Build +import android.graphics.drawable.Drawable import android.os.Parcelable import android.util.DisplayMetrics import android.util.SparseArray +import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.ImageView import android.widget.TextView +import androidx.annotation.ColorInt import androidx.annotation.Px -import androidx.annotation.StyleRes +import androidx.appcompat.widget.Toolbar import androidx.core.view.children +import androidx.core.view.forEach +import androidx.lifecycle.MutableLiveData +import com.google.common.net.MediaType +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.databinding.ToolbarMenuItemAvatarBinding +import com.meloda.fast.extensions.ImageLoader.loadWithGlide fun Int.dpToPx(): Int { val metrics = Resources.getSystem().displayMetrics @@ -52,6 +62,11 @@ fun ValueAnimator.startWithIntValues(from: Int, to: Int) { start() } +fun ValueAnimator.startWithFloatValues(from: Float, to: Float) { + setFloatValues(from, to) + start() +} + fun View.setMarginsPx( @Px leftMargin: Int? = null, @Px topMargin: Int? = null, @@ -84,4 +99,87 @@ fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) @JvmOverloads fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse -} \ No newline at end of file +} + +fun View.showKeyboard(flags: Int = 0) { + AppGlobal.inputMethodManager.showSoftInput(this, flags) +} + +fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) { + AppGlobal.inputMethodManager.hideSoftInputFromWindow( + focusedView?.windowToken ?: this.windowToken, flags + ) +} + +fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) { + menu.forEach { item -> + item.icon?.setTint(colorToTint) + } +} + +fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem { + val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate( + LayoutInflater.from(context), null, false + ) + + val avatarMenuItem = menu.add("Profile") + avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + avatarMenuItem.actionView = avatarMenuItemBinding.root + + val imageView = avatarMenuItemBinding.avatar + + when { + urlToLoad != null -> { + imageView.loadWithGlide( + url = urlToLoad, + transformations = ImageLoader.userAvatarTransformations + ) + } + drawable != null -> { + imageView.loadWithGlide( + drawable = drawable, + transformations = ImageLoader.userAvatarTransformations + ) + } + } + + return avatarMenuItem +} + +fun MutableLiveData.notifyObservers() { + this.value = this.value +} + +fun MutableLiveData.setIfNotEquals(item: T) { + if (this.value != item) this.value = item +} + +fun MutableLiveData.requireValue(): T { + return this.value!! +} + +val EditText.trimmedText: String get() = text.toString().trim() + +val MediaType.mimeType: String get() = "${type()}/${subtype()}" + +fun EditText.selectLast() { + setSelection(text.length) +} + +fun T?.requireNotNull(): T { + return requireNotNull(this) +} + + +fun String?.orDots(count: Int = 3): String { + return this ?: ("." * count) +} + +private operator fun String.times(count: Int): String { + val builder = StringBuilder() + for (i in 0 until count) { + builder.append(this) + } + + return builder.toString() +} diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt b/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt index 5ad156f9..04cf5275 100644 --- a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt +++ b/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt @@ -1,7 +1,6 @@ package com.meloda.fast.extensions import android.graphics.Bitmap -import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri @@ -33,10 +32,13 @@ object ImageLoader { uri: Uri? = null, drawableRes: Int? = null, drawable: Drawable? = null, - placeholderDrawable: Drawable = ColorDrawable(Color.TRANSPARENT), - errorDrawable: Drawable = placeholderDrawable, + placeholderDrawable: Drawable? = null, + placeholderColor: Int? = null, + errorDrawable: Drawable? = placeholderDrawable, + errorColor: Int? = null, crossFade: Boolean = false, - crossFadeDuration: Int = 200, + crossFadeDuration: Int? = null, + asCircle: Boolean = false, transformations: List = emptyList(), onLoadedAction: (() -> Unit)? = null, onFailedAction: (() -> Unit)? = null, @@ -53,16 +55,27 @@ object ImageLoader { else -> request.load(null as Drawable?) } + val transforms = transformations.toMutableList() + if (asCircle) { + transforms += TypeTransformations.CircleCrop + } + builder = builder - .apply(TypeTransformations.createRequestOptions(transformations)) - .error(errorDrawable) - .placeholder(placeholderDrawable) + .apply(TypeTransformations.createRequestOptions(transforms)) + .error( + errorDrawable + ?: if (errorColor != null) ColorDrawable(errorColor) else null + ) + .placeholder( + placeholderDrawable + ?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null + ) .addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction)) .diskCacheStrategy(cacheStrategy) .priority(priority) - if (crossFade) { - builder = builder.transition(withCrossFade(crossFadeDuration)) + if (crossFade || crossFadeDuration != null) { + builder = builder.transition(withCrossFade(crossFadeDuration ?: 200)) } builder.into(this) diff --git a/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt b/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt new file mode 100644 index 00000000..ca3bd121 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/AppAccount.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.model + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +@Entity(tableName = "accounts") +@Parcelize +data class AppAccount( + @PrimaryKey(autoGenerate = false) + val userId: Int, + val accessToken: String, + val fastToken: String? +) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt index f381fe69..965aa08c 100644 --- a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt +++ b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt @@ -1,6 +1,6 @@ package com.meloda.fast.model -sealed class DataItem { +abstract class DataItem { abstract val dataItemId: IdType object Header : DataItem() { diff --git a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt new file mode 100644 index 00000000..208b2b9a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.model + +import android.os.Parcelable +import com.google.gson.Gson +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UpdateItem( + val id: Int, + val versionName: String, + val versionCode: Int, + val mandatory: Int, + val changelog: String?, + val enabled: Int, + val fileName: String, + val date: Long, + val extension: String, + val originalName: String, + val fileSize: Int, + val preRelease: Int, + val downloadLink: String +) : Parcelable { + + fun isMandatory(): Boolean = mandatory == 1 + fun isEnabled(): Boolean = enabled == 1 + fun isPreRelease(): Boolean = preRelease == 1 + + override fun toString(): String { + return Gson().toJson(this) + } + +} + +@Parcelize +data class UpdateActualUrl(val url: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt new file mode 100644 index 00000000..5a80ffda --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/receiver/DownloadManagerReceiver.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class DownloadManagerReceiver : BroadcastReceiver() { + + var onReceiveAction: (() -> Unit)? = null + + override fun onReceive(context: Context, intent: Intent) { + onReceiveAction?.invoke() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt deleted file mode 100644 index fe60a687..00000000 --- a/app/src/main/kotlin/com/meloda/fast/receiver/MinuteReceiver.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.meloda.fast.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.meloda.fast.common.TimeManager - -class MinuteReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context?, intent: Intent?) { - TimeManager.broadcastMinute() - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt index 24803dac..5ac71de2 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt @@ -3,40 +3,54 @@ package com.meloda.fast.screens.conversations import android.content.Context import android.content.res.ColorStateList import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import android.text.SpannableString import android.text.TextUtils import android.text.style.ForegroundColorSpan import android.view.ViewGroup -import androidx.core.content.ContextCompat import androidx.core.util.ObjectsCompat import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.recyclerview.widget.DiffUtil import com.meloda.fast.R import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkUser import com.meloda.fast.base.adapter.BaseAdapter -import com.meloda.fast.base.adapter.BindingHolder +import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemConversationBinding -import com.meloda.fast.extensions.ImageLoader +import com.meloda.fast.extensions.* import com.meloda.fast.extensions.ImageLoader.clear import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.extensions.visible import com.meloda.fast.util.TimeUtils class ConversationsAdapter constructor( context: Context, - private val resourceManager: ConversationsResourceManager, + private val resourceManager: ConversationsResourceProvider, var isMultilineEnabled: Boolean = true, val profiles: HashMap = hashMapOf(), val groups: HashMap = hashMapOf(), -) : BaseAdapter(context, Comparator) { +) : BaseAdapter(context, comparator) { + + companion object { + private val comparator = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VkConversation, + newItem: VkConversation + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: VkConversation, + newItem: VkConversation + ): Boolean { + return ObjectsCompat.equals(oldItem, newItem) + } + } + } var pinnedCount = 0 @@ -48,47 +62,73 @@ class ConversationsAdapter constructor( } inner class ItemHolder( - binding: ItemConversationBinding, - private val resourceManager: ConversationsResourceManager - ) : BindingHolder(binding) { - - init { - binding.title.ellipsize = TextUtils.TruncateAt.END - binding.message.ellipsize = TextUtils.TruncateAt.END - } + private val binding: ItemConversationBinding, + private val resourceManager: ConversationsResourceProvider + ) : BaseHolder(binding.root) { override fun bind(position: Int) { val conversation = getItem(position) - binding.service.isVisible = conversation.isPhantom || conversation.callInProgress - binding.callIcon.isVisible = conversation.callInProgress - binding.phantomIcon.isVisible = conversation.isPhantom + if (conversation.isAccount()) { + binding.service.gone() + binding.callIcon.gone() + binding.phantomIcon.gone() + } else { + binding.service.toggleVisibility(conversation.isPhantom || conversation.callInProgress) + binding.callIcon.toggleVisibility(conversation.callInProgress) + binding.phantomIcon.toggleVisibility(conversation.isPhantom) + } val maxLines = if (isMultilineEnabled) 2 else 1 binding.title.maxLines = maxLines binding.message.maxLines = maxLines - val message = if (conversation.lastMessage != null) conversation.lastMessage!! - else { - binding.title.text = conversation.title - val text = context.getString( - if (conversation.isPhantom) R.string.messages_self_destructed - else R.string.no_messages - ) + val message = + if (conversation.lastMessage != null) requireNotNull(conversation.lastMessage) + else { + binding.title.text = conversation.title + val text = context.getString( + if (conversation.isPhantom) R.string.messages_self_destructed + else R.string.no_messages + ) - val span = SpannableString(text) - span.setSpan(ForegroundColorSpan(resourceManager.colorOutline), 0, text.length, 0) + val span = SpannableString(text) + span.setSpan( + ForegroundColorSpan(resourceManager.colorOutline), + 0, + text.length, + 0 + ) - binding.message.text = span - return - } + binding.message.text = span + return + } - val conversationUser = VkUtils.getConversationUser(conversation, profiles) - val conversationGroup = VkUtils.getConversationGroup(conversation, groups) + val conversationUserGroup = + VkUtils.getConversationUserGroup(conversation, profiles, groups) + val messageUserGroup = VkUtils.getMessageUserGroup(message, profiles, groups) - val messageUser = VkUtils.getMessageUser(message, profiles) - val messageGroup = VkUtils.getMessageGroup(message, groups) + val conversationUser = conversationUserGroup.first + val conversationGroup = conversationUserGroup.second + + val messageUser = messageUserGroup.first + val messageGroup = messageUserGroup.second + + val title = VkUtils.getConversationTitle( + context = context, + conversation = conversation, + defConversationUser = conversationUser, + defConversationGroup = conversationGroup + ) + + binding.title.text = title.orDots() + + binding.online.toggleVisibility( + !conversation.isAccount() && conversationUser?.online == true + ) + + binding.pin.toggleVisibility(conversation.isPinned()) val avatar = VkUtils.getConversationAvatar( conversation = conversation, @@ -99,17 +139,18 @@ class ConversationsAdapter constructor( binding.avatar.toggleVisibility(avatar != null) if (avatar == null) { + binding.avatar.clear() binding.avatarPlaceholder.visible() - if (conversation.ownerId == VKConstants.FAST_GROUP_ID) { + if (conversation.isAccount()) { binding.placeholderBack.loadWithGlide( drawable = ColorDrawable(resourceManager.icLauncherColor), transformations = ImageLoader.userAvatarTransformations ) binding.placeholder.imageTintList = ColorStateList.valueOf(resourceManager.colorOnPrimary) - binding.placeholder.setImageResource(R.drawable.ic_fast_logo) - binding.placeholder.setPadding(18) + binding.placeholder.setImageResource(R.drawable.ic_round_bookmark_border_24) + binding.placeholder.setPadding(36) } else { binding.placeholderBack.loadWithGlide( drawable = ColorDrawable(resourceManager.colorOnUserAvatarAction), @@ -119,7 +160,6 @@ class ConversationsAdapter constructor( ColorStateList.valueOf(resourceManager.colorUserAvatarAction) binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut) binding.placeholder.setPadding(0) - binding.avatar.clear() } } else { binding.avatar.loadWithGlide( @@ -129,9 +169,6 @@ class ConversationsAdapter constructor( ) } - binding.online.toggleVisibility(conversationUser?.online == true) - binding.pin.toggleVisibility(conversation.isPinned) - val actionMessage = VkUtils.getActionConversationText( context = context, message = message, @@ -142,17 +179,17 @@ class ConversationsAdapter constructor( messageGroup = messageGroup ) - val attachmentIcon = - if (message.text == null) null - else if (!message.forwards.isNullOrEmpty()) ContextCompat.getDrawable( - context, - if (message.forwards?.size == 1) R.drawable.ic_attachment_forwarded_message - else R.drawable.ic_attachment_forwarded_messages - ) - else VkUtils.getAttachmentConversationIcon( - context = context, - message = message - ) + val attachmentIcon: Drawable? = when { + message.text == null -> null + !message.forwards.isNullOrEmpty() -> { + if (message.forwards?.size == 1) { + resourceManager.iconForwardedMessage + } else { + resourceManager.iconForwardedMessages + } + } + else -> VkUtils.getAttachmentConversationIcon(context, message) + } binding.textAttachment.toggleVisibility(attachmentIcon != null) binding.textAttachment.setImageDrawable(attachmentIcon) @@ -189,7 +226,6 @@ class ConversationsAdapter constructor( if ((!conversation.isChat() && !message.isOut) || conversation.id == UserConfig.userId) prefix = "" -// if (conversation.isChat() || message.isOut) { val spanText = "$prefix$coloredMessage$messageText" val spanMessage = SpannableString(spanText) @@ -201,31 +237,31 @@ class ConversationsAdapter constructor( binding.message.text = spanMessage - binding.title.text = - getItem(position).title ?: conversationUser?.toString() ?: conversationGroup?.name - ?: "..." - binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L) - binding.container.background = if (conversation.isUnread()) ContextCompat.getDrawable( - context, - R.drawable.ic_message_unread - ) else null + val showUnreadBackgroundCondition = + (message.isOut && conversation.isOutUnread()) || + (!message.isOut && conversation.isInUnread()) + + binding.container.background = + if (showUnreadBackgroundCondition) resourceManager.conversationUnreadBackground + else null binding.onlineBorder.setImageDrawable( ColorDrawable( - ContextCompat.getColor( - context, - if (conversation.isUnread()) R.color.colorBackgroundVariant - else R.color.colorBackground - ) + if (showUnreadBackgroundCondition) resourceManager.colorBackgroundVariant + else resourceManager.colorBackground ) ) - binding.counter.isVisible = conversation.isInUnread() - if (conversation.isInUnread()) { - conversation.unreadCount?.let { - val count = if (it > 999) "${it / 1000}K" else it.toString() + binding.counter.toggleVisibility( + !message.isOut && conversation.isInUnread() + ) + if (binding.counter.isVisible) { + if (conversation.unreadCount > 0) { + val count = + if (conversation.unreadCount > 999) "${conversation.unreadCount / 1000}K" + else conversation.unreadCount.toString() binding.counter.text = count } } else { @@ -249,27 +285,19 @@ class ConversationsAdapter constructor( fun searchConversationIndex(conversationId: Int): Int? { for (i in indices) { val conversation = getItem(i) - if (conversation.id == conversationId) return i } return null } - companion object { - private val Comparator = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ): Boolean { - return oldItem.id == newItem.id - } + override fun onQueryItem(item: VkConversation, query: String): Boolean { + val userGroup = VkUtils.getConversationUserGroup(item, profiles, groups) + val title = VkUtils.getConversationTitle(context, item, userGroup.first, userGroup.second) - override fun areContentsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ) = ObjectsCompat.equals(oldItem, newItem) - } + return title.orEmpty().contains(query, ignoreCase = true) || + item.lastMessage?.text.orEmpty().contains(query, ignoreCase = true) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt index 42a9cbea..6fbb34f4 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt @@ -2,34 +2,47 @@ package com.meloda.fast.screens.conversations import android.os.Bundle import android.view.Gravity +import android.view.MenuItem import android.view.View import android.viewbinding.library.fragment.viewBinding +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.addCallback +import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.datastore.preferences.core.edit +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.meloda.fast.R import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.base.BaseViewModelFragment -import com.meloda.fast.base.viewmodel.StartProgressEvent -import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.AppGlobal -import com.meloda.fast.common.AppSettings -import com.meloda.fast.common.dataStore +import com.meloda.fast.common.Screens import com.meloda.fast.databinding.FragmentConversationsBinding import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.extensions.addAvatarMenuItem import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.tintMenuItemIcons import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.screens.messages.MessagesHistoryFragment +import com.meloda.fast.screens.main.MainActivity +import com.meloda.fast.screens.main.MainFragment +import com.meloda.fast.screens.settings.SettingsPrefsFragment import com.meloda.fast.util.AndroidUtils +import com.meloda.fast.util.NotificationsUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @AndroidEntryPoint @@ -42,7 +55,7 @@ class ConversationsFragment : private val adapter: ConversationsAdapter by lazy { ConversationsAdapter( requireContext(), - ConversationsResourceManager(requireContext()) + ConversationsResourceProvider(requireContext()) ).also { it.itemClickListener = this::onItemClick it.itemLongClickListener = this::onItemLongClick @@ -53,56 +66,149 @@ class ConversationsFragment : get() = PopupMenu( requireContext(), - binding.avatar, - Gravity.BOTTOM + binding.toolbar, + Gravity.BOTTOM or Gravity.END ).apply { + menu.add("Settings") menu.add(getString(R.string.log_out)) setOnMenuItemClickListener { item -> - if (item.title == getString(R.string.log_out)) { - showLogOutDialog() - return@setOnMenuItemClickListener true + return@setOnMenuItemClickListener when (item.title) { + getString(R.string.log_out) -> { + showLogOutDialog() + true + } + "Settings" -> { + requireActivityRouter().navigateTo(Screens.Settings()) + true + } + else -> false } - - false } } + private var toggle: ActionBarDrawerToggle? = null + + private val useNavDrawer: Boolean get() = (requireActivity() as MainActivity).useNavDrawer + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + adapter.isMultilineEnabled = + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefMultiline, true) + prepareViews() binding.recyclerView.adapter = adapter - lifecycleScope.launch { - requireContext().dataStore.data.map { - adapter.isMultilineEnabled = it[AppSettings.keyIsMultilineEnabled] ?: true - adapter.refreshList() - }.collect() - } - binding.createChat.setOnClickListener {} - UserConfig.vkUser.observe(viewLifecycleOwner) { user -> - user?.run { binding.avatar.loadWithGlide(url = this.photo200, crossFade = true) } + binding.toolbar.tintMenuItemIcons( + ContextCompat.getColor( + requireContext(), + R.color.colorPrimary + ) + ) + + val searchMenuItem = binding.toolbar.menu.findItem(R.id.search) + val actionView = searchMenuItem.actionView as SearchView + + searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { + if (!adapter.isSearching) + adapter.isSearching = true + return true + } + + override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { + if (adapter.isSearching) + adapter.isSearching = false + return true + } + + }) + + actionView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + Toast.makeText(requireContext(), "API Search: $query", Toast.LENGTH_SHORT).show() + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + adapter.filter.filter(newText) + return false + } + + }) + + requireActivity().onBackPressedDispatcher.addCallback(this) { + if (searchMenuItem.isActionViewExpanded) { + searchMenuItem.collapseActionView() + } else { + isEnabled = false + requireActivity().onBackPressed() + } } - binding.avatar.setOnClickListener { avatarPopupMenu.show() } + val avatarMenuItem = binding.toolbar.addAvatarMenuItem() + syncAvatarMenuItem(avatarMenuItem) - binding.avatar.setOnLongClickListener { - lifecycleScope.launch { - requireContext().dataStore.edit { settings -> - val isMultilineEnabled = settings[AppSettings.keyIsMultilineEnabled] ?: true - settings[AppSettings.keyIsMultilineEnabled] = !isMultilineEnabled + UserConfig.vkUser.observe(viewLifecycleOwner) { user -> + user?.run { + avatarMenuItem.actionView?.findViewById(R.id.avatar) + ?.loadWithGlide( + url = this.photo200, crossFade = true, asCircle = true + ) - adapter.isMultilineEnabled = !isMultilineEnabled - adapter.refreshList() - } + val header = (requireActivity() as MainActivity).binding.drawer.getHeaderView(0) + header.findViewById(R.id.name).text = user.fullName + header.findViewById(R.id.avatar).loadWithGlide( + url = this.photo200, crossFade = true, asCircle = true + ) } - true + } + + avatarMenuItem.actionView?.run { + setOnClickListener { avatarPopupMenu.show() } } viewModel.loadProfileUser() viewModel.loadConversations() + + syncToolbarToggle() + + binding.createChat.gone() + + setFragmentResultListener(SettingsPrefsFragment.KeyChangeMultiline) { _, bundle -> + val enabled = bundle.getBoolean(SettingsPrefsFragment.ArgEnabled) + + if (adapter.isMultilineEnabled != enabled) { + adapter.isMultilineEnabled = enabled + adapter.refreshList() + } + } + } + + private fun syncAvatarMenuItem(item: MenuItem) { + item.isVisible = !useNavDrawer + } + + private fun syncToolbarToggle() { + (requireActivity() as MainActivity).let { activity -> + if (useNavDrawer) { + toggle = ActionBarDrawerToggle( + activity, activity.binding.drawerLayout, + binding.toolbar, R.string.app_name, R.string.app_name + ).apply { + isDrawerSlideAnimationEnabled = false + activity.binding.drawerLayout.addDrawerListener(this) + syncState() + } + } else { + toggle?.let { toggle -> + activity.binding.drawerLayout.removeDrawerListener(toggle) + } + } + } } private fun showLogOutDialog() { @@ -121,6 +227,10 @@ class ConversationsFragment : lifecycleScope.launch(Dispatchers.Default) { UserConfig.clear() AppGlobal.appDatabase.clearAllTables() + setFragmentResult( + MainFragment.KeyStartServices, + bundleOf("enable" to false) + ) viewModel.openRootScreen() } @@ -132,13 +242,9 @@ class ConversationsFragment : override fun onEvent(event: VkEvent) { super.onEvent(event) when (event) { - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() - is ConversationsLoadedEvent -> refreshConversations(event) is ConversationsDeleteEvent -> deleteConversation(event.peerId) - // TODO: 10-Oct-21 remove this and sort conversations list is ConversationsPinEvent -> { adapter.pinnedCount++ viewModel.loadConversations() @@ -150,17 +256,18 @@ class ConversationsFragment : is MessagesNewEvent -> onMessageNew(event) is MessagesEditEvent -> onMessageEdit(event) + is MessagesReadEvent -> onMessageRead(event) } } - private fun onProgressStarted() { - binding.progressBar.toggleVisibility(adapter.isEmpty()) - binding.refreshLayout.isRefreshing = adapter.isNotEmpty() - } - - private fun onProgressStopped() { - binding.progressBar.gone() - binding.refreshLayout.isRefreshing = false + override fun toggleProgress(isProgressing: Boolean) { + view?.run { + findViewById(R.id.progress_bar).toggleVisibility( + if (isProgressing) adapter.isEmpty() else false + ) + findViewById(R.id.refresh_layout).isRefreshing = + if (isProgressing) adapter.isNotEmpty() else false + } } private fun prepareViews() { @@ -186,7 +293,7 @@ class ConversationsFragment : setColorSchemeColors( AndroidUtils.getThemeAttrColor( requireContext(), - R.attr.colorAccent + R.attr.colorPrimary ) ) setOnRefreshListener { viewModel.loadConversations() } @@ -197,7 +304,16 @@ class ConversationsFragment : adapter.profiles += event.profiles adapter.groups += event.groups - val pinnedConversations = event.conversations.filter { it.isPinned } + if (event.avatars != null) { + event.avatars.forEach { avatar -> + Glide.with(requireContext()) + .load(avatar) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .preload(200, 200) + } + } + + val pinnedConversations = event.conversations.filter { it.isPinned() } adapter.pinnedCount = pinnedConversations.count() fillRecyclerView(event.conversations) @@ -218,13 +334,7 @@ class ConversationsFragment : if (conversation.isGroup()) adapter.groups[conversation.id] else null - viewModel.openMessagesHistoryScreen( - bundleOf( - MessagesHistoryFragment.ARG_USER to user, - MessagesHistoryFragment.ARG_GROUP to group, - MessagesHistoryFragment.ARG_CONVERSATION to conversation - ) - ) + viewModel.openMessagesHistoryScreen(conversation, user, group) } private fun onItemLongClick(position: Int): Boolean { @@ -237,17 +347,17 @@ class ConversationsFragment : var canPinOneMoreDialog = true if (adapter.itemCount > 4) { - val firstFiveDialogs = adapter.currentList.subList(0, 5) - var pinnedCount = 0 + val pinnedConversations = adapter.cloneCurrentList().filter { it.majorId > 0 } - firstFiveDialogs.forEach { if (it.isPinned) pinnedCount++ } - if (pinnedCount == 5 && position > 4) { + if (pinnedConversations.size == 5 && position > 4) { canPinOneMoreDialog = false } } + val read = "Mark as read" + val pin = getString( - if (conversation.isPinned) R.string.conversation_context_action_unpin + if (conversation.isPinned()) R.string.conversation_context_action_unpin else R.string.conversation_context_action_pin ) @@ -255,6 +365,12 @@ class ConversationsFragment : val params = mutableListOf() + conversation.lastMessage?.run { + if (!this.isRead(conversation) && !isOut) { + params += read + } + } + if (canPinOneMoreDialog) params += pin params += delete @@ -264,6 +380,7 @@ class ConversationsFragment : MaterialAlertDialogBuilder(requireContext()) .setItems(arrayParams) { _, which -> when (params[which]) { + read -> viewModel.readConversation(conversation) pin -> showPinConversationDialog(conversation) delete -> showDeleteConversationDialog(conversation.id) } @@ -276,7 +393,7 @@ class ConversationsFragment : .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteConversation(conversationId) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -285,7 +402,7 @@ class ConversationsFragment : } private fun showPinConversationDialog(conversation: VkConversation) { - val isPinned = conversation.isPinned + val isPinned = conversation.isPinned() MaterialAlertDialogBuilder(requireContext()) .setTitle( if (isPinned) R.string.confirm_unpin_conversation @@ -300,7 +417,7 @@ class ConversationsFragment : pin = !isPinned ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -312,24 +429,50 @@ class ConversationsFragment : val conversationIndex = adapter.searchConversationIndex(message.peerId) if (conversationIndex == null) { // диалога нет в списке - + // pizdets } else { val conversation = adapter[conversationIndex] - conversation.run { - lastMessage = message - lastMessageId = message.id + val newConversation = conversation.copy( + lastMessage = message, + lastMessageId = message.id, lastConversationMessageId = -1 + ) + if (!message.isOut) { + newConversation.unreadCount += 1 } - if (conversation.isPinned) { - adapter[conversationIndex] = conversation +// if (!message.isOut) { +// NotificationsUtils.showSimpleNotification( +// requireContext(), +// VkUtils.getConversationTitle( +// requireContext(), conversation, profiles = event.profiles, +// groups = event.groups +// ) ?: "...", +// "${ +// VkUtils.getMessageTitle( +// message, +// profiles = event.profiles, +// groups = event.groups +// ) ?: "..." +// }: ${message.text}", +// customNotificationId = message.id, +// showWhen = true, +// timeStampWhen = message.date * 1000L +// ) +// } + + if (conversation.isPinned()) { + adapter[conversationIndex] = newConversation return } - adapter.removeConversation(message.peerId) ?: return - val toPosition = adapter.pinnedCount + val newList = adapter.cloneCurrentList() + newList.removeAt(conversationIndex) - adapter.add(conversation, toPosition) + val toPosition = adapter.pinnedCount + newList.add(toPosition, newConversation) + + adapter.submitList(newList) } } @@ -341,8 +484,25 @@ class ConversationsFragment : } else { val conversation = adapter[conversationIndex] - conversation.lastMessage = message - adapter[conversationIndex] = conversation + adapter[conversationIndex] = conversation.copy( + lastMessage = message, + lastMessageId = message.id, + lastConversationMessageId = -1 + ) } } + + private fun onMessageRead(event: MessagesReadEvent) { + val conversationIndex = adapter.searchConversationIndex(event.peerId) ?: return + + val newConversation = adapter[conversationIndex].copy() + + if (event.isOut) { + newConversation.outRead = event.messageId + } else { + newConversation.inRead = event.messageId + } + + adapter[conversationIndex] = newConversation + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt deleted file mode 100644 index c561a4c4..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceManager.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.meloda.fast.screens.conversations - -import android.content.Context -import com.meloda.fast.R -import com.meloda.fast.base.ResourceManager -import com.meloda.fast.extensions.TypeTransformations - -class ConversationsResourceManager(context: Context) : ResourceManager(context) { - - val colorOutline = getColor(R.color.colorOutline) - val colorOnPrimary = getColor(R.color.colorOnPrimary) - val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) - val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) - - val icLauncherColor = getColor(R.color.a1_500) - - val youPrefix = getString(R.string.you_message_prefix) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt new file mode 100644 index 00000000..875f426a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.screens.conversations + +import android.content.Context +import com.meloda.fast.R +import com.meloda.fast.base.ResourceProvider + +class ConversationsResourceProvider(context: Context) : ResourceProvider(context) { + + val colorOutline = getColor(R.color.colorOutline) + val colorOnPrimary = getColor(R.color.colorOnPrimary) + val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) + val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) + val colorBackground = getColor(R.color.colorBackground) + val colorBackgroundVariant = getColor(R.color.colorBackgroundVariant) + + val icLauncherColor = getColor(R.color.a1_500) + + val youPrefix = getString(R.string.you_message_prefix) + + val conversationUnreadBackground get() = getDrawable(R.drawable.ic_message_unread) + + val iconForwardedMessages = getDrawable(R.drawable.ic_attachment_forwarded_messages) + val iconForwardedMessage = getDrawable(R.drawable.ic_attachment_forwarded_message) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt index 75dc08d9..733d61de 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt @@ -1,22 +1,24 @@ package com.meloda.fast.screens.conversations -import android.os.Bundle import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router -import com.meloda.fast.api.LongPollEvent -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.longpoll.LongPollEvent +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.network.conversations.* -import com.meloda.fast.api.network.users.UsersDataSource import com.meloda.fast.api.network.users.UsersGetRequest import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens +import com.meloda.fast.data.conversations.ConversationsRepository +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.users.UsersRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,16 +26,13 @@ import javax.inject.Inject @HiltViewModel class ConversationsViewModel @Inject constructor( - private val conversations: ConversationsDataSource, - private val users: UsersDataSource, + private val conversationsRepository: ConversationsRepository, + private val usersRepository: UsersRepository, updatesParser: LongPollUpdatesParser, - private val router: Router + private val router: Router, + private val messagesRepository: MessagesRepository ) : BaseViewModel() { - companion object { - private const val TAG = "ConversationsViewModel" - } - init { updatesParser.onNewMessage { viewModelScope.launch { handleNewMessage(it) } @@ -42,15 +41,23 @@ class ConversationsViewModel @Inject constructor( updatesParser.onMessageEdited { viewModelScope.launch { handleEditedMessage(it) } } + + updatesParser.onMessageIncomingRead { + viewModelScope.launch { handleReadIncomingMessage(it) } + } + + updatesParser.onMessageOutgoingRead { + viewModelScope.launch { handleReadOutgoingMessage(it) } + } } fun loadConversations( offset: Int? = null ) = viewModelScope.launch(Dispatchers.Default) { makeJob({ - conversations.get( + conversationsRepository.get( ConversationsGetRequest( - count = 30, + count = 100, extended = true, offset = offset, fields = VKConstants.ALL_FIELDS @@ -69,18 +76,29 @@ class ConversationsViewModel @Inject constructor( baseGroup.asVkGroup().let { group -> groups[group.id] = group } } + val conversations = response.items.map { items -> + items.conversation.asVkConversation( + items.lastMessage?.asVkMessage() + ) + } + + val avatars = conversations.mapNotNull { conversation -> + VkUtils.getConversationAvatar( + conversation, + if (conversation.isUser()) profiles[conversation.id] else null, + if (conversation.isGroup()) groups[conversation.id] else null + ) + } + sendEvent( ConversationsLoadedEvent( count = response.count, offset = offset, unreadCount = response.unreadCount ?: 0, - conversations = response.items.map { items -> - items.conversation.asVkConversation( - items.lastMessage?.asVkMessage() - ) - }, + conversations = conversations, profiles = profiles, - groups = groups + groups = groups, + avatars = avatars ) ) } @@ -89,11 +107,11 @@ class ConversationsViewModel @Inject constructor( } fun loadProfileUser() = viewModelScope.launch { - makeJob({ users.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) }, + makeJob({ usersRepository.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) }, onAnswer = { it.response?.let { r -> val users = r.map { u -> u.asVkUser() } - this@ConversationsViewModel.users.storeUsers(users) + this@ConversationsViewModel.usersRepository.storeUsers(users) UserConfig.vkUser.value = users[0] } @@ -102,7 +120,7 @@ class ConversationsViewModel @Inject constructor( fun deleteConversation(peerId: Int) = viewModelScope.launch { makeJob({ - conversations.delete( + conversationsRepository.delete( ConversationsDeleteRequest(peerId) ) }, onAnswer = { sendEvent(ConversationsDeleteEvent(peerId)) }) @@ -114,12 +132,12 @@ class ConversationsViewModel @Inject constructor( ) = viewModelScope.launch { if (pin) { makeJob( - { conversations.pin(ConversationsPinRequest(peerId)) }, + { conversationsRepository.pin(ConversationsPinRequest(peerId)) }, onAnswer = { sendEvent(ConversationsPinEvent(peerId)) } ) } else { makeJob( - { conversations.unpin(ConversationsUnpinRequest(peerId)) }, + { conversationsRepository.unpin(ConversationsUnpinRequest(peerId)) }, onAnswer = { sendEvent(ConversationsUnpinEvent(peerId)) } ) } @@ -139,13 +157,33 @@ class ConversationsViewModel @Inject constructor( sendEvent(MessagesEditEvent(event.message)) } - fun openRootScreen() { - router.exit() - router.newRootScreen(Screens.Main()) + private suspend fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) { + sendEvent(MessagesReadEvent(false, event.peerId, event.messageId)) } - fun openMessagesHistoryScreen(bundle: Bundle) { - router.navigateTo(Screens.MessagesHistory(bundle)) + private suspend fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) { + sendEvent(MessagesReadEvent(true, event.peerId, event.messageId)) + } + + fun openRootScreen() { + router.replaceScreen(Screens.Main()) + } + + fun openMessagesHistoryScreen( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ) { + router.navigateTo(Screens.MessagesHistory(conversation, user, group)) + } + + fun readConversation(conversation: VkConversation) { + makeJob( + { messagesRepository.markAsRead(conversation.id, startMessageId = conversation.lastMessageId) }, + onAnswer = { + sendEvent(MessagesReadEvent(false, conversation.id, conversation.lastMessageId)) + } + ) } } @@ -155,7 +193,8 @@ data class ConversationsLoadedEvent( val unreadCount: Int?, val conversations: List, val profiles: HashMap, - val groups: HashMap + val groups: HashMap, + val avatars: List? = null ) : VkEvent() data class ConversationsDeleteEvent(val peerId: Int) : VkEvent() @@ -170,4 +209,6 @@ data class MessagesNewEvent( val groups: HashMap ) : VkEvent() -data class MessagesEditEvent(val message: VkMessage) : VkEvent() \ No newline at end of file +data class MessagesEditEvent(val message: VkMessage) : VkEvent() + +data class MessagesReadEvent(val isOut: Boolean, val peerId: Int, val messageId: Int) : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt index 14169b8e..0e42eb3c 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt @@ -1,6 +1,7 @@ package com.meloda.fast.screens.login import android.annotation.SuppressLint +import android.content.Intent import android.graphics.Bitmap import android.graphics.Typeface import android.os.Bundle @@ -13,25 +14,29 @@ import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.activity.addCallback +import androidx.core.content.edit +import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import coil.load -import coil.transform.RoundedCornersTransformation -import com.google.android.material.snackbar.Snackbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.meloda.fast.BuildConfig import com.meloda.fast.R import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants -import com.meloda.fast.base.BaseViewModelFragment import com.meloda.fast.base.viewmodel.* +import com.meloda.fast.common.AppGlobal import com.meloda.fast.databinding.DialogCaptchaBinding +import com.meloda.fast.databinding.DialogFastLoginBinding import com.meloda.fast.databinding.DialogValidationBinding import com.meloda.fast.databinding.FragmentLoginBinding -import com.meloda.fast.util.KeyboardUtils +import com.meloda.fast.extensions.* +import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.screens.main.MainActivity +import com.meloda.fast.screens.settings.SettingsPrefsFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -43,6 +48,19 @@ import kotlin.concurrent.schedule @AndroidEntryPoint class LoginFragment : BaseViewModelFragment(R.layout.fragment_login) { + companion object { + private const val ArgGetFastToken = "get_fast_token" + + fun newInstance(getFastToken: Boolean = false): LoginFragment { + val fragment = LoginFragment() + fragment.arguments = bundleOf( + ArgGetFastToken to getFastToken + ) + + return fragment + } + } + override val viewModel: LoginViewModel by viewModels() private val binding: FragmentLoginBinding by viewBinding() @@ -54,9 +72,12 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo private var captchaInputLayout: TextInputLayout? = null private var validationInputLayout: TextInputLayout? = null + private var isGetFastToken: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.unknownErrorDefaultText = getString(R.string.unknown_error_occurred) + isGetFastToken = requireArguments().getBoolean(ArgGetFastToken, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -65,35 +86,61 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo prepareViews() binding.loginInput.clearFocus() + + binding.useCrashReporter.isChecked = + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) + binding.useCrashReporter.setOnCheckedChangeListener { _, isChecked -> + AppGlobal.preferences.edit { + putBoolean(SettingsPrefsFragment.PrefEnableReporter, isChecked) + requireActivity().finishAffinity() + startActivity(Intent(requireContext(), MainActivity::class.java)) + } + } + + requireActivity().onBackPressedDispatcher.addCallback { + if (getView() == null) { + isEnabled = false + return@addCallback + } + + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + isEnabled = false + } + } } override fun onEvent(event: VkEvent) { super.onEvent(event) when (event) { - is ErrorEvent -> showErrorSnackbar(event.errorText) - is CaptchaEvent -> showCaptchaDialog(event.sid, event.image) - is ValidationEvent -> showValidationRequired(event.sid) - is SuccessAuth -> launchWebView() + StartProgressEvent -> onProgressStarted() + StopProgressEvent -> onProgressStopped() - is CodeSent -> showValidationDialog() - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() + is CaptchaRequiredEvent -> showCaptchaDialog(event.sid, event.image) + is ValidationRequiredEvent -> showValidationRequired(event.sid) + + LoginSuccessAuth -> { + viewModel.initUserConfig() + viewModel.openPrimaryScreen() + } + LoginCodeSent -> showValidationDialog() } } private fun onProgressStarted() { - binding.loginContainer.isVisible = false - binding.passwordContainer.isVisible = false - binding.auth.isVisible = false - binding.progress.isVisible = true + binding.loginContainer.gone() + binding.passwordContainer.gone() + binding.auth.gone() + binding.progressBar.visible() } private fun onProgressStopped() { - binding.loginContainer.isVisible = true - binding.passwordContainer.isVisible = true - binding.auth.isVisible = true - binding.progress.isVisible = false + binding.loginContainer.visible() + binding.passwordContainer.visible() + binding.auth.visible() + binding.progressBar.gone() } private fun prepareViews() { @@ -111,12 +158,20 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo clearCache(true) webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String, favicon: Bitmap?) { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + if (getView() == null) return + binding.webViewProgressBar.visible() + binding.webView.gone() + super.onPageStarted(view, url, favicon) parseAuthUrl(url) } - override fun onPageFinished(view: WebView, url: String?) { + override fun onPageFinished(view: WebView, url: String) { + if (getView() == null) return + binding.webViewProgressBar.gone() + binding.webView.visible() + super.onPageFinished(view, url) } } @@ -130,24 +185,25 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo } private fun launchWebView() { - binding.webView.isVisible = true - binding.webView.loadUrl( - "https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" + - "access_token=${UserConfig.accessToken}&" + - "sdk_package=com.meloda.fast.activity&" + - "sdk_fingerprint=AA88DSADAS8DG8FSA8&" + - "display=page&" + - "revoke=1&" + - "scope=136297695&" + - "redirect_uri=${ - URLEncoder.encode( - "https://oauth.vk.com/blank.html", - Charsets.UTF_8.toString() - ) - }&" + - "response_type=token&" + - "v=${VKConstants.API_VERSION}" - ) + binding.webViewContainer.visible() + + val urlToLoad = "https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" + + "access_token=${UserConfig.accessToken}&" + + "sdk_package=${BuildConfig.sdkPackage}&" + + "sdk_fingerprint=${BuildConfig.sdkFingerprint}&" + + "display=page&" + + "revoke=1&" + + "scope=${VKConstants.Auth.SCOPE.replace("messages,", "")}&" + + "redirect_uri=${ + URLEncoder.encode( + "https://oauth.vk.com/blank.html", + "utf-8" + ) + }&" + + "response_type=token&" + + "v=${VKConstants.API_VERSION}" + + binding.webView.loadUrl(urlToLoad) } private fun parseAuthUrl(url: String) { @@ -165,9 +221,20 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo return } - val token = authData.first + val fastToken = authData.first - UserConfig.fastToken = token + if (isGetFastToken) { + val userId = UserConfig.userId + val accessToken = UserConfig.accessToken + + UserConfig.fastToken = fastToken + + viewModel.saveAccount(userId, accessToken, fastToken) + } else { + val account = requireNotNull(viewModel.currentAccount) + viewModel.currentAccount = account.copy(fastToken = fastToken) + viewModel.initUserConfig() + } viewModel.openPrimaryScreen() } @@ -213,7 +280,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo return@edit if (event.action == EditorInfo.IME_ACTION_GO || (event.action == KeyEvent.ACTION_DOWN && (event.keyCode == KeyEvent.KEYCODE_ENTER || event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) ) { - KeyboardUtils.hideKeyboardFrom(binding.passwordInput) + binding.passwordInput.hideKeyboard() binding.auth.performClick() true } else false @@ -223,13 +290,42 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo private fun prepareAuthButton() { binding.auth.setOnClickListener { validateDataAndAuth() } binding.auth.setOnLongClickListener { - validateDataAndAuth(BuildConfig.vkLogin to BuildConfig.vkPassword) + showFastLoginAlert() true } } + private fun showFastLoginAlert() { + val dialogFastLoginBinding = DialogFastLoginBinding.inflate(layoutInflater, null, false) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.fast_login_title) + .setView(dialogFastLoginBinding.root) + .setPositiveButton(R.string.ok) { _, _ -> + val text = dialogFastLoginBinding.fastLoginText.trimmedText + if (text.isEmpty()) return@setPositiveButton + + val split = text.split(";") + try { + val login = split[0] + val password = split[1] + + binding.loginInput.setText(login) + binding.loginInput.selectLast() + + binding.passwordInput.setText(password) + binding.passwordInput.selectLast() + + validateDataAndAuth(login to password) + } catch (ignored: Exception) { + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun validateDataAndAuth(data: Pair? = null) { - if (binding.progress.isVisible) return + if (binding.progressBar.isVisible) return val loginString = data?.first ?: binding.loginInput.text.toString().trim() val passwordString = data?.second ?: binding.passwordInput.text.toString().trim() @@ -238,7 +334,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo lastLogin = loginString lastPassword = passwordString - KeyboardUtils.hideKeyboardFrom(requireView().findFocus()) + requireView().findFocus()?.hideKeyboard() viewModel.login( login = loginString, @@ -305,12 +401,14 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val captchaBinding = DialogCaptchaBinding.inflate(layoutInflater, null, false) captchaInputLayout = captchaBinding.captchaLayout - captchaBinding.image.load(captchaImage) { - crossfade(100) - transformations(RoundedCornersTransformation(4f)) - } + captchaBinding.image.loadWithGlide( + url = captchaImage, + crossFade = true + ) + captchaBinding.image.shapeAppearanceModel = + captchaBinding.image.shapeAppearanceModel.withCornerSize(16.dpToPx().toFloat()) - val builder = AlertDialog.Builder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireContext()) .setView(captchaBinding.root) .setCancelable(false) .setTitle(R.string.input_captcha) @@ -342,7 +440,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val validationBinding = DialogValidationBinding.inflate(layoutInflater, null, false) validationInputLayout = validationBinding.codeLayout - val builder = AlertDialog.Builder(requireContext()) + val builder = MaterialAlertDialogBuilder(requireContext()) .setView(validationBinding.root) .setCancelable(false) .setTitle(R.string.input_validation_code) @@ -350,7 +448,7 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo val dialog = builder.show() validationBinding.ok.setOnClickListener { - val validationCode = validationBinding.codeInput.text.toString().trim() + val validationCode = validationBinding.codeInput.trimmedText if (!validateInputData( loginString = null, @@ -374,15 +472,4 @@ class LoginFragment : BaseViewModelFragment(R.layout.fragment_lo Toast.makeText(requireContext(), R.string.validation_required, Toast.LENGTH_LONG).show() viewModel.sendSms(validationSid) } - - private fun showErrorSnackbar(errorDescription: String) { - val snackbar = Snackbar.make( - requireView(), - getString(R.string.error, errorDescription), - Snackbar.LENGTH_LONG - ) - - snackbar.animationMode = Snackbar.ANIMATION_MODE_FADE - snackbar.show() - } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt index 53a55fbb..c0ad4819 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt @@ -4,26 +4,26 @@ import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants -import com.meloda.fast.api.VKException -import com.meloda.fast.api.network.auth.AuthDataSource import com.meloda.fast.api.network.auth.AuthDirectRequest import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.ErrorEvent +import com.meloda.fast.base.viewmodel.ErrorTextEvent import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.data.auth.AuthRepository +import com.meloda.fast.model.AppAccount import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val dataSource: AuthDataSource, - private val router: Router + private val authRepository: AuthRepository, + private val router: Router, + private val accounts: AccountsDao ) : BaseViewModel() { - companion object { - private const val TAG = "LoginViewModel" - } + var currentAccount: AppAccount? = null fun login( login: String, @@ -33,7 +33,7 @@ class LoginViewModel @Inject constructor( ) = viewModelScope.launch { makeJob( { - dataSource.auth( + authRepository.auth( AuthDirectRequest( grantType = VKConstants.Auth.GrantType.PASSWORD, clientId = VKConstants.VK_APP_ID, @@ -50,55 +50,47 @@ class LoginViewModel @Inject constructor( }, onAnswer = { if (it.userId == null || it.accessToken == null) { - sendEvent(ErrorEvent(unknownErrorDefaultText)) + sendEvent(ErrorTextEvent(unknownErrorDefaultText)) return@makeJob } - UserConfig.userId = it.userId - UserConfig.accessToken = it.accessToken - - sendEvent(SuccessAuth()) - - // TODO: 19-Oct-21 do somewhen -// makeJob({ -// dataSource.authWithApp( -// AuthWithAppRequest( -// accessToken = it.accessToken -// ) -// ) -// }, onAnswer = { kindaAnswer -> -// println("$TAG: AppAuthResponse: $kindaAnswer") -// } -// ) - - - }, - onError = { - if (it !is VKException) { - onError(it) - return@makeJob + currentAccount = AppAccount( + userId = it.userId, + accessToken = it.accessToken, + fastToken = null + ).also { account -> + UserConfig.currentUserId = account.userId + UserConfig.userId = account.userId + UserConfig.accessToken = account.accessToken } - // TODO: 9/27/2021 use `delay` parameter - twoFaCode?.let { sendEvent(CodeSent) } + sendEvent(LoginSuccessAuth) } ) } fun sendSms(validationSid: String) = viewModelScope.launch { - makeJob({ dataSource.sendSms(validationSid) }, - onAnswer = { sendEvent(CodeSent) } + makeJob({ authRepository.sendSms(validationSid) }, + onAnswer = { sendEvent(LoginCodeSent) } ) } fun openPrimaryScreen() { - router.navigateTo(Screens.Conversations()) + router.replaceScreen(Screens.Main()) } + fun initUserConfig() = viewModelScope.launch { + val account = requireNotNull(currentAccount) + UserConfig.fastToken = account.fastToken + + accounts.insert(listOf(account)) + } + + fun saveAccount(userId: Int, accessToken: String, fastToken: String?) = viewModelScope.launch { + val account = AppAccount(userId, accessToken, fastToken) + accounts.insert(listOf(account)) + } } -object CodeSent : VkEvent() - -data class SuccessAuth( - val haveAuthorized: Boolean = true -) : VkEvent() \ No newline at end of file +object LoginCodeSent : VkEvent() +object LoginSuccessAuth : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt new file mode 100644 index 00000000..381b6376 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt @@ -0,0 +1,350 @@ +package com.meloda.fast.screens.main + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.viewbinding.library.activity.viewBinding +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.size +import androidx.datastore.preferences.core.edit +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.lifecycleScope +import com.github.terrakok.cicerone.NavigatorHolder +import com.github.terrakok.cicerone.Router +import com.github.terrakok.cicerone.androidx.AppNavigator +import com.github.terrakok.cicerone.androidx.FragmentScreen +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.longpoll.LongPollUpdatesParser +import com.meloda.fast.base.BaseActivity +import com.meloda.fast.common.* +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.databinding.ActivityMainBinding +import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.screens.settings.SettingsPrefsFragment +import com.meloda.fast.service.LongPollService +import com.meloda.fast.service.OnlineService +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.analytics.Analytics +import com.microsoft.appcenter.crashes.Crashes +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +@AndroidEntryPoint +class MainActivity : BaseActivity(R.layout.activity_main) { + + private val navigator = object : AppNavigator(this, R.id.root_fragment_container) { + override fun setupFragmentTransaction( + screen: FragmentScreen, + fragmentTransaction: FragmentTransaction, + currentFragment: Fragment?, + nextFragment: Fragment + ) { + } + } + + @Inject + lateinit var navigatorHolder: NavigatorHolder + + @Inject + lateinit var router: Router + + @Inject + lateinit var updateManager: UpdateManager + + @Inject + lateinit var accountsDao: AccountsDao + + @Inject + lateinit var updatesParser: LongPollUpdatesParser + + val binding: ActivityMainBinding by viewBinding() + + var useNavDrawer: Boolean by Delegates.observable(false) { _, _, _ -> + syncNavigationMode() + } + + override fun onResumeFragments() { + navigatorHolder.setNavigator(navigator) + super.onResumeFragments() + } + + override fun onPause() { + navigatorHolder.removeNavigator() + super.onPause() + } + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + createNotificationChannels() + + AppCenter.configure(application, BuildConfig.msAppCenterAppToken) + + if (!BuildConfig.DEBUG) { + AppCenter.start(Analytics::class.java) + } + + AppCenter.start(Crashes::class.java) + Crashes.setEnabled( + AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) + ) + + binding.navigationBar.gone() + + lifecycleScope.launch { + dataStore.data.map { data -> + useNavDrawer = data[AppSettings.keyUseNavigationDrawer] ?: false + }.collect() + } + + if (UserConfig.currentUserId == -1) { + openMainScreen() + } else { + initUserConfig() + } + + updateManager.checkUpdates { item, _ -> + if (item != null) { + router.navigateTo(Screens.Updates(item)) + } + } + + binding.drawer.getHeaderView(0).setOnLongClickListener { + lifecycleScope.launch { + dataStore.edit { settings -> + val useNavDrawer = settings[AppSettings.keyUseNavigationDrawer] ?: false + settings[AppSettings.keyUseNavigationDrawer] = !useNavDrawer + + finish() + startActivity(Intent(this@MainActivity, MainActivity::class.java)) + } + } + true + } + + syncNavigationMode() + binding.navigationBar.selectedItemId = R.id.messages + + supportFragmentManager.setFragmentResultListener( + MainFragment.KeyStartServices, + this + ) { _, result -> + val enable = result.getBoolean("enable", true) + if (enable) { + startServices() + } else { + stopServices() + } + } + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val dialogsName = "Dialogs" + val dialogsDescriptionText = "Channel for dialogs notifications" + val dialogsImportance = NotificationManager.IMPORTANCE_MAX + val dialogsChannel = NotificationChannel("simple_notifications", dialogsName, dialogsImportance).apply { + description = dialogsDescriptionText + } + + val longPollName = "Long Polling" + val longPollDescriptionText = "Channel for long polling service (temporary)" + val longPollImportance = NotificationManager.IMPORTANCE_NONE + val longPollChannel = NotificationChannel("long_polling", longPollName, longPollImportance).apply { + description = longPollDescriptionText + } + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dialogsChannel) + notificationManager.createNotificationChannel(longPollChannel) + } + } + + override fun onResume() { + super.onResume() + + Crashes.getLastSessionCrashReport().thenAccept { report -> + if (report != null) { + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefShowCrashAlert, + true + ) + ) { + val stackTrace = report.stackTrace + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.app_crash_occurred) + .setMessage("Stacktrace: $stackTrace") + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.copy) { _, _ -> + AppGlobal.clipboardManager.setPrimaryClip( + ClipData.newPlainText( + "Fast_Crash_Report", + stackTrace + ) + ) + Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() + } + .setNeutralButton(R.string.share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, stackTrace) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Share stacktrace") + try { + startActivity(shareIntent) + } catch (e: Exception) { + e.printStackTrace() + + runOnUiThread { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage("Can't share") + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + .show() + } + } + } + + if (AppGlobal.preferences.getBoolean(LongPollService.KeyLongPollWasDestroyed, false)) { + AppGlobal.preferences.edit { + putBoolean(LongPollService.KeyLongPollWasDestroyed, false) + } + + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefShowDestroyedLongPollAlert, + false + ) + ) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage("Long poll was destroyed.") + .setPositiveButton("Restart this shit") { _, _ -> + startServices() + } + .setCancelable(false) + .show() + } else { + startServices() + } + } + } + + private fun startServices() { + ContextCompat.startForegroundService(this, Intent(this, LongPollService::class.java)) + startService(Intent(this, OnlineService::class.java)) + } + + private fun stopServices() { + stopService(Intent(this, LongPollService::class.java)) + stopService(Intent(this, OnlineService::class.java)) + } + + private fun addTestMenuItem() { + val test = binding.navigationBar.menu.add("Test") + test.setIcon(R.drawable.ic_round_settings_24) + test.setOnMenuItemClickListener { + if (binding.navigationBar.menu.size < 5) { + addClearMenuItem() + } else { + binding.navigationBar.menu.clear() + addTestMenuItem() + } + + true + } + } + + private fun addClearMenuItem() { + binding.navigationBar.menu.add("Test").run { + setIcon(R.drawable.ic_round_settings_24) + setOnMenuItemClickListener { + binding.navigationBar.menu.clear() + addTestMenuItem() + true + } + } + } + + private fun initUserConfig() { + if (UserConfig.currentUserId == -1) return + + lifecycleScope.launch { + val accounts = accountsDao.getAll() + + Log.d("MainActivity", "initUserConfig: accounts: $accounts") + if (accounts.isNotEmpty()) { + val currentAccount = accounts.find { it.userId == UserConfig.currentUserId } + if (currentAccount != null) { + UserConfig.parse(currentAccount) + } + + openMainScreen() + } else { + openMainScreen() + } + } + } + + private fun openMainScreen() { + router.newRootScreen(Screens.Main()) + } + + private fun syncNavigationMode() { +// binding.navigationBar.toggleVisibility(!useNavDrawer) + binding.drawerLayout.setDrawerLockMode( + if (useNavDrawer) DrawerLayout.LOCK_MODE_UNLOCKED + else DrawerLayout.LOCK_MODE_LOCKED_CLOSED + ) + } + + fun toggleNavBarVisibility(isVisible: Boolean, smooth: Boolean = false) { + if (true) { + binding.navigationBar.gone() + return + } + + if (useNavDrawer) { + binding.navigationBar.gone() + } else { + if (smooth) { + binding.navigationBar.toggleVisibility(isVisible) + } else { + binding.navigationBar.toggleVisibility(isVisible) + } + } + } + + override fun onDestroy() { + super.onDestroy() + stopServices() + updatesParser.clearListeners() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt index 727f80d5..e2dcec48 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt @@ -4,13 +4,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels -import com.meloda.fast.base.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.VkEvent import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainFragment : BaseViewModelFragment() { + companion object { + const val KeyStartServices = "start_services" + } + override val viewModel: MainViewModel by viewModels() override fun onCreateView( @@ -24,6 +31,22 @@ class MainFragment : BaseViewModelFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.checkSession(requireContext()) + viewModel.checkSession() + } + + override fun onEvent(event: VkEvent) { + super.onEvent(event) + + when (event) { + StartServicesEvent -> { + setFragmentResult(KeyStartServices, bundleOf("enable" to true)) + } + StopServicesEvent -> { + setFragmentResult(KeyStartServices, bundleOf("enable" to false)) + } + is SetNavBarVisibilityEvent -> { + (requireActivity() as MainActivity).toggleNavBarVisibility(event.isVisible) + } + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt index 96fd48ac..05e62829 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt @@ -1,30 +1,59 @@ package com.meloda.fast.screens.main -import android.content.Context -import android.content.Intent +import android.util.Log +import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router +import com.github.terrakok.cicerone.Screen import com.meloda.fast.api.UserConfig import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens -import com.meloda.fast.service.MessagesUpdateService -import com.meloda.fast.service.OnlineService import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor(private val router: Router) : BaseViewModel() { - fun checkSession(context: Context) { - if (UserConfig.isLoggedIn()) { - router.navigateTo(Screens.Conversations()) + fun checkSession() = viewModelScope.launch { + val currentUserId = UserConfig.currentUserId + val userId = UserConfig.userId + val accessToken = UserConfig.accessToken + val fastToken = UserConfig.fastToken - context.run { - startService(Intent(this, MessagesUpdateService::class.java)) - startService(Intent(this, OnlineService::class.java)) + viewModelScope.launch { + sendEvent(SetNavBarVisibilityEvent(UserConfig.isLoggedIn())) + } + + Log.d( + "MainViewModel", + "checkSession: currentUserId: $currentUserId; userId: $userId; accessToken: $accessToken; fastToken: $fastToken" + ) + + when { +// fastToken == null -> { +// sendEvent(StopServicesEvent) +// openScreen(Screens.Login(true)) +// } + UserConfig.isLoggedIn() -> { + sendEvent(StartServicesEvent) + openScreen(Screens.Conversations()) + } + else -> { + sendEvent(StopServicesEvent) + openScreen(Screens.Login()) } - } else { - router.navigateTo(Screens.Login()) } } -} \ No newline at end of file + private fun openScreen(screen: Screen) { + router.replaceScreen(screen) + } + +} + +data class SetNavBarVisibilityEvent(val isVisible: Boolean) : VkEvent() + +object StartServicesEvent : VkEvent() + +object StopServicesEvent : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt index a12ece1c..33e919a0 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt @@ -6,8 +6,11 @@ import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.util.Log import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.Space +import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.LinearLayoutCompat import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat @@ -21,6 +24,7 @@ import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.databinding.* import com.meloda.fast.extensions.* import com.meloda.fast.extensions.ImageLoader.clear @@ -30,11 +34,11 @@ import java.text.SimpleDateFormat import java.util.* import kotlin.math.roundToInt -// TODO: 9/29/2021 use recyclerview for viewing attachments class AttachmentInflater constructor( private val context: Context, private val container: LinearLayoutCompat, - private val textContainer: LinearLayoutCompat, + private val replyContainer: FrameLayout, + private val timeReadContainer: View, private val message: VkMessage, private val profiles: Map, private val groups: Map @@ -52,24 +56,66 @@ class AttachmentInflater constructor( R.color.colorSecondary ) + private val timeReadBackground = ContextCompat.getDrawable( + context, + R.drawable.time_read_indicator_on_attachments_background + ) + private var photoClickListener: ((url: String) -> Unit)? = null + private var replyClickListener: ((replyMessage: VkMessage) -> Unit)? = null + private var forwardsClickListener: ((forwards: List) -> Unit)? = null private val displayMetrics get() = Resources.getSystem().displayMetrics - fun setPhotoClickListener(unit: ((url: String) -> Unit)?): AttachmentInflater { - this.photoClickListener = unit + fun withPhotoClickListener(block: ((url: String) -> Unit)?): AttachmentInflater { + this.photoClickListener = block + return this + } + + fun withReplyClickListener(block: ((replyMessage: VkMessage) -> Unit)?): AttachmentInflater { + this.replyClickListener = block + return this + } + + fun withForwardsClickListener(block: ((forwards: List) -> Unit)?): AttachmentInflater { + this.forwardsClickListener = block return this } fun inflate() { container.removeAllViews() + replyContainer.removeAllViews() - if (textContainer.childCount > 1) { - textContainer.removeViews(1, textContainer.childCount - 1) + replyContainer.toggleVisibility(message.hasReply()) + container.toggleVisibility( + !message.attachments.isNullOrEmpty() + || message.hasForwards() + || message.hasGeo() + ) + + timeReadContainer.run { + updateLayoutParams { + val margin = (if (container.isVisible) 6 else 2).dpToPx() + updateMarginsRelative(end = margin, bottom = margin) + } + + background = if (container.isVisible) timeReadBackground else null + } + + if (message.hasReply()) { + reply(requireNotNull(message.replyMessage)) + } + + if (message.hasForwards()) { + forwards(requireNotNull(message.forwards)) + } + + if (message.hasGeo()) { + geo(requireNotNull(message.geo)) } if (message.attachments.isNullOrEmpty()) return - attachments = message.attachments!! + attachments = requireNotNull(message.attachments) if (attachments.size == 1) { when (val attachment = attachments[0]) { @@ -109,13 +155,64 @@ class AttachmentInflater constructor( is VkFile -> file(attachment) is VkLink -> link(attachment) - else -> Log.e( - "Attachment inflater", - "Unknown attachment type: ${attachment.javaClass.name}" - ) + else -> unknown(attachment) } } + } + private fun unknown(attachment: VkAttachment) { + val attachmentType = attachment.javaClass.name + Log.e( + "Attachment inflater", + "Unknown attachment type: $attachmentType" + ) + + val textView = AppCompatTextView(context) + textView.text = attachmentType + + container.addView(textView) + } + + private fun reply(replyMessage: VkMessage) { + val binding = ItemMessageAttachmentReplyBinding.inflate(inflater, replyContainer, true) + binding.root.setOnClickListener { replyClickListener?.invoke(replyMessage) } + + val attachmentText = VkUtils.getAttachmentText( + context = context, + message = replyMessage + ) + + val forwardsMessage = if (replyMessage.text == null) VkUtils.getForwardsText( + context = context, + message = replyMessage + ) else null + + val messageText = attachmentText ?: forwardsMessage ?: (replyMessage.text.orDots()).run { + VkUtils.prepareMessageText(this) + } + + binding.text.text = messageText + + val replyUserGroup = VkUtils.getMessageUserGroup(replyMessage, profiles, groups) + + val fromUser: VkUser? = replyUserGroup.first + val fromGroup: VkGroup? = replyUserGroup.second + + val title = VkUtils.getMessageTitle(replyMessage, fromUser, fromGroup) + binding.title.text = title.orDots() + } + + private fun forwards(forwards: List) { + val binding = ItemMessageAttachmentForwardsBinding.inflate(inflater, container, true) + + binding.root.setOnClickListener { forwardsClickListener?.invoke(forwards) } + } + + private fun geo(geo: BaseVkMessage.Geo) { + val binding = ItemMessageAttachmentGeoBinding.inflate(inflater, container, true) + + binding.location.text = geo.place.title + binding.location.toggleVisibilityIfHasContent() } private fun photo(photo: VkPhoto) { @@ -140,7 +237,7 @@ class AttachmentInflater constructor( val binding = ItemMessageAttachmentPhotoBinding.inflate(inflater, container, true) - val cornersRadius = 8.dpToPx().toFloat() + val cornersRadius = 17.dpToPx().toFloat() binding.border.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius) @@ -159,10 +256,8 @@ class AttachmentInflater constructor( binding.image.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius * 0.8F) - if (photoClickListener != null) { - setOnClickListener { photoClickListener?.invoke(size.url) } - } else { - setOnClickListener(null) + setOnClickListener { + photo.getMaxSize()?.let { size -> photoClickListener?.invoke(size.url) } } loadWithGlide( @@ -194,7 +289,7 @@ class AttachmentInflater constructor( } val ratio = "${size.width}:${size.height}" - val cornersRadius = 8.dpToPx().toFloat() + val cornersRadius = 17.dpToPx().toFloat() binding.border.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius) @@ -243,7 +338,9 @@ class AttachmentInflater constructor( } private fun link(link: VkLink) { - val binding = ItemMessageAttachmentLinkBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentLinkBinding.inflate( + inflater, container, true + ) binding.title.text = link.title binding.title.toggleVisibility(!link.title.isNullOrBlank()) @@ -283,7 +380,7 @@ class AttachmentInflater constructor( } private fun wall(wall: VkWall) { - val binding = ItemMessageAttachmentWallPostBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentWallPostBinding.inflate(inflater, container, true) val group = if (wall.fromId > 0) null else groups[wall.fromId] val user = if (wall.fromId < 0) null else profiles[wall.fromId] @@ -300,11 +397,11 @@ class AttachmentInflater constructor( else -> null } - val title = when { + val title = (when { group == null && user != null -> user.fullName user == null && group != null -> group.name - else -> "..." - } + else -> null + }).orDots() binding.postTitle.text = context.getString(postTitleRes) binding.postTitle.gone() @@ -326,7 +423,7 @@ class AttachmentInflater constructor( } private fun voice(voiceMessage: VkVoiceMessage) { - val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentVoiceBinding.inflate(inflater, container, true) if (message.isOut) { val padding = 6.dpToPx() @@ -349,7 +446,7 @@ class AttachmentInflater constructor( } private fun call(call: VkCall) { - val binding = ItemMessageAttachmentCallBinding.inflate(inflater, textContainer, true) + val binding = ItemMessageAttachmentCallBinding.inflate(inflater, container, true) if (message.isOut) binding.root.updatePadding( diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt new file mode 100644 index 00000000..c9ca0d64 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt @@ -0,0 +1,231 @@ +package com.meloda.fast.screens.messages + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import com.google.android.material.shape.ShapeAppearanceModel +import com.meloda.fast.R +import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.base.adapter.BaseAdapter +import com.meloda.fast.base.adapter.BaseHolder +import com.meloda.fast.databinding.ItemUploadedAttachmentAudioBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentFileBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentPhotoBinding +import com.meloda.fast.databinding.ItemUploadedAttachmentVideoBinding +import com.meloda.fast.extensions.ImageLoader.clear +import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.extensions.dpToPx +import com.meloda.fast.extensions.gone +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.extensions.visible + +class AttachmentsAdapter( + context: Context, + preAddedValues: List, + private var onRemoveClickedListener: ((position: Int) -> Unit)? = null, +) : BaseAdapter( + context, comparator, preAddedValues +) { + + private companion object { + + private const val TypePhoto = 1 + private const val TypeVideo = 2 + private const val TypeAudio = 3 + private const val TypeFile = 4 + + private val comparator = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: VkAttachment, newItem: VkAttachment): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: VkAttachment, newItem: VkAttachment): Boolean { + return false + } + } + } + + private val colorPrimaryVariant = ContextCompat.getColor(context, R.color.colorPrimaryVariant) + + open inner class Holder(v: View) : BaseHolder(v) + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is VkPhoto -> TypePhoto + is VkVideo -> TypeVideo + is VkAudio -> TypeAudio + is VkFile -> TypeFile + else -> super.getItemViewType(position) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return when (viewType) { + TypePhoto -> PhotoViewHolder( + ItemUploadedAttachmentPhotoBinding.inflate(inflater, parent, false) + ) + TypeVideo -> VideoViewHolder( + ItemUploadedAttachmentVideoBinding.inflate(inflater, parent, false) + ) + TypeAudio -> AudioViewHolder( + ItemUploadedAttachmentAudioBinding.inflate(inflater, parent, false) + ) + TypeFile -> FileViewHolder( + ItemUploadedAttachmentFileBinding.inflate(inflater, parent, false) + ) + else -> Holder(View(context)) + } + } + + inner class PhotoViewHolder( + private val binding: ItemUploadedAttachmentPhotoBinding + ) : Holder(binding.root) { + + init { + binding.image.shapeAppearanceModel = + binding.image.shapeAppearanceModel.withCornerSize(18.dpToPx().toFloat()) + } + + override fun bind(position: Int) { + val photo = getItem(position) as VkPhoto + + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = photo.getSizeOrSmaller(VkPhoto.SIZE_TYPE_807)?.url, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { binding.progressBar.gone() } + ) + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + } + + inner class VideoViewHolder( + private val binding: ItemUploadedAttachmentVideoBinding + ) : Holder(binding.root) { + init { + val cornerSizedShapeAppearanceModel = ShapeAppearanceModel().withCornerSize( + 18.dpToPx().toFloat() + ) + + binding.image.shapeAppearanceModel = cornerSizedShapeAppearanceModel + binding.coloredBackground.shapeAppearanceModel = cornerSizedShapeAppearanceModel + } + + override fun bind(position: Int) { + val video = getItem(position) as VkVideo + + binding.title.text = video.title + + val previewSrc = video.imageForWidthAtLeast(300) + binding.image.toggleVisibility(previewSrc != null) + binding.coloredBackground.toggleVisibility(previewSrc == null) + binding.videoIcon.toggleVisibility(previewSrc == null) + + if (previewSrc != null) { + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = previewSrc.url, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { showPlaceholder() } + ) + } else { + binding.progressBar.gone() + binding.image.clear() + } + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + + private fun showPlaceholder() { + binding.coloredBackground.visible() + binding.videoIcon.visible() + binding.image.clear() + binding.image.gone() + binding.progressBar.gone() + } + } + + inner class AudioViewHolder( + private val binding: ItemUploadedAttachmentAudioBinding + ) : Holder(binding.root) { + init { + binding.coloredBackground.shapeAppearanceModel = + binding.coloredBackground.shapeAppearanceModel.withCornerSize(18.dpToPx().toFloat()) + } + + override fun bind(position: Int) { + val audio = getItem(position) as VkAudio + + binding.title.text = audio.title + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + } + + inner class FileViewHolder( + private val binding: ItemUploadedAttachmentFileBinding + ) : Holder(binding.root) { + + init { + val cornerSizedShapeAppearanceModel = ShapeAppearanceModel().withCornerSize( + 18.dpToPx().toFloat() + ) + + binding.image.shapeAppearanceModel = cornerSizedShapeAppearanceModel + binding.coloredBackground.shapeAppearanceModel = cornerSizedShapeAppearanceModel + } + + override fun bind(position: Int) { + val file = getItem(position) as VkFile + + binding.title.text = file.title + + val previewSrc = file.preview?.photo?.sizes?.get(0) + binding.image.toggleVisibility(previewSrc != null) + binding.coloredBackground.toggleVisibility(previewSrc == null) + binding.fileIcon.toggleVisibility(previewSrc == null) + + if (previewSrc != null) { + binding.progressBar.visible() + + binding.image.loadWithGlide( + url = previewSrc.src, + crossFade = true, + placeholderColor = colorPrimaryVariant, + onLoadedAction = { binding.progressBar.gone() }, + onFailedAction = { showPlaceholder() } + ) + } else { + binding.progressBar.gone() + binding.image.clear() + } + + binding.close.setOnClickListener { + onRemoveClickedListener?.invoke(position) + } + } + + private fun showPlaceholder() { + binding.coloredBackground.visible() + binding.fileIcon.visible() + binding.image.clear() + binding.image.gone() + binding.progressBar.gone() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt new file mode 100644 index 00000000..5362b612 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt @@ -0,0 +1,98 @@ +package com.meloda.fast.screens.messages + +import android.os.Bundle +import android.view.View +import android.viewbinding.library.fragment.viewBinding +import androidx.core.os.bundleOf +import com.meloda.fast.R +import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.common.Screens +import com.meloda.fast.databinding.FragmentForwardedMessagesBinding + +class ForwardedMessagesFragment : BaseFragment(R.layout.fragment_forwarded_messages) { + + companion object { + private const val ArgConversation = "conversation" + private const val ArgMessages = "messages" + private const val ArgProfiles = "profiles" + private const val ArgGroups = "groups" + + fun newInstance( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ): ForwardedMessagesFragment { + val fragment = ForwardedMessagesFragment() + fragment.arguments = bundleOf( + ArgConversation to conversation, + ArgMessages to messages, + ArgProfiles to profiles, + ArgGroups to groups + ) + + return fragment + } + } + + private val binding: FragmentForwardedMessagesBinding by viewBinding() + + private var conversation: VkConversation? = null + private var messages: List = emptyList() + private var profiles: HashMap = hashMapOf() + private var groups: HashMap = hashMapOf() + + private val adapter: MessagesHistoryAdapter by lazy { + MessagesHistoryAdapter( + this, requireNotNull(conversation), profiles, groups + ) + } + + @Suppress("UNCHECKED_CAST") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requireArguments().run { + conversation = getParcelable(ArgConversation) + messages = getParcelableArrayList(ArgMessages) ?: emptyList() + + profiles = getSerializable(ArgProfiles) as? HashMap ?: hashMapOf() + groups = getSerializable(ArgGroups) as? HashMap ?: hashMapOf() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + fillRecyclerView() + } + + private fun fillRecyclerView() { + adapter.setItems(messages) + binding.recyclerView.adapter = adapter + } + + fun scrollToMessage(messageId: Int) { + adapter.searchMessageIndex(messageId)?.let { index -> + binding.recyclerView.scrollToPosition(index) + } + } + + fun openForwardsScreen( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) { + requireActivityRouter().navigateTo( + Screens.ForwardedMessages(conversation, messages, profiles, groups) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt index f58ac1af..77f063f3 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt @@ -2,16 +2,17 @@ package com.meloda.fast.screens.messages import android.annotation.SuppressLint import android.content.Context +import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.util.Log import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.util.ObjectsCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import coil.load import com.meloda.fast.R import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation @@ -24,6 +25,7 @@ import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemMessageInBinding import com.meloda.fast.databinding.ItemMessageOutBinding import com.meloda.fast.databinding.ItemMessageServiceBinding +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import com.meloda.fast.extensions.dpToPx import com.meloda.fast.model.DataItem @@ -31,8 +33,35 @@ class MessagesHistoryAdapter constructor( context: Context, val conversation: VkConversation, val profiles: HashMap = hashMapOf(), - val groups: HashMap = hashMapOf() -) : BaseAdapter, MessagesHistoryAdapter.BasicHolder>(context, Comparator) { + val groups: HashMap = hashMapOf(), +) : BaseAdapter, MessagesHistoryAdapter.BasicHolder>( + context, + Comparator +) { + + constructor( + fragment: MessagesHistoryFragment, + conversation: VkConversation, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf(), + ) : this(fragment.requireContext(), conversation, profiles, groups) { + this.messagesHistoryFragment = fragment + } + + constructor( + fragment: ForwardedMessagesFragment, + conversation: VkConversation, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) : this(fragment.requireContext(), conversation, profiles, groups) { + this.isForwards = true + this.forwardedMessagesFragment = fragment + } + + private var isForwards: Boolean = false + + private var messagesHistoryFragment: MessagesHistoryFragment? = null + private var forwardedMessagesFragment: ForwardedMessagesFragment? = null var avatarLongClickListener: ((position: Int) -> Unit)? = null @@ -86,14 +115,14 @@ class MessagesHistoryAdapter constructor( if (holder is Header || holder is Footer) { Log.d( "MessagesHistoryAdapter", - "onBindViewHolder: index $position, holder is ${holder.javaClass.simpleName}. Skip" + "onBindViewHolder: index $position, holder: ${holder.javaClass.simpleName}. Skip" ) return } Log.d( "MessagesHistoryAdapter", - "onBindViewHolder: index $position, holder is ${holder.javaClass.simpleName}. Bind" + "onBindViewHolder: index $position, holder: ${holder.javaClass.simpleName}. Bind" ) initListeners(holder.itemView, position) @@ -121,7 +150,7 @@ class MessagesHistoryAdapter constructor( private val binding: ItemMessageInBinding ) : BasicHolder(binding.root) { - override fun bind(position: Int) { + override fun bind(position: Int, payloads: MutableList?) { val message = getItem(position) as VkMessage val prevMessage = getVkMessage(getOrNull(position - 1)) @@ -129,6 +158,7 @@ class MessagesHistoryAdapter constructor( MessagesPreparator( context = context, + payloads = payloads, root = binding.root, @@ -143,19 +173,38 @@ class MessagesHistoryAdapter constructor( bubble = binding.bubble, text = binding.text, spacer = binding.spacer, - unread = binding.unread, + messageState = binding.messageState, + time = binding.time, - textContainer = binding.textContainer, + replyContainer = binding.replyContainer, attachmentContainer = binding.attachmentContainer, - attachmentSpacer = binding.attachmentSpacer, + timeReadContainer = binding.timeReadContainer, profiles = profiles, - groups = groups - ).setPhotoClickListener { - Toast.makeText(context, "Photo url: $it", Toast.LENGTH_LONG).show() - }.prepare() + groups = groups, - binding.avatar.setOnLongClickListener() { + isForwards = isForwards + ) + .withPhotoClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(it)).run { + context.startActivity(this) + } + } + .withReplyClickListener { + messagesHistoryFragment?.scrollToMessage(it.id) + forwardedMessagesFragment?.scrollToMessage(it.id) + } + .withForwardsClickListener { messages -> + messagesHistoryFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + forwardedMessagesFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + } + .prepare() + + binding.avatar.setOnLongClickListener { avatarLongClickListener?.invoke(position) true } @@ -166,12 +215,13 @@ class MessagesHistoryAdapter constructor( private val binding: ItemMessageOutBinding ) : BasicHolder(binding.root) { - override fun bind(position: Int) { + override fun bind(position: Int, payloads: MutableList?) { val message = getItem(position) as VkMessage val prevMessage = getVkMessage(getOrNull(position - 1)) MessagesPreparator( context = context, + payloads = payloads, root = binding.root, conversation = conversation, message = message, @@ -180,15 +230,36 @@ class MessagesHistoryAdapter constructor( bubble = binding.bubble, text = binding.text, spacer = binding.spacer, - unread = binding.unread, + messageState = binding.messageState, + time = binding.time, - textContainer = binding.textContainer, + timeReadContainer = binding.timeReadContainer, + replyContainer = binding.replyContainer, attachmentContainer = binding.attachmentContainer, - attachmentSpacer = binding.attachmentSpacer, profiles = profiles, - groups = groups - ).prepare() + groups = groups, + + isForwards = isForwards + ) + .withPhotoClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(it)).run { + context.startActivity(this) + } + } + .withReplyClickListener { + messagesHistoryFragment?.scrollToMessage(it.id) + forwardedMessagesFragment?.scrollToMessage(it.id) + } + .withForwardsClickListener { messages -> + messagesHistoryFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + forwardedMessagesFragment?.openForwardsScreen( + conversation, messages, profiles, groups + ) + } + .prepare() } } @@ -239,14 +310,56 @@ class MessagesHistoryAdapter constructor( size.height ) - binding.photo.load(size.url) { - crossfade(150) - fallback(ColorDrawable(Color.LTGRAY)) + binding.photo.loadWithGlide( + url = size.url, + crossFade = true, + placeholderDrawable = ColorDrawable(Color.LTGRAY) + ) + + binding.photo.setOnClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(size.url)).run { + context.startActivity(this) + } } } } } + fun containsUnreadMessages(isOutgoingMessages: Boolean = false): Boolean { + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.isOut == isOutgoingMessages && !item.isRead(conversation)) { + return true + } + } + return false + } + + fun containsRandomId(randomId: Int): Boolean { + if (randomId == 0) return false + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.randomId == randomId) return true + } + + return false + } + + fun containsId(id: Int): Boolean { + for (i in indices) { + val item = getItem(i) + if (item !is VkMessage) continue + + if (item.id == id) return true + } + + return false + } + fun getVkMessage(item: DataItem<*>?): VkMessage? { if (item == null) return null if (item is VkMessage) return item @@ -287,16 +400,19 @@ class MessagesHistoryAdapter constructor( return if (oldItem is VkMessage && newItem is VkMessage) { oldItem.id == newItem.id } else { - oldItem is DataItem.Footer && newItem is DataItem.Footer - || oldItem is DataItem.Header && newItem is DataItem.Header + oldItem is DataItem.Footer && newItem is DataItem.Footer || + oldItem is DataItem.Header && newItem is DataItem.Header || + ObjectsCompat.equals(oldItem, newItem) } } - @SuppressLint("DiffUtilEquals") override fun areContentsTheSame( oldItem: DataItem, newItem: DataItem - ): Boolean = oldItem == newItem + ): Boolean { + + return ObjectsCompat.equals(oldItem, newItem) && ((oldItem is VkMessage && newItem is VkMessage) && oldItem.state == newItem.state) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt index 0d27f113..e72cffe1 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt @@ -1,53 +1,61 @@ package com.meloda.fast.screens.messages -import android.animation.ValueAnimator -import android.content.res.ColorStateList -import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.os.Bundle -import android.text.TextUtils +import android.os.Environment +import android.provider.OpenableColumns +import android.util.Log import android.view.View -import android.view.animation.LinearInterpolator import android.viewbinding.library.fragment.viewBinding +import android.widget.ImageView import android.widget.Toast -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu +import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.core.view.setPadding -import androidx.core.view.updateLayoutParams import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import coil.load +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.common.net.MediaType import com.meloda.fast.R import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.base.BaseViewModelFragment -import com.meloda.fast.base.viewmodel.StartProgressEvent -import com.meloda.fast.base.viewmodel.StopProgressEvent +import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.Screens +import com.meloda.fast.data.files.FilesRepository import com.meloda.fast.databinding.DialogMessageDeleteBinding import com.meloda.fast.databinding.FragmentMessagesHistoryBinding import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.clear import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.screens.conversations.MessagesNewEvent +import com.meloda.fast.screens.settings.SettingsPrefsFragment import com.meloda.fast.util.AndroidUtils import com.meloda.fast.util.TimeUtils +import com.meloda.fast.view.SpaceItemDecoration import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File import java.text.SimpleDateFormat import java.util.* import kotlin.concurrent.schedule -import kotlin.math.roundToInt - +import kotlin.math.abs +import kotlin.random.Random @AndroidEntryPoint class MessagesHistoryFragment : @@ -60,9 +68,17 @@ class MessagesHistoryFragment : private const val ATTACHMENT_PANEL_ANIMATION_DURATION = 150L - fun newInstance(bundle: Bundle): MessagesHistoryFragment { + fun newInstance( + conversation: VkConversation, + user: VkUser?, + group: VkGroup? + ): MessagesHistoryFragment { val fragment = MessagesHistoryFragment() - fragment.arguments = bundle + fragment.arguments = bundleOf( + ARG_CONVERSATION to conversation, + ARG_USER to user, + ARG_GROUP to group + ) return fragment } @@ -71,7 +87,39 @@ class MessagesHistoryFragment : override val viewModel: MessagesHistoryViewModel by viewModels() private val binding: FragmentMessagesHistoryBinding by viewBinding() - private val action = MutableLiveData() + private var pickFile: Boolean = false + + private val attachmentsToLoad = mutableListOf() + + private val getContent = + registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uriList: List? -> + if (uriList.isNullOrEmpty()) { + return@registerForActivityResult + } + + if (uriList.size > 10) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Select no more than 10 files") + .setPositiveButton(R.string.ok, null) + .show() + return@registerForActivityResult + } + + viewLifecycleOwner.lifecycleScope.launch { + val uploadFlow = flow { + uriList.forEach { uri -> + processFileFromStorage(uri) + emit(null) + } + } + + uploadFlow.collect() + } + } + + + private val actionState = MutableLiveData() private enum class Action { RECORD, SEND, EDIT, DELETE @@ -90,19 +138,35 @@ class MessagesHistoryFragment : } private val adapter: MessagesHistoryAdapter by lazy { - MessagesHistoryAdapter(requireContext(), conversation).also { + MessagesHistoryAdapter(this, conversation).also { it.itemClickListener = this::onItemClick it.avatarLongClickListener = this::onAvatarLongClickListener } } + private val attachmentsAdapter: AttachmentsAdapter by lazy { + AttachmentsAdapter( + requireContext(), + emptyList(), + onRemoveClickedListener = { position -> + removeAttachment(attachmentsAdapter[position]) + } + ) + } + private var timestampTimer: Timer? = null private lateinit var attachmentController: AttachmentPanelController + init { + shouldNavBarShown = false + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + attachmentController = AttachmentPanelController().init() val title = when { @@ -112,12 +176,7 @@ class MessagesHistoryFragment : else -> null } - binding.back.setOnClickListener { requireActivity().onBackPressed() } - - binding.title.ellipsize = TextUtils.TruncateAt.END - binding.status.ellipsize = TextUtils.TruncateAt.END - - binding.title.text = title ?: "..." + binding.toolbar.title = title.orDots() val status = when { conversation.isChat() -> "${conversation.membersCount} members" @@ -125,7 +184,10 @@ class MessagesHistoryFragment : // TODO: 9/15/2021 user normal time user?.online == true -> "Online" user?.lastSeen != null -> "Last seen at ${ - SimpleDateFormat("HH:mm", Locale.getDefault()).format(user?.lastSeen!! * 1000L) + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(user?.lastSeen!! * 1000L) }" else -> if (user?.lastSeenStatus != null) "Last seen ${user?.lastSeenStatus!!}" else "Last seen recently" } @@ -133,7 +195,7 @@ class MessagesHistoryFragment : else -> null } - binding.status.text = status ?: "..." + binding.toolbar.subtitle = status.orDots() prepareAvatar() @@ -153,25 +215,48 @@ class MessagesHistoryFragment : if (lastVisiblePosition <= adapter.lastPosition - 10) return@addOnLayoutChangeListener binding.recyclerView.postDelayed({ + if (getView() == null) return@postDelayed binding.recyclerView.scrollToPosition(adapter.lastPosition) }, 25) } + binding.unreadCounter.setOnClickListener { + binding.recyclerView.scrollToPosition(adapter.lastPosition) + } + + binding.recyclerView.setItemViewCacheSize(30) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val firstPosition = - (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val firstPosition = layoutManager.findFirstVisibleItemPosition() + val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition() + + if (AppGlobal.preferences.getBoolean( + SettingsPrefsFragment.PrefHideKeyboardOnScroll, + true + ) && dy < 0 + ) { + binding.recyclerView.hideKeyboard() + } + + setUnreadCounterVisibility(lastPosition, dy) adapter.getOrNull(firstPosition)?.let { if (it !is VkMessage) return - binding.timestamp.isVisible = true + binding.timestamp.visible() val time = "${ TimeUtils.getLocalizedDate( requireContext(), it.date * 1000L ) - }, ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(it.date * 1000L)}" + }, ${ + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(it.date * 1000L) + }" binding.timestamp.text = time @@ -197,13 +282,23 @@ class MessagesHistoryFragment : when { attachmentController.isEditing -> if (it.isNullOrBlank()) Action.DELETE else Action.EDIT canSend -> Action.SEND - else -> Action.RECORD + else -> { + if (attachmentsToLoad.isNotEmpty()) { + if (attachmentController.isEditing) { + Action.EDIT + } else { + Action.SEND + } + } else { + Action.RECORD + } + } } - if (action.value != newValue) action.value = newValue + actionState.setIfNotEquals(newValue) } - action.observe(viewLifecycleOwner) { + actionState.observe(viewLifecycleOwner) { binding.action.animate() .scaleX(1.25f) .scaleY(1.25f) @@ -238,32 +333,30 @@ class MessagesHistoryFragment : attachmentController.isPanelVisible.observe(viewLifecycleOwner) { isVisible -> if (isVisible) binding.message.setSelection(binding.message.text.toString().length) - val currentMargin = - (binding.refreshLayout.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin - - val newMargin = - if (isVisible) (binding.attachmentPanel.measuredHeight / 1.5).roundToInt() - else 0 - - ValueAnimator.ofInt(currentMargin, newMargin).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (getView() == null) return@addUpdateListener - val value = animator.animatedValue as Int - binding.refreshLayout.updateLayoutParams { - bottomMargin = value - } - } - }.start() +// val currentHeight = binding.listAnchor.height +// +// val newHeight = +// if (isVisible) (binding.attachmentPanel.measuredHeight / 1.5).roundToInt() +// else 1 +// +// ValueAnimator.ofInt(currentHeight, newHeight).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (getView() == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// binding.listAnchor.updateLayoutParams { +// height = value +// } +// } +// }.start() } - binding.attachmentPanel.setOnClickListener c@{ - val message = attachmentController.message.value ?: return@c - - val index = adapter.indexOf(message) - if (index == -1) return@c + binding.replyMessage.setOnClickListener { + val message = attachmentController.message.value ?: return@setOnClickListener + val index = adapter.searchMessageIndex(message.id) ?: return@setOnClickListener binding.recyclerView.scrollToPosition(index) } @@ -272,79 +365,268 @@ class MessagesHistoryFragment : if (attachmentController.message.value != null) attachmentController.message.value = null } + + binding.attach.setOnClickListener { + showAttachmentsPopupMenu() + } + + binding.attach.setOnLongClickListener { + pickPhoto() + true + } } - @ColorInt - private fun getColor(@ColorRes resId: Int): Int { - return ContextCompat.getColor(requireContext(), resId) + override fun onEvent(event: VkEvent) { + super.onEvent(event) + + when (event) { + is MessagesMarkAsImportantEvent -> markMessagesAsImportant(event) + is MessagesLoadedEvent -> refreshMessages(event) + is MessagesPinEvent -> conversation.pinnedMessage = event.message + is MessagesUnpinEvent -> conversation.pinnedMessage = null + is MessagesDeleteEvent -> deleteMessages(event) + is MessagesEditEvent -> editMessage(event) + is MessagesReadEvent -> readMessages(event) + is MessagesNewEvent -> addNewMessage(event) + } + } + + override fun toggleProgress(isProgressing: Boolean) { + view?.run { + findViewById(R.id.progress_bar).toggleVisibility( + if (isProgressing) adapter.isEmpty() else false + ) + findViewById(R.id.refresh_layout).isRefreshing = + if (isProgressing) adapter.isNotEmpty() else false + } + } + + private suspend fun processFileFromStorage(uri: Uri) { + var name = "" + var size = 0.0 + + val contentResolver = requireContext().contentResolver + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + + cursor.moveToFirst() + name = cursor.getString(nameIndex) + size = AndroidUtils.bytesToMegabytes(cursor.getLong(sizeIndex).toDouble()) + cursor.close() + } + + if (size > 200) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Selected file weighs more than 200 megabytes. Compress it or send other file") + .setPositiveButton(R.string.ok, null) + .setCancelable(false) + .show() + return + } + + val lastDotIndex = name.lastIndexOf(".") + var extension = if (lastDotIndex == -1) "" else name.substring(lastDotIndex + 1) + + if (extension.endsWith("msi") || extension.endsWith("exe") || extension.endsWith("apk")) { + extension += "fast" + name += "fast" + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage("Selected file is executable. Fast changed it extension to \"$extension\", so the final name is \"$name\"") + .setPositiveButton(R.string.ok, null) + .setCancelable(false) + .show() + } + + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + + "${File.separator}upload.$extension" + + val file = File(destination) + if (file.exists()) file.delete() + + withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + val inputStream = + requireActivity().contentResolver.openInputStream(uri) ?: return@withContext + + inputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + } + + val mimeType = contentResolver.getType(uri) ?: return + + if (pickFile) { + val uploadedAttachment = viewModel.uploadFile( + conversation.id, + file, + name, + FilesRepository.FileType.File + ) + addAttachment(uploadedAttachment) + } else { + when (MediaType.parse(mimeType).type()) { + MediaType.ANY_IMAGE_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadPhoto(conversation.id, file, name) + addAttachment(uploadedAttachment) + } + MediaType.ANY_VIDEO_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadVideo(file, name) + addAttachment(uploadedAttachment) + } + MediaType.ANY_AUDIO_TYPE.type() -> { + val uploadedAttachment = viewModel.uploadAudio(file, name) + addAttachment(uploadedAttachment) + } + } + } + } + + private fun showAttachmentsPopupMenu() { + val popupMenu = PopupMenu(requireContext(), binding.attach) + + if (attachmentsToLoad.isNotEmpty()) { + popupMenu.menu.add("Clear attachments") + } + + popupMenu.menu.add("Photo") + popupMenu.menu.add("Video") + popupMenu.menu.add("Audio") + popupMenu.menu.add("File") + popupMenu.setOnMenuItemClickListener { menuItem -> + return@setOnMenuItemClickListener when (menuItem.title) { + "Clear attachments" -> { + clearAttachments() + true + } + "Photo" -> { + pickPhoto() + true + } + "Video" -> { + pickVideo() + true + } + "Audio" -> { + pickAudio() + true + } + "File" -> { + pickFile() + true + } + else -> false + } + } + popupMenu.show() + } + + private fun addAttachment(attachment: VkAttachment) { + attachmentsToLoad += attachment + binding.attachmentsCounter.visible() + binding.attachmentsCounter.text = attachmentsToLoad.size.toString() + + binding.attachmentsList.visible() + attachmentsAdapter.add(attachment) + + attachmentController.showPanel() + + actionState.setIfNotEquals( + if (attachmentController.isEditing) Action.EDIT + else Action.SEND + ) + } + + private fun removeAttachment(attachment: VkAttachment) { + attachmentsToLoad -= attachment + binding.attachmentsCounter.visible() + binding.attachmentsCounter.text = attachmentsToLoad.size.toString() + + binding.attachmentsList.visible() + + attachmentController.showPanel() + + if (attachmentsToLoad.isEmpty()) { + clearAttachments() + } else { + attachmentsAdapter.remove(attachment) + } + } + + private fun clearAttachments() { + attachmentsToLoad.clear() + binding.attachmentsCounter.gone() + binding.attachmentsCounter.text = null + + attachmentsAdapter.clear() + binding.attachmentsList.gone() + + attachmentController.hidePanel() + } + + private fun pickPhoto() { + getContent.launch(MediaType.ANY_IMAGE_TYPE.mimeType) + } + + private fun pickVideo() { + getContent.launch(MediaType.ANY_VIDEO_TYPE.mimeType) + } + + private fun pickAudio() { + getContent.launch(MediaType.MPEG_AUDIO.mimeType) + } + + private fun pickFile() { + pickFile = true + getContent.launch(MediaType.ANY_TYPE.mimeType) + } + + fun scrollToMessage(messageId: Int) { + adapter.searchMessageIndex(messageId)?.let { index -> + binding.recyclerView.scrollToPosition(index) + } } private fun prepareAvatar() { val avatar = when { - conversation.ownerId == VKConstants.FAST_GROUP_ID -> null conversation.isUser() -> user?.photo200 conversation.isGroup() -> group?.photo200 conversation.isChat() -> conversation.photo200 else -> null } - val colorOnPrimary = getColor(R.color.colorOnPrimary) - val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) - val colorOnUserAvatarAction = getColor(R.color.colorOnUserAvatarAction) + val avatarMenuItem = binding.toolbar.addAvatarMenuItem() + val avatarImageView: ImageView? = avatarMenuItem.actionView?.findViewById(R.id.avatar) - val icLauncherColor = getColor(R.color.a1_500) - - binding.avatar.toggleVisibility(avatar != null) - - if (avatar == null) { - binding.avatarPlaceholder.visible() - - if (conversation.ownerId == VKConstants.FAST_GROUP_ID) { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(icLauncherColor), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(colorOnPrimary) - binding.placeholder.setImageResource(R.drawable.ic_fast_logo) - binding.placeholder.setPadding(18) - } else { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(colorOnUserAvatarAction), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(colorUserAvatarAction) - binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut) - binding.placeholder.setPadding(0) - binding.avatar.clear() - } - } else { - binding.avatar.load(avatar) { - crossfade(200) - target { - binding.avatarPlaceholder.gone() - binding.avatar.setImageDrawable(it) - } - } - } - - binding.phantomIcon.toggleVisibility(conversation.isPhantom) - binding.online.toggleVisibility(user?.online) + avatarImageView?.loadWithGlide(url = avatar, asCircle = true, crossFade = true) } private fun performAction() { - when (action.value) { + when (actionState.value) { Action.RECORD -> { } Action.SEND -> { - val messageText = binding.message.text.toString().trim() - if (messageText.isBlank()) return + val messageText = binding.message.trimmedText + if (messageText.isBlank() && attachmentsToLoad.isEmpty()) { + Log.d( + "MessagesHistoryFragment", + "performAction: SEND: messageText is empty & attachments is empty. return" + ) + return + } val date = System.currentTimeMillis() val messageIndex = adapter.lastPosition + val attachments = attachmentsToLoad.ifEmpty { null }?.toList() + clearAttachments() + val message = VkMessage( id = Int.MAX_VALUE, text = messageText, @@ -352,9 +634,14 @@ class MessagesHistoryFragment : peerId = conversation.id, fromId = UserConfig.userId, date = (date / 1000).toInt(), - randomId = 0, - replyMessage = attachmentController.message.value - ) + randomId = Random.nextInt(), + replyMessage = attachmentController.message.value, + attachments = attachments, + ).also { + it.state = VkMessage.State.Sending + } + + Log.d("LongPollUpdatesParser", "newMessageRandomId: ${message.randomId}") adapter.add(message, beforeFooter = true, commitCallback = { binding.recyclerView.scrollToPosition(adapter.lastPosition) @@ -366,14 +653,25 @@ class MessagesHistoryFragment : viewModel.sendMessage( peerId = conversation.id, - message = messageText, - randomId = 0, + message = messageText.ifBlank { null }, + randomId = message.randomId, replyTo = replyMessage?.id, setId = { messageId -> val messageToUpdate = adapter[messageIndex] as VkMessage messageToUpdate.id = messageId - adapter[messageIndex] = messageToUpdate - } + messageToUpdate.state = VkMessage.State.Sent + adapter.notifyItemChanged(messageIndex, "kek") +// adapter[messageIndex] = messageToUpdate + attachmentsAdapter.clear() + }, + onError = { + val messageToUpdate = adapter[messageIndex] as VkMessage + messageToUpdate.state = VkMessage.State.Error + adapter.notifyItemChanged(messageIndex, "kek") +// adapter[messageIndex] = messageToUpdate + attachmentsAdapter.clear() + }, + attachments = attachments ) } Action.EDIT -> { @@ -397,35 +695,11 @@ class MessagesHistoryFragment : } } - override fun onEvent(event: VkEvent) { - super.onEvent(event) - - when (event) { - is StartProgressEvent -> onProgressStarted() - is StopProgressEvent -> onProgressStopped() - - is MessagesMarkAsImportantEvent -> markMessagesAsImportant(event) - is MessagesLoadedEvent -> refreshMessages(event) - is MessagesPinEvent -> conversation.pinnedMessage = event.message - is MessagesUnpinEvent -> conversation.pinnedMessage = null - is MessagesDeleteEvent -> deleteMessages(event) - is MessagesEditEvent -> editMessage(event) - } - } - - private fun onProgressStarted() { - binding.progressBar.isVisible = adapter.isEmpty() - binding.refreshLayout.isRefreshing = adapter.isNotEmpty() - } - - private fun onProgressStopped() { - binding.progressBar.isVisible = false - binding.refreshLayout.isRefreshing = false - } - private fun prepareViews() { prepareRecyclerView() prepareRefreshLayout() + prepareEmojiButton() + prepareAttachmentsList() } private fun prepareRecyclerView() { @@ -446,28 +720,57 @@ class MessagesHistoryFragment : setColorSchemeColors( AndroidUtils.getThemeAttrColor( requireContext(), - R.attr.colorAccent + R.attr.colorPrimary ) ) setOnRefreshListener { viewModel.loadHistory(peerId = conversation.id) } } } + private fun prepareEmojiButton() { + binding.emoji.setOnLongClickListener { + val text = binding.message.text.toString() + AppGlobal.preferences.getString( + SettingsPrefsFragment.PrefFastText, SettingsPrefsFragment.PrefFastTextDefaultValue + ) + binding.message.setText(text) + binding.message.selectLast() + + binding.emoji.animate() + .scaleX(1.25f) + .scaleY(1.25f) + .setDuration(100) + .withEndAction { + if (view == null) return@withEndAction + + binding.emoji.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(100) + .start() + }.start() + true + } + } + + private fun prepareAttachmentsList() { + binding.attachmentsList.addItemDecoration( + SpaceItemDecoration(endMargin = 4.dpToPx()) + ) + binding.attachmentsList.adapter = attachmentsAdapter + } + private fun markMessagesAsImportant(event: MessagesMarkAsImportantEvent) { - var changed = false - val positions = mutableListOf() + val newList = adapter.cloneCurrentList() - for (i in adapter.indices) { - val message = adapter[i] as VkMessage - message.important = event.important + for (i in newList.indices) { + val item = newList[i] + val message: VkMessage = (if (item !is VkMessage) null else item) ?: continue if (event.messagesIds.contains(message.id)) { - if (!changed) changed = true - - positions.add(i) - - adapter[i] = message + newList[i] = message.copy(important = event.important) } } + + adapter.submitList(newList) } private fun refreshMessages(event: MessagesLoadedEvent) { @@ -485,6 +788,7 @@ class MessagesHistoryFragment : withHeader = true, withFooter = true, commitCallback = { + if (view == null) return@setItems if (smoothScroll) binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) else binding.recyclerView.scrollToPosition(adapter.lastPosition) } @@ -517,11 +821,6 @@ class MessagesHistoryFragment : ).format(message.date * 1000L) ) - val important = getString( - if (message.important) R.string.message_context_action_unmark_as_important - else R.string.message_context_action_mark_as_important - ) - val reply = getString(R.string.message_context_action_reply) val isMessageAlreadyPinned = message.id == conversation.pinnedMessage?.id @@ -531,44 +830,70 @@ class MessagesHistoryFragment : else R.string.message_context_action_pin ) + val important = getString( + if (message.important) R.string.message_context_action_unmark_as_important + else R.string.message_context_action_mark_as_important + ) + + val read = "Mark as read" + val edit = getString(R.string.message_context_action_edit) val delete = getString(R.string.message_context_action_delete) - val params = mutableListOf( - important, reply - ) + val params = mutableListOf() + val onlySentParams = mutableListOf() + + params += reply + onlySentParams += reply if (conversation.canChangePin) { params += pin + onlySentParams += pin + } + + params += important + onlySentParams += important + + if (!message.isRead(conversation) && !message.isOut) { + params += read + onlySentParams += read } if (message.canEdit()) { params += edit + onlySentParams += edit } params += delete + if (!message.isSent()) { + params.removeAll(onlySentParams) + } + val arrayParams = params.toTypedArray() MaterialAlertDialogBuilder(requireContext()) .setTitle(time) .setItems(arrayParams) { _, which -> when (params[which]) { - important -> viewModel.markAsImportant( - messagesIds = listOf(message.id), - important = !message.important - ) reply -> { if (attachmentController.message.value != message) attachmentController.message.value = message } - pin -> - showPinMessageDialog( - peerId = conversation.id, - messageId = message.id, - pin = !isMessageAlreadyPinned - ) + pin -> showPinMessageDialog( + peerId = conversation.id, + messageId = message.id, + pin = !isMessageAlreadyPinned + ) + important -> viewModel.markAsImportant( + messagesIds = listOf(message.id), + important = !message.important + ) + read -> viewModel.readMessage( + conversation.id, + message.id + ) edit -> { attachmentController.isEditing = true @@ -600,7 +925,7 @@ class MessagesHistoryFragment : pin = pin ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } @@ -613,9 +938,11 @@ class MessagesHistoryFragment : ) binding.check.isEnabled = - (conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit()) + message.isSent() && ((conversation.id != UserConfig.userId) && (!message.isOut || message.canEdit())) - if (conversation.id == UserConfig.userId) binding.check.isChecked = true + if (message.isSent() && conversation.id == UserConfig.userId || + (binding.check.isEnabled && message.isOut) + ) binding.check.isChecked = true MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.confirm_delete_message) @@ -623,6 +950,14 @@ class MessagesHistoryFragment : .setPositiveButton(R.string.action_delete) { _, _ -> attachmentController.message.value = null + if (message.isError()) { + adapter.searchIndexOf(message)?.let { index -> + adapter.removeAt(index) + } + + return@setPositiveButton + } + viewModel.deleteMessage( peerId = conversation.id, messagesIds = listOf(message.id), @@ -630,18 +965,123 @@ class MessagesHistoryFragment : deleteForAll = if (!binding.check.isEnabled) null else binding.check.isChecked ) } - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(R.string.cancel, null) .show() } private fun deleteMessages(event: MessagesDeleteEvent) { - val messagesToDelete = event.messagesIds.mapNotNull { id -> adapter.searchMessageById(id) } + if (event.peerId != conversation.id) return + val messagesToDelete = + event.messagesIds.mapNotNull { id -> adapter.searchMessageById(id) } adapter.removeAll(messagesToDelete) } private fun editMessage(event: MessagesEditEvent) { + if (event.message.peerId != conversation.id) return adapter.searchMessageIndex(event.message.id)?.let { index -> adapter[index] = event.message + adapter.notifyItemChanged(index) + } + } + + private fun readMessages(event: MessagesReadEvent) { + if (event.peerId != conversation.id) return + + val oldOutRead = conversation.outRead + val oldInRead = conversation.inRead + + if (event.isOut) { + conversation.outRead = event.messageId + } else { + conversation.inRead = event.messageId + } + + val positionsToUpdate = mutableListOf() + val newList = adapter.cloneCurrentList() + for (i in newList.indices) { + val message = newList[i] + if (message !is VkMessage) continue + + if ((message.isOut && conversation.outRead - oldOutRead > 0 && message.id > oldOutRead) || + (!message.isOut && conversation.inRead - oldInRead > 0 && message.id > oldInRead) + ) { + positionsToUpdate += i + } + } + + positionsToUpdate.forEach { index -> + adapter.notifyItemChanged(index) + + if (binding.unreadCounter.isVisible) { + setUnreadCounterVisibility( + (binding.recyclerView.layoutManager as LinearLayoutManager) + .findLastCompletelyVisibleItemPosition() + ) + } + } + } + + @Suppress("NAME_SHADOWING") + private fun setUnreadCounterVisibility( + lastCompletelyVisiblePosition: Int, + dy: Int? = null + ) { + if (lastCompletelyVisiblePosition >= adapter.lastPosition - 1) { + setUnreadCounterVisibility(false) + } else { + if (adapter.containsUnreadMessages()) { + setUnreadCounterVisibility(true) + } else { + if (dy == null) { + setUnreadCounterVisibility(false) + } else { + if (dy > 0) { + if (dy > 40) setUnreadCounterVisibility(true) + } else { + if (dy < -40) setUnreadCounterVisibility(false) + } + } + } + } + } + + private fun addNewMessage(event: MessagesNewEvent) { + if (event.message.peerId != conversation.id) return + + adapter.profiles += event.profiles + adapter.groups += event.groups + + if (adapter.containsRandomId(event.message.randomId) + || adapter.containsId(event.message.id) + ) return + + val itemCount = adapter.itemCount + + adapter.add(event.message, beforeFooter = true) { + if (view == null) return@add + + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + + if (abs(lastVisiblePosition - adapter.lastPosition) <= 3) { + binding.recyclerView.scrollToPosition(adapter.lastPosition) + } else { + setUnreadCounterVisibility(true) + // add counter of unread + } + } + adapter.notifyItemRangeChanged(0, itemCount, "avatars") + } + + private fun setUnreadCounterVisibility(isVisible: Boolean) { + if (view == null) return + + binding.unreadCounter.run { + if (isVisible) { + show() + } else { + hide() + } } } @@ -665,11 +1105,15 @@ class MessagesHistoryFragment : } private fun applyMessage(message: VkMessage) { - val title = when { - message.isGroup() && message.group.value != null -> message.group.value?.name - message.isUser() && message.user.value != null -> message.user.value?.fullName - else -> null - } + val messageUser: VkUser? = + if (message.isUser()) adapter.profiles[message.fromId] + else null + val messageGroup: VkGroup? = + if (message.isGroup()) adapter.groups[message.fromId] + else null + val title = VkUtils.getMessageTitle( + message, messageUser, messageGroup + ) val attachmentText = if (message.text == null) VkUtils.getAttachmentText( context = requireContext(), @@ -689,13 +1133,22 @@ class MessagesHistoryFragment : if (isEditing) { binding.message.setText(message.text) + binding.message.setSelection(message.text?.length ?: 0) + binding.message.requestFocusFromTouch() + binding.message.showKeyboard() } + binding.replyMessage.visible() + showPanel() } private fun clearMessage() { - hidePanel() + if (attachmentsToLoad.isEmpty()) { + hidePanel() + } + + binding.replyMessage.gone() binding.replyMessageTitle.clear() binding.replyMessageText.clear() @@ -706,66 +1159,100 @@ class MessagesHistoryFragment : } } - private fun showPanel() { - binding.attachmentPanel.visible() - binding.attachmentPanel.measure( - View.MeasureSpec.AT_MOST, View.MeasureSpec.UNSPECIFIED - ) + fun showPanel() { + if (isPanelVisible.requireValue()) return - if (attachmentController.isPanelVisible.value == false) + binding.attachmentPanel.visible() +// binding.attachmentPanel.measure( +// View.MeasureSpec.AT_MOST, View.MeasureSpec.UNSPECIFIED +// ) + + if (!attachmentController.isPanelVisible.requireValue()) attachmentController.isPanelVisible.value = true - val measuredHeight = binding.attachmentPanel.measuredHeight +// binding.attachmentPanel.visible() - binding.attachmentPanel.updateLayoutParams { - height = 0 - } - - binding.attachmentPanel.animate() - .translationY(0f) - .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) - .start() - - ValueAnimator.ofInt(0, measuredHeight).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (view == null) return@addUpdateListener - val value = animator.animatedValue as Int - binding.attachmentPanel.updateLayoutParams { - height = value - } - } - }.start() +// val measuredHeight = binding.attachmentPanel.measuredHeight +// +// binding.attachmentPanel.updateLayoutParams { +// height = 0 +// } +// +// binding.attachmentPanel.animate() +// .translationY(0f) +// .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) +// .start() +// +// ValueAnimator.ofInt(0, measuredHeight).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (view == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// if (value >= 36.dpToPx()) { +// binding.attachmentPanel.visible() +// } +// +// binding.attachmentPanel.updateLayoutParams { +// height = value +// } +// } +// }.start() } - private fun hidePanel() { - if (attachmentController.isPanelVisible.value == true) + fun hidePanel() { + if (!isPanelVisible.requireValue() || + attachmentsToLoad.isNotEmpty() || + message.value != null + ) return + + if (attachmentController.isPanelVisible.requireValue()) attachmentController.isPanelVisible.value = false - val currentHeight = binding.attachmentPanel.height + binding.attachmentPanel.gone() - binding.attachmentPanel.animate() - .translationY(75F) - .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) - .start() - - ValueAnimator.ofInt(currentHeight, 0).apply { - duration = ATTACHMENT_PANEL_ANIMATION_DURATION - interpolator = LinearInterpolator() - - addUpdateListener { animator -> - if (view == null) return@addUpdateListener - val value = animator.animatedValue as Int - - binding.attachmentPanel.updateLayoutParams { - height = value - } - } - }.start() +// val currentHeight = binding.attachmentPanel.height +// +// binding.attachmentPanel.animate() +// .translationY(75F) +// .setDuration(ATTACHMENT_PANEL_ANIMATION_DURATION) +// .start() +// +// ValueAnimator.ofInt(currentHeight, 0).apply { +// duration = ATTACHMENT_PANEL_ANIMATION_DURATION +// interpolator = LinearInterpolator() +// +// addUpdateListener { animator -> +// if (view == null) return@addUpdateListener +// val value = animator.animatedValue as Int +// +// if (value <= 36.dpToPx()) { +// binding.attachmentPanel.gone() +// } +// +// binding.attachmentPanel.updateLayoutParams { +// height = value +// } +// } +// doOnEnd { +// if (view == null) return@doOnEnd +// binding.attachmentPanel.gone() +// } +// }.start() } + } + fun openForwardsScreen( + conversation: VkConversation, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ) { + requireActivityRouter().navigateTo( + Screens.ForwardedMessages(conversation, messages, profiles, groups) + ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt index e829cbc7..1051fd44 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt @@ -1,46 +1,100 @@ package com.meloda.fast.screens.messages -import androidx.lifecycle.viewModelScope -import com.meloda.fast.api.LongPollEvent -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.base.ApiError +import com.meloda.fast.api.longpoll.LongPollEvent +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.attachments.VkVideo +import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.messages.* +import com.meloda.fast.api.network.photos.PhotosSaveMessagePhotoRequest import com.meloda.fast.base.viewmodel.BaseViewModel import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.data.audios.AudiosRepository +import com.meloda.fast.data.files.FilesRepository +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.data.photos.PhotosRepository +import com.meloda.fast.data.videos.VideosRepository +import com.meloda.fast.extensions.requireNotNull +import com.meloda.fast.screens.conversations.MessagesNewEvent import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + @HiltViewModel class MessagesHistoryViewModel @Inject constructor( - private val messages: MessagesDataSource, - updatesParser: LongPollUpdatesParser + private val messagesRepository: MessagesRepository, + updatesParser: LongPollUpdatesParser, + private val photosRepository: PhotosRepository, + private val filesRepository: FilesRepository, + private val audiosRepository: AudiosRepository, + private val videosRepository: VideosRepository ) : BaseViewModel() { init { updatesParser.onNewMessage { -// viewModelScope.launch { handleNewMessage(it) } + launch { handleNewMessage(it) } } updatesParser.onMessageEdited { - viewModelScope.launch { handleEditedMessage(it) } + launch { handleEditedMessage(it) } + } + + updatesParser.onMessageIncomingRead { + launch { handleReadIncomingEvent(it) } + } + + updatesParser.onMessageOutgoingRead { + launch { handleReadOutgoingEvent(it) } } } - private suspend fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { - sendEvent(com.meloda.fast.screens.messages.MessagesEditEvent(event.message)) + private suspend fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { + sendEvent(MessagesNewEvent(event.message, event.profiles, event.groups)) } - fun loadHistory(peerId: Int) = viewModelScope.launch { + private suspend fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { + sendEvent(MessagesEditEvent(event.message)) + } + + private suspend fun handleReadIncomingEvent(event: LongPollEvent.VkMessageReadIncomingEvent) { + sendEvent( + MessagesReadEvent( + isOut = false, + peerId = event.peerId, + messageId = event.messageId + ) + ) + } + + private suspend fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) { + sendEvent( + MessagesReadEvent( + isOut = true, + peerId = event.peerId, + messageId = event.messageId + ) + ) + } + + fun loadHistory(peerId: Int) = launch { makeJob({ - messages.getHistory( + messagesRepository.getHistory( MessagesGetHistoryRequest( - count = 30, + count = 100, peerId = peerId, extended = true, fields = VKConstants.ALL_FIELDS @@ -66,10 +120,11 @@ class MessagesHistoryViewModel @Inject constructor( val hashMessages = hashMapOf() response.items.forEach { baseMessage -> - baseMessage.asVkMessage().let { message -> hashMessages[message.id] = message } + baseMessage.asVkMessage() + .let { message -> hashMessages[message.id] = message } } - messages.store(hashMessages.values.toList()) + messagesRepository.store(hashMessages.values.toList()) val conversations = hashMapOf() response.conversations?.let { baseConversations -> @@ -97,31 +152,38 @@ class MessagesHistoryViewModel @Inject constructor( message: String? = null, randomId: Int = 0, replyTo: Int? = null, - setId: ((messageId: Int) -> Unit)? = null - ) = viewModelScope.launch { + setId: ((messageId: Int) -> Unit)? = null, + onError: ((error: Throwable) -> Unit)? = null, + attachments: List? = null + ) = launch { + delay(2500) makeJob( { - messages.send( + messagesRepository.send( MessagesSendRequest( peerId = peerId, randomId = randomId, message = message, - replyTo = replyTo + replyTo = replyTo, + attachments = attachments ) ) }, onAnswer = { val response = it.response ?: return@makeJob setId?.invoke(response) + }, + onError = { + onError?.invoke(it) }) } fun markAsImportant( messagesIds: List, important: Boolean - ) = viewModelScope.launch { + ) = launch { makeJob({ - messages.markAsImportant( + messagesRepository.markAsImportant( MessagesMarkAsImportantRequest( messagesIds = messagesIds, important = important @@ -144,10 +206,10 @@ class MessagesHistoryViewModel @Inject constructor( messageId: Int? = null, conversationMessageId: Int? = null, pin: Boolean - ) = viewModelScope.launch { + ) = launch { if (pin) { makeJob({ - messages.pin( + messagesRepository.pin( MessagesPinMessageRequest( peerId = peerId, messageId = messageId, @@ -161,7 +223,7 @@ class MessagesHistoryViewModel @Inject constructor( } ) } else { - makeJob({ messages.unpin(MessagesUnPinMessageRequest(peerId = peerId)) }, + makeJob({ messagesRepository.unpin(MessagesUnPinMessageRequest(peerId = peerId)) }, onAnswer = { println("Fast::MessagesHistoryViewModel::unPin::Response::${it.response}") sendEvent(MessagesUnpinEvent) @@ -176,18 +238,27 @@ class MessagesHistoryViewModel @Inject constructor( conversationsMessagesIds: List? = null, isSpam: Boolean? = null, deleteForAll: Boolean? = null - ) = viewModelScope.launch { - makeJob({ - messages.delete( - MessagesDeleteRequest( - peerId = peerId, - messagesIds = messagesIds, - conversationsMessagesIds = conversationsMessagesIds, - isSpam = isSpam, - deleteForAll = deleteForAll + ) = launch { + makeJob( + { + messagesRepository.delete( + MessagesDeleteRequest( + peerId = peerId, + messagesIds = messagesIds, + conversationsMessagesIds = conversationsMessagesIds, + isSpam = isSpam, + deleteForAll = deleteForAll + ) ) - ) - }, onAnswer = { sendEvent(MessagesDeleteEvent(messagesIds = messagesIds ?: emptyList())) }) + }, + onAnswer = { + sendEvent( + MessagesDeleteEvent( + peerId = peerId, + messagesIds = messagesIds ?: emptyList() + ) + ) + }) } fun editMessage( @@ -196,10 +267,10 @@ class MessagesHistoryViewModel @Inject constructor( messageId: Int, message: String? = null, attachments: List? = null - ) = viewModelScope.launch { + ) = launch { makeJob( { - messages.edit( + messagesRepository.edit( MessagesEditRequest( peerId = peerId, messageId = messageId, @@ -210,10 +281,318 @@ class MessagesHistoryViewModel @Inject constructor( }, onAnswer = { originalMessage.text = message - sendEvent(com.meloda.fast.screens.messages.MessagesEditEvent(originalMessage)) + sendEvent(MessagesEditEvent(originalMessage)) } ) } + + fun readMessage(peerId: Int, messageId: Int) { + makeJob( + { messagesRepository.markAsRead(peerId, startMessageId = messageId) }, + onAnswer = { + sendEvent(MessagesReadEvent(false, peerId, messageId)) + } + ) + } + + suspend fun uploadPhoto( + peerId: Int, + photo: File, + name: String + ) = suspendCoroutine { + launch { + val uploadServerUrl = getPhotoMessageUploadServer(peerId) + val uploadedFileInfo = uploadPhotoToServer(uploadServerUrl, photo, name) + + val savedAttachment = saveMessagePhoto( + uploadedFileInfo.first, + uploadedFileInfo.second, + uploadedFileInfo.third + ) + + it.resume(savedAttachment) + }.also { it.invokeOnCompletion { launch { onStop() } } } + } + + private suspend fun getPhotoMessageUploadServer(peerId: Int) = suspendCoroutine { + launch { + val uploadServerResponse = makeSuspendJob( + { photosRepository.getMessagesUploadServer(peerId) } + ) + if (!uploadServerResponse.isSuccessful()) { + throw requireNotNull(uploadServerResponse.error.throwable) + } else { + (uploadServerResponse as ApiAnswer.Success).run { + it.resume(requireNotNull(this.data.response?.uploadUrl)) + } + } + } + } + + private suspend fun uploadPhotoToServer( + uploadUrl: String, + photo: File, + name: String + ) = suspendCoroutine> { + launch { + val requestBody = photo.asRequestBody("image/*".toMediaType()) + val body = MultipartBody.Part.createFormData("photo", name, requestBody) + + val uploadFileResponse = makeSuspendJob( + { photosRepository.uploadPhoto(uploadUrl, body) } + ) + if (!uploadFileResponse.isSuccessful()) { + throw uploadFileResponse.error.throwable!! + } else { + (uploadFileResponse as ApiAnswer.Success).data.run { + it.resume(Triple(this.server, this.photo, this.hash)) + } + } + } + } + + private suspend fun saveMessagePhoto( + server: Int, + photo: String, + hash: String + ) = suspendCoroutine { + launch { + val saveResponse = makeSuspendJob( + { + photosRepository.saveMessagePhoto( + PhotosSaveMessagePhotoRequest( + photo, + server, + hash + ) + ) + } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.response?.run { + it.resume(requireNotNull(first().asVkPhoto())) + } + } + } + } + + suspend fun uploadVideo( + file: File, + name: String + ) = suspendCoroutine { + launch { + val uploadInfo = getVideoMessageUploadServer() + + uploadVideoToServer( + uploadInfo.first, + file, + name + ) + + it.resume(uploadInfo.second) + } + } + + private suspend fun getVideoMessageUploadServer() = suspendCoroutine> { + launch { + val saveResponse = makeSuspendJob( + { videosRepository.save() } + ) + + if (!saveResponse.isSuccessful()) { + it.resumeWithException(saveResponse.error.throwable!!) + return@launch + } else { + val response = (saveResponse as ApiAnswer.Success).data.response ?: return@launch + + val uploadUrl = response.uploadUrl + val video = VkVideo( + id = response.videoId, + ownerId = response.ownerId, + images = emptyList(), + firstFrames = null, + accessKey = response.accessKey, + title = response.title + ) + + it.resume(uploadUrl to video) + } + } + } + + private suspend fun uploadVideoToServer( + uploadUrl: String, + file: File, + name: String + ) = launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("video_file", name, requestBody) + + val response = makeSuspendJob( + { videosRepository.upload(uploadUrl, body) } + ) + if (!response.isSuccessful()) { + throw response.error.throwable!! + } + } + + suspend fun uploadAudio( + file: File, + name: String + ) = suspendCoroutine { + launch { + val uploadUrl = getAudioUploadServer() + val uploadInfo = uploadAudioToServer(uploadUrl, file, name) + val saveInfo = saveMessageAudio( + uploadInfo.first, uploadInfo.second, uploadInfo.third + ) + + it.resume(saveInfo) + } + } + + private suspend fun getAudioUploadServer() = suspendCoroutine { + launch { + val uploadResponse = makeSuspendJob( + { audiosRepository.getUploadServer() } + ) + if (!uploadResponse.isSuccessful()) { + throw uploadResponse.error.throwable!! + } else { + (uploadResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).uploadUrl) + } + } + } + } + + private suspend fun uploadAudioToServer( + uploadUrl: String, + file: File, + name: String + ) = suspendCoroutine> { + launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("file", name, requestBody) + + val uploadResponse = makeSuspendJob( + { audiosRepository.upload(uploadUrl, body) } + ) + if (!uploadResponse.isSuccessful()) { + throw uploadResponse.error.throwable!! + } else { + (uploadResponse as ApiAnswer.Success).data.run { + if (this.error != null) { + throw ApiError(error = error) + } else { + it.resume(Triple(this.server, requireNotNull(this.audio), this.hash)) + } + } + } + } + } + + private suspend fun saveMessageAudio( + server: Int, + audio: String, + hash: String + ) = suspendCoroutine { + launch { + val saveResponse = makeSuspendJob( + { audiosRepository.save(server, audio, hash) } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).asVkAudio()) + } + } + } + } + + suspend fun uploadFile( + peerId: Int, + file: File, + name: String, + type: FilesRepository.FileType + ) = suspendCoroutine { + launch { + val uploadServerUrl = getFileMessageUploadServer(peerId, type) + val uploadedFileInfo = uploadFileToServer(uploadServerUrl, file, name) + val savedAttachmentPair = saveMessageFile(uploadedFileInfo) + + it.resume(savedAttachmentPair.second) + }.also { it.invokeOnCompletion { launch { onStop() } } } + } + + private suspend fun getFileMessageUploadServer( + peerId: Int, + type: FilesRepository.FileType + ) = suspendCoroutine { + launch { + val uploadServerResponse = makeSuspendJob( + { filesRepository.getMessagesUploadServer(peerId, type) } + ) + if (!uploadServerResponse.isSuccessful()) { + throw uploadServerResponse.error.throwable!! + } else { + (uploadServerResponse as ApiAnswer.Success).data.response.run { + it.resume(requireNotNull(this).uploadUrl) + } + } + } + } + + private suspend fun uploadFileToServer( + uploadUrl: String, + file: File, + name: String + ) = suspendCoroutine { + launch { + val requestBody = file.asRequestBody() + val body = MultipartBody.Part.createFormData("file", name, requestBody) + + val uploadFileResponse = makeSuspendJob( + { filesRepository.uploadFile(uploadUrl, body) } + ) + if (!uploadFileResponse.isSuccessful()) { + throw uploadFileResponse.error.throwable!! + } else { + (uploadFileResponse as ApiAnswer.Success).data.run { + if (this.error != null) { + throw ApiError(error = this.error) + } else { + it.resume(this.file.requireNotNull()) + } + } + } + } + } + + private suspend fun saveMessageFile(file: String) = + suspendCoroutine> { + launch { + val saveResponse = makeSuspendJob( + { filesRepository.saveMessageFile(file) } + ) + if (!saveResponse.isSuccessful()) { + throw saveResponse.error.throwable!! + } else { + (saveResponse as ApiAnswer.Success).data.run { + val response = this.response.requireNotNull() + it.resume( + response.type to ( + response.file?.asVkFile() + ?: response.voiceMessage?.asVkVoiceMessage() + ).requireNotNull() + ) + } + } + } + } } data class MessagesLoadedEvent( @@ -224,12 +603,19 @@ data class MessagesLoadedEvent( val groups: HashMap ) : VkEvent() -data class MessagesMarkAsImportantEvent(val messagesIds: List, val important: Boolean) : VkEvent() +data class MessagesMarkAsImportantEvent(val messagesIds: List, val important: Boolean) : + VkEvent() data class MessagesPinEvent(val message: VkMessage) : VkEvent() object MessagesUnpinEvent : VkEvent() -data class MessagesDeleteEvent(val messagesIds: List) : VkEvent() +data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List) : VkEvent() -data class MessagesEditEvent(val message: VkMessage) : VkEvent() \ No newline at end of file +data class MessagesEditEvent(val message: VkMessage) : VkEvent() + +data class MessagesReadEvent( + val isOut: Boolean, + val peerId: Int, + val messageId: Int +) : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt index a906f62d..67153ea4 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt @@ -1,37 +1,37 @@ package com.meloda.fast.screens.messages import android.content.Context +import android.content.res.ColorStateList import android.graphics.drawable.ColorDrawable import android.util.Log import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import android.widget.Space import android.widget.TextView import androidx.appcompat.widget.LinearLayoutCompat +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import coil.load +import androidx.core.view.updateLayoutParams import com.meloda.fast.R -import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.model.attachments.VkSticker import com.meloda.fast.common.AppGlobal -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.extensions.visible -import com.meloda.fast.widget.BoundedLinearLayout +import com.meloda.fast.extensions.* +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import java.text.SimpleDateFormat import java.util.* -import kotlin.math.roundToInt class MessagesPreparator constructor( private val context: Context, + private val payloads: MutableList? = null, + private val root: View? = null, private val conversation: VkConversation, @@ -39,44 +39,42 @@ class MessagesPreparator constructor( private val prevMessage: VkMessage? = null, private val nextMessage: VkMessage? = null, - private val bubble: BoundedLinearLayout? = null, + private val bubble: ConstraintLayout, private val text: TextView? = null, private val avatar: ImageView? = null, private val title: TextView? = null, private val spacer: Space? = null, - private val unread: ImageView? = null, + private val messageState: ImageView? = null, private val time: TextView? = null, - private val textContainer: LinearLayoutCompat? = null, + private val replyContainer: FrameLayout? = null, + private val timeReadContainer: View, private val attachmentContainer: LinearLayoutCompat? = null, - private val attachmentSpacer: Space? = null, private val profiles: Map, - private val groups: Map + private val groups: Map, + + private val isForwards: Boolean = false ) { - init { - val maxWidth = (AppGlobal.screenWidth * 0.7).roundToInt() - - if (bubble != null) bubble.maxWidth = maxWidth - } - - private val backgroundNormalIn = - ContextCompat.getDrawable(context, R.drawable.ic_message_in_background) - private val backgroundMiddleIn = - ContextCompat.getDrawable(context, R.drawable.ic_message_in_background_middle) - - private val backgroundNormalOut = - ContextCompat.getDrawable(context, R.drawable.ic_message_out_background) - private val backgroundMiddleOut = - ContextCompat.getDrawable(context, R.drawable.ic_message_out_background_middle) - private val rootHighlightedColor = ContextCompat.getColor(context, R.color.n2_100) private var photoClickListener: ((url: String) -> Unit)? = null + private var replyClickListener: ((replyMessage: VkMessage) -> Unit)? = null + private var forwardsClickListener: ((forwards: List) -> Unit)? = null - fun setPhotoClickListener(unit: ((url: String) -> Unit)?): MessagesPreparator { - this.photoClickListener = unit + fun withPhotoClickListener(block: ((url: String) -> Unit)?): MessagesPreparator { + this.photoClickListener = block + return this + } + + fun withReplyClickListener(block: ((replyMessage: VkMessage) -> Unit)?): MessagesPreparator { + this.replyClickListener = block + return this + } + + fun withForwardsClickListener(block: ((forwards: List) -> Unit)?): MessagesPreparator { + this.forwardsClickListener = block return this } @@ -94,8 +92,6 @@ class MessagesPreparator constructor( prepareAttachments() - prepareAttachmentsSpacer() - prepareBubbleBackground() prepareText() @@ -105,10 +101,12 @@ class MessagesPreparator constructor( messageGroup = messageGroup ) - if (message.isPeerChat()) { + if (message.isPeerChat() || isForwards) { val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message) val nextSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(message, nextMessage) val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val nextMessageFiveMinAfter = + VkUtils.isPreviousMessageSentFiveMinutesAgo(message, nextMessage) val change = (prevMessage?.date ?: 0) - message.date @@ -117,27 +115,37 @@ class MessagesPreparator constructor( "text: ${message.text}; prevText: ${prevMessage?.text}; time change: $change; fromDiffSender: $prevSenderDiff; fiveMinAgo: $fiveMinAgo; " ) - title?.isVisible = prevSenderDiff || fiveMinAgo + title?.toggleVisibility(prevSenderDiff || fiveMinAgo) avatar?.visibility = if (nextSenderDiff - || (fiveMinAgo && prevSenderDiff) - || !prevSenderDiff + || (fiveMinAgo && prevSenderDiff && nextMessageFiveMinAfter) + || nextMessageFiveMinAfter + || (!prevSenderDiff && nextSenderDiff) || nextMessage == null ) View.VISIBLE else View.INVISIBLE } else { - title?.isVisible = false - avatar?.isVisible = false + title?.gone() + avatar?.gone() + } + + + bubble.run { + updateLayoutParams { + matchConstraintMaxWidth = + if (avatar?.isVisible == true) AppGlobal.screenWidth80 - avatar.width - 6.dpToPx() + else AppGlobal.screenWidth80 + } } if (title != null) { val titleString = when { - message.isUser() && messageUser != null -> messageUser.firstName + message.isUser() && messageUser != null -> messageUser.fullName message.isGroup() && messageGroup != null -> messageGroup.name else -> null } - title.text = titleString + title.text = titleString.orDots() } } @@ -150,92 +158,110 @@ class MessagesPreparator constructor( } private fun prepareTime() { - time?.text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L) + val sentTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(message.date * 1000L) + + val timeText = + if (message.isUpdated()) context.getString(R.string.message_update_time_short, sentTime) + else sentTime + + time?.text = timeText } private fun prepareUnreadIndicator() { - unread?.toggleVisibility(!message.isRead(conversation)) + val isMessageRead = message.isRead(conversation) + + val drawableRes: Int = when (message.state) { + VkMessage.State.Sending -> { + R.drawable.ic_round_access_time_24 + } + VkMessage.State.Error -> { + R.drawable.ic_round_error_outline_24 + } + VkMessage.State.Sent -> { + if (isMessageRead) R.drawable.ic_round_done_all_24 + else R.drawable.ic_round_done_24 + } + } + + messageState?.run { + imageTintList = ColorStateList.valueOf( + ContextCompat.getColor( + context, + if (message.state == VkMessage.State.Error) R.color.colorError + else R.color.colorOnBackground + ) + ) + + toggleVisibility(!isMessageRead || message.isOut) + setImageResource(drawableRes) + } } private fun prepareSpacer() { - spacer?.isVisible = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val fiveMinAgo = VkUtils.isPreviousMessageSentFiveMinutesAgo(prevMessage, message) + val prevSenderDiff = VkUtils.isPreviousMessageFromDifferentSender(prevMessage, message) + spacer?.toggleVisibility(fiveMinAgo || prevSenderDiff) } private fun prepareAttachments() { attachmentContainer?.removeAllViews() - textContainer?.let { textContainer -> - if (textContainer.childCount > 1) { - textContainer.removeViews(1, textContainer.childCount - 1) - } - } - - if (attachmentContainer != null && textContainer != null) { - - if (message.attachments.isNullOrEmpty()) { + if (attachmentContainer != null && replyContainer != null) { + if ( + !message.hasAttachments() && + !message.hasReply() && + !message.hasForwards() && + !message.hasGeo() + ) { attachmentContainer.gone() + replyContainer.gone() } else { - attachmentContainer.visible() - AttachmentInflater( context = context, container = attachmentContainer, - textContainer = textContainer, + replyContainer = replyContainer, + timeReadContainer = timeReadContainer, message = message, groups = groups, profiles = profiles ) - .setPhotoClickListener(photoClickListener) + .withPhotoClickListener(photoClickListener) + .withReplyClickListener(replyClickListener) + .withForwardsClickListener(forwardsClickListener) .inflate() } } } - private fun prepareAttachmentsSpacer() { - attachmentSpacer?.isVisible = - !message.attachments.isNullOrEmpty() && text?.isVisible == true - } - private fun prepareBubbleBackground() { - if (bubble != null) { - // TODO: 9/23/2021 use external function - bubble.background = - if (!message.attachments.isNullOrEmpty() && message.attachments!![0] is VkSticker) null - else { - if (message.isOut) { - if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundNormalOut - else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleOut - else backgroundNormalOut - } else { - if (prevMessage == null || prevMessage.fromId != message.fromId) backgroundNormalIn - else if (prevMessage.fromId == message.fromId && message.date - prevMessage.date < 60) backgroundMiddleIn - else backgroundNormalIn - } - } - } +// bubble.background = if (message.isOut) backgroundMiddleOut else backgroundMiddleIn } private fun prepareText() { - if (bubble != null && text != null) { + if (text != null) { + text.updateLayoutParams { + val topMargin = if (title != null && title.isVisible) 6 else 0.dpToPx() + + goneTopMargin = topMargin + } + if (message.text == null) { + text.clear() text.gone() - - val hasAttachments = !message.attachments.isNullOrEmpty() - var shouldBeVisible = hasAttachments - if (hasAttachments) { - for (attachment in message.attachments ?: emptyList()) { - if (VKConstants.separatedFromTextAttachments.contains(attachment.javaClass)) { - shouldBeVisible = false - break - } - } - } - - bubble.toggleVisibility(shouldBeVisible) } else { text.visible() - bubble.visible() - text.text = VkUtils.prepareMessageText(message.text ?: "") + + val updSpacer = "\t\t\t\t" + val timeSpacer = "\t\t\t\t\t\t" + val messageStateSpacer = "\t\t\t" + + val preparedText = + VkUtils.prepareMessageText(message.text ?: "") + + (if (message.isUpdated()) updSpacer else "") + + timeSpacer + + (if (!message.isOut && message.isRead(conversation)) "" else messageStateSpacer) + + text.text = preparedText } } } @@ -247,7 +273,10 @@ class MessagesPreparator constructor( if (avatar != null) { val avatarUrl = VkUtils.getMessageAvatar(message, messageUser, messageGroup) - avatar.load(avatarUrl) { crossfade(100) } + avatar.loadWithGlide( + url = avatarUrl, + crossFade = true + ) } } } diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt index f3f99443..3ff40d25 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt @@ -6,7 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.fragment.app.viewModels -import com.meloda.fast.base.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.BaseViewModelFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt index b9a2984d..6b06b293 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt @@ -2,8 +2,8 @@ package com.meloda.fast.screens.photos import android.widget.ImageView import androidx.lifecycle.viewModelScope -import coil.load import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.extensions.ImageLoader.loadWithGlide import kotlinx.coroutines.launch class PhotoViewViewModel : BaseViewModel() { @@ -12,11 +12,7 @@ class PhotoViewViewModel : BaseViewModel() { url: String, imageView: ImageView ) = viewModelScope.launch { - imageView.load(url) - } - - fun saveImageToLocalStorage(url: String) = viewModelScope.launch { - TODO("Not implemented") + imageView.loadWithGlide(url = url) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt new file mode 100644 index 00000000..a5174f6b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt @@ -0,0 +1,109 @@ +package com.meloda.fast.screens.settings + +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.common.AppGlobal +import com.microsoft.appcenter.crashes.Crashes + +class SettingsPrefsFragment : PreferenceFragmentCompat(), + Preference.OnPreferenceClickListener, + Preference.OnPreferenceChangeListener { + + @Suppress("unused") + companion object { + const val KeyChangeMultiline = "change_multiline" + const val ArgEnabled = "enabled" + + const val CategoryAppearance = "appearance" + const val PrefMultiline = "multiline" + + const val CategoryFeatures = "features" + const val PrefHideKeyboardOnScroll = "hide_keyboard_on_scroll" + const val PrefFastText = "fast_text" + const val PrefFastTextDefaultValue = "¯\\_(ツ)_/¯" + + const val CategoryVisibility = "visibility" + const val PrefSendOnlineStatus = "send_online_status" + + const val CategoryUpdates = "updates" + const val PrefCheckUpdates = "check_updates" + + const val CategoryDebug = "debug" + const val PrefPerformCrash = "perform_crash" + const val PrefShowDestroyedLongPollAlert = "show_destroyed_long_poll_alert" + const val PrefShowCrashAlert = "show_crash_alert" + + const val CategoryAppCenter = "msappcenter" + const val PrefEnableReporter = "msappcenter.enable" + } + + private val prefs = AppGlobal.preferences + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences, rootKey) + + getPreference(PrefMultiline)?.let { + it.onPreferenceChangeListener = this + } + + getPreference(PrefCheckUpdates)?.let { + val version = AppGlobal.versionName.split("_").getOrNull(1) + val summaryText = getString(R.string.pref_updates_check_update_summary, version) + it.summary = summaryText + + it.onPreferenceClickListener = this + } + + getPreference(CategoryDebug)?.let { + it.isVisible = BuildConfig.DEBUG + } + getPreference(PrefPerformCrash)?.let { + it.isVisible = BuildConfig.DEBUG + it.onPreferenceClickListener = this + } + + findPreference(PrefFastText)?.summaryProvider = fastTextSummaryProvider + } + + override fun onPreferenceClick(preference: Preference): Boolean { + return when (preference.key) { + PrefCheckUpdates -> { + rootFragment?.openUpdatesScreen() + true + } + PrefPerformCrash -> { + Crashes.generateTestCrash() + true + } + else -> false + } + } + + override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { + return when (preference.key) { + PrefMultiline -> { + val enabled = newValue as Boolean + setFragmentResult(KeyChangeMultiline, bundleOf(ArgEnabled to enabled)) + true + } + else -> false + } + } + + private val fastTextSummaryProvider = Preference.SummaryProvider { + getString( + R.string.pref_message_fast_text_summary, + prefs.getString(PrefFastText, PrefFastTextDefaultValue) + ) + } + + private val rootFragment: SettingsRootFragment? get() = parentFragment as? SettingsRootFragment + + private fun getPreference(key: String) = findPreference(key) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt new file mode 100644 index 00000000..4c2272f9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt @@ -0,0 +1,39 @@ +package com.meloda.fast.screens.settings + +import android.os.Bundle +import android.view.View +import android.viewbinding.library.fragment.viewBinding +import androidx.fragment.app.commit +import androidx.fragment.app.setFragmentResultListener +import com.meloda.fast.R +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.common.Screens +import com.meloda.fast.databinding.FragmentSettingsRootBinding + +class SettingsRootFragment : BaseFragment(R.layout.fragment_settings_root) { + + companion object { + const val KeyCheckUpdates = "check_updates" + } + + private val binding: FragmentSettingsRootBinding by viewBinding() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + setFragmentResultListener(KeyCheckUpdates) { _, _ -> + openUpdatesScreen() + } + + childFragmentManager.commit { + replace(R.id.settings_fragment_container, SettingsPrefsFragment()) + } + } + + fun openUpdatesScreen() { + requireActivityRouter().navigateTo(Screens.Updates()) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt new file mode 100644 index 00000000..24b7ef02 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt @@ -0,0 +1,5 @@ +package com.meloda.fast.screens.updates + +enum class UpdateState { + NewUpdate, NoUpdates, Loading, Error, Downloading +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt new file mode 100644 index 00000000..bda360af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt @@ -0,0 +1,362 @@ +package com.meloda.fast.screens.updates + +import android.animation.ObjectAnimator +import android.app.DownloadManager +import android.content.IntentFilter +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.util.Log +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.viewbinding.library.fragment.viewBinding +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.R +import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.common.AppConstants +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.databinding.FragmentUpdatesBinding +import com.meloda.fast.extensions.clear +import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.model.UpdateItem +import com.meloda.fast.receiver.DownloadManagerReceiver +import com.meloda.fast.util.AndroidUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.ResponseBody +import java.io.* +import java.util.* + +@AndroidEntryPoint +class UpdatesFragment : BaseViewModelFragment(R.layout.fragment_updates) { + + companion object { + private const val ARG_UPDATE_ITEM = "arg_update_item" + private const val ARG_FILE_BASE_PATH = "file://" + private const val ARG_PROVIDER_PATH = ".provider" + + fun newInstance(updateItem: UpdateItem? = null): UpdatesFragment { + val fragment = UpdatesFragment() + if (updateItem != null) { + fragment.arguments = bundleOf(ARG_UPDATE_ITEM to updateItem) + } else { + fragment.arguments = Bundle() + } + + return fragment + } + } + + override val viewModel: UpdatesViewModel by viewModels() + + private val binding: FragmentUpdatesBinding by viewBinding() + + private var downloadId: Long? = null + + private var timer: Timer? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + UpdateManager.newUpdate.observe(viewLifecycleOwner) { item -> + viewModel.currentItem.setIfNotEquals(item) + } + + viewModel.updateState.observe(viewLifecycleOwner) { state -> + state?.run { refreshState(this) } + } + + if (requireArguments().containsKey(ARG_UPDATE_ITEM)) { + val updateItem: UpdateItem = requireArguments().getParcelable(ARG_UPDATE_ITEM) ?: return + viewModel.currentItem.setIfNotEquals(updateItem) + viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) + } else { + viewModel.checkUpdates() + } + + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + binding.changelog.setOnClickListener { + showChangelogAlert() + } + } + + private fun refreshState(state: UpdateState) { + binding.actionButton.toggleVisibility( + !listOf( + UpdateState.Downloading, + UpdateState.Loading + ).contains(viewModel.updateState.value) + ) + binding.flow.toggleVisibility( + !listOf( + UpdateState.Downloading, + UpdateState.Loading + ).contains(viewModel.updateState.value) + ) + binding.progress.toggleVisibility( + viewModel.updateState.value == UpdateState.Loading + ) + binding.changelog.toggleVisibility( + viewModel.updateState.value == UpdateState.NewUpdate + ) + binding.loadingProgress.toggleVisibility( + viewModel.updateState.value == UpdateState.Downloading + ) + + if (state != UpdateState.Downloading) { + timer?.cancel() + downloadId?.run { AppGlobal.downloadManager.remove(this) } + } + + when (state) { + UpdateState.NewUpdate -> { + val item = viewModel.currentItem.value ?: return + binding.title.setText(R.string.fragment_updates_new_version) + + binding.description.text = getString( + R.string.fragment_updates_new_version_description, + item.versionName + ) + + binding.actionButton.setText(R.string.fragment_updates_download_update) + binding.actionButton.setOnClickListener { checkIsInstallingAllowed(item) } + } + UpdateState.NoUpdates -> { + binding.title.setText(R.string.fragment_updates_no_updates) + binding.description.setText(R.string.fragment_updates_no_updates_description) + + binding.actionButton.setText(R.string.fragment_updates_check_updates) + binding.actionButton.setOnClickListener { viewModel.checkUpdates() } + } + UpdateState.Loading -> { + binding.title.clear() + binding.description.clear() + binding.actionButton.clear() + } + UpdateState.Error -> { + val error = viewModel.currentError.value ?: return + + binding.title.setText(R.string.fragment_updates_error_occurred) + + val errorText = + if (error.contains("cannot be converted", ignoreCase = true) + || error.contains("begin_object", ignoreCase = true) + ) { + "OTA Server is unavailable" + } else { + getString(R.string.fragment_updates_error_occurred_description, error) + } + + binding.description.text = errorText + + binding.actionButton.setText(R.string.fragment_updates_try_again) + binding.actionButton.setOnClickListener { viewModel.checkUpdates() } + } + UpdateState.Downloading -> { + binding.loadingProgress.run { + max = 0 + progress = 0 + isIndeterminate = true + } + } + } + } + + private fun checkIsInstallingAllowed(item: UpdateItem) { + if (!AndroidUtils.isCanInstallUnknownApps(requireContext())) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.warning) + builder.setMessage(R.string.fragment_updates_unknown_sources_disabled_message) + builder.setPositiveButton(R.string.yes) { _, _ -> + AndroidUtils.openInstallUnknownAppsScreen(requireContext()) + } + builder.setNegativeButton(R.string.no, null) + builder.show() + } else { + downloadUpdate(item) + } + } + + private fun downloadUpdate(newUpdate: UpdateItem) { + viewModel.updateState.setIfNotEquals(UpdateState.Loading) + + timer = Timer() + + val apkName = newUpdate.versionName + + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/$apkName.apk" + + val file = File(destination) + if (file.exists()) file.delete() + + val request = DownloadManager.Request(Uri.parse(newUpdate.downloadLink)).apply { + setTitle("${getString(R.string.app_name)} ${apkName}.apk") + setMimeType(AppConstants.INSTALL_APP_MIME_TYPE) + setDestinationInExternalFilesDir( + requireContext(), + Environment.DIRECTORY_DOWNLOADS, + "$apkName.apk" + ) + setAllowedNetworkTypes( + DownloadManager.Request.NETWORK_WIFI or + DownloadManager.Request.NETWORK_MOBILE + ) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + + val receiver = DownloadManagerReceiver() + receiver.onReceiveAction = { + timer?.cancel() + downloadId = null + + installUpdate(file) + + requireContext().unregisterReceiver(receiver) + + viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) + } + + requireContext().registerReceiver( + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + + downloadId = AppGlobal.downloadManager.enqueue(request) + + viewModel.updateState.setIfNotEquals(UpdateState.Downloading) + + if (binding.loadingProgress.max != 100 * 100) { + binding.loadingProgress.max = 100 * 100 + } + + timer?.schedule(object : TimerTask() { // for progress + override fun run() { + val query = DownloadManager.Query() + query.setFilterById(downloadId ?: -1) + + val cursor = AppGlobal.downloadManager.query(query) + if (cursor.moveToFirst()) { + val sizeIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val downloadedIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val size = cursor.getInt(sizeIndex) + val downloaded = cursor.getInt(downloadedIndex) + + val progress = + if (size != -1) (downloaded * 100.0F / size) else 0.0F + + if (progress.toInt() >= 1) { + lifecycleScope.launch(Dispatchers.Main) { + if (view == null) { + downloadId?.run { AppGlobal.downloadManager.remove(this) } + timer?.cancel() + return@launch + } + binding.loadingProgress.isIndeterminate = false + + if (binding.loadingProgress.progress != progress.toInt()) { + ObjectAnimator.ofInt( + binding.loadingProgress, + "progress", + binding.loadingProgress.progress, + progress.toInt() * 100 + ).apply { + duration = 250 + setAutoCancel(true) + interpolator = DecelerateInterpolator() + }.start() + } + } + } + + Log.d("Downloading update", "progress $progress%") + } + } + + }, 0, 250) + } + + private fun writeFileToStorage(responseBody: ResponseBody?) { + if (responseBody == null) return + + val updateItem = requireNotNull(viewModel.currentItem.value) + + try { + val destination = requireContext() + .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + + "${File.separator}${updateItem.fileName}.${updateItem.extension}" + + val file = File(destination) + if (file.exists()) file.delete() + + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + val fileReader = ByteArray(4096) + val fileSize: Long = responseBody.contentLength() + + requireActivity().runOnUiThread { + binding.loadingProgress.max = fileSize.toInt() + binding.loadingProgress.progress = 0 + } + + var fileSizeDownloaded: Long = 0 + inputStream = responseBody.byteStream() + outputStream = FileOutputStream(file) + while (true) { + val read: Int = inputStream.read(fileReader) + if (read == -1) { + break + } + outputStream.write(fileReader, 0, read) + fileSizeDownloaded += read.toLong() + } + outputStream.flush() + + requireActivity().runOnUiThread { + installUpdate(file) + } + } catch (e: IOException) { + + } finally { + inputStream?.close() + outputStream?.close() + } + } catch (e: IOException) { + + } + } + + private fun installUpdate(file: File) { + val installIntent = AndroidUtils.getInstallPackageIntent( + requireContext(), + ARG_PROVIDER_PATH, + file + ) + + requireContext().startActivity(installIntent) + } + + private fun showChangelogAlert() { + val changelog = viewModel.currentItem.value?.changelog + + val messageText = + if (changelog.isNullOrBlank()) getString(R.string.fragment_updates_changelog_none) + else changelog + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.fragment_updates_changelog) + .setMessage(messageText) + .setPositiveButton(R.string.ok, null) + .show() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt new file mode 100644 index 00000000..416fa2b3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt @@ -0,0 +1,10 @@ +package com.meloda.fast.screens.updates + +import android.content.Context +import com.meloda.fast.base.ResourceProvider + +class UpdatesResourceProvider(context: Context) : ResourceProvider(context) { + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt new file mode 100644 index 00000000..950c48ba --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt @@ -0,0 +1,50 @@ +package com.meloda.fast.screens.updates + +import androidx.lifecycle.MutableLiveData +import com.meloda.fast.data.ota.OtaApi +import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.model.UpdateItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import javax.inject.Inject + + +@HiltViewModel +class UpdatesViewModel @Inject constructor( + private val updateManager: UpdateManager, + private val otaApi: OtaApi +) : BaseViewModel() { + + val updateState = MutableLiveData(UpdateState.Loading) + val currentError = MutableLiveData(null) + val currentItem = MutableLiveData(null) + + private var currentJob: Job? = null + + fun checkUpdates() { + if (currentJob != null) { + currentJob?.cancel() + currentJob = null + } + updateState.setIfNotEquals(UpdateState.Loading) + + currentJob = updateManager.checkUpdates { item, error -> + when { + item != null -> { + currentError.setIfNotEquals(null) + updateState.setIfNotEquals(UpdateState.NewUpdate) + } + error != null -> { + currentError.setIfNotEquals(error.message ?: "") + updateState.setIfNotEquals(UpdateState.Error) + } + else -> { + currentError.setIfNotEquals(null) + updateState.setIfNotEquals(UpdateState.NoUpdates) + } + } + }.apply { invokeOnCompletion { currentJob = null } } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt similarity index 62% rename from app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt rename to app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt index a7a5657c..d38c230a 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/MessagesUpdateService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt @@ -4,27 +4,33 @@ import android.app.Service import android.content.Intent import android.os.IBinder import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.edit import com.google.gson.JsonArray import com.google.gson.JsonObject -import com.meloda.fast.api.LongPollUpdatesParser import com.meloda.fast.api.VKConstants -import com.meloda.fast.api.VKException +import com.meloda.fast.api.base.ApiError +import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.model.base.BaseVkLongPoll -import com.meloda.fast.api.network.Answer +import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest -import com.meloda.fast.api.network.longpoll.LongPollRepo -import com.meloda.fast.api.network.messages.MessagesDataSource import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.util.NotificationsUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import javax.inject.Inject import kotlin.coroutines.CoroutineContext @AndroidEntryPoint -class MessagesUpdateService : Service(), CoroutineScope { +class LongPollService : Service(), CoroutineScope { companion object { const val TAG = "LongPollTask" + + const val KeyLongPollWasDestroyed = "long_poll_was_destroyed" } private val job = SupervisorJob() @@ -38,10 +44,10 @@ class MessagesUpdateService : Service(), CoroutineScope { get() = Dispatchers.Default + job + exceptionHandler @Inject - lateinit var dataSource: MessagesDataSource + lateinit var repository: MessagesRepository @Inject - lateinit var longPollRepo: LongPollRepo + lateinit var longPollApi: LongPollApi @Inject lateinit var updatesParser: LongPollUpdatesParser @@ -51,19 +57,41 @@ class MessagesUpdateService : Service(), CoroutineScope { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d("LongPollService", "onStartCommand: flags: $flags; startId: $startId") launch { startPolling().join() } + + val notificationBuilder = + NotificationsUtils.createNotification( + context = this, + title = "Сервис анального зондирования", + contentText = "ищем нюдесы в ваших сообщениях", + notRemovable = true, + channelId = "long_polling", + priority = NotificationsUtils.NotificationPriority.Min, + category = NotificationCompat.CATEGORY_SERVICE + ) + + startForeground( + startId, + notificationBuilder.build() + ) return START_STICKY } private fun startPolling(): Job { - if (job.isCompleted || job.isCancelled) throw Exception("Job is over") + if (job.isCompleted || job.isCancelled) { + Log.d("LongPollService", "job is completed or cancelled. Fuck off") + throw Exception("Job is over") + } + + Log.d("LongPollService", "job started") return launch { var serverInfo = getServerInfo() - ?: throw VKException(error = "bad VK response (server info)") + ?: throw ApiError(errorMessage = "bad VK response (server info)") var lastUpdatesResponse: JsonObject? = getUpdatesResponse(serverInfo) - ?: throw VKException(error = "initiation error: bad VK response (last updates)") + ?: throw ApiError(errorMessage = "initiation error: bad VK response (last updates)") var failCount = 0 @@ -71,7 +99,7 @@ class MessagesUpdateService : Service(), CoroutineScope { if (lastUpdatesResponse == null) { failCount++ serverInfo = getServerInfo() - ?: throw VKException(error = "failed retrieving server info after error: bad VK response (server info #2)") + ?: throw ApiError(errorMessage = "failed retrieving server info after error: bad VK response (server info #2)") lastUpdatesResponse = getUpdatesResponse(serverInfo) continue } @@ -88,8 +116,8 @@ class MessagesUpdateService : Service(), CoroutineScope { } 2, 3 -> { serverInfo = getServerInfo() - ?: throw VKException( - error = "failed retrieving server info after error: bad VK response (server info #3)" + ?: throw ApiError( + errorMessage = "failed retrieving server info after error: bad VK response (server info #3)" ) lastUpdatesResponse = getUpdatesResponse(serverInfo) } @@ -122,7 +150,7 @@ class MessagesUpdateService : Service(), CoroutineScope { } private suspend fun getServerInfo(): BaseVkLongPoll? { - val response = dataSource.getLongPollServer( + val response = repository.getLongPollServer( MessagesGetLongPollServerRequest( needPts = true, version = VKConstants.LP_VERSION @@ -131,8 +159,8 @@ class MessagesUpdateService : Service(), CoroutineScope { println("$TAG: serverInfoResponse: $response") - if (response is Answer.Error) return null - if (response is Answer.Success) { + if (response is ApiAnswer.Error) return null + if (response is ApiAnswer.Success) { return response.data.response } @@ -140,21 +168,22 @@ class MessagesUpdateService : Service(), CoroutineScope { } private suspend fun getUpdatesResponse(server: BaseVkLongPoll): JsonObject? { - val response = dataSource.getLongPollUpdates( + val response = repository.getLongPollUpdates( serverUrl = "https://${server.server}", params = LongPollGetUpdatesRequest( key = server.key, ts = server.ts, wait = 25, - mode = 2 or 8 or 32 or 64 or 128 + mode = 2 or 8 or 32 or 64 or 128, + version = VKConstants.LP_VERSION ) ) println("$TAG: lastUpdateResponse: $response") - if (response is Answer.Error) return null + if (response is ApiAnswer.Error) return null - if (response is Answer.Success) { + if (response is ApiAnswer.Success) { return response.data } @@ -162,21 +191,24 @@ class MessagesUpdateService : Service(), CoroutineScope { } private fun handleUpdateEvent(eventJson: JsonArray) { -// println("$TAG: handleUpdateEvent: $eventJson") - updatesParser.parseNextUpdate(eventJson) } -// fun registerListener(eventType: Int, listener: VkEventCallback) = -// updatesParser.registerListener(eventType, listener) - override fun onDestroy() { + Log.d("LongPollService", "onDestroy") try { + AppGlobal.preferences.edit { + putBoolean(KeyLongPollWasDestroyed, true) + } job.cancel() } catch (e: Exception) { + e.printStackTrace() } - updatesParser.clearListeners() super.onDestroy() } + override fun onLowMemory() { + Log.d("LongPollService", "onLowMemory") + super.onLowMemory() + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt index f654dc84..a0ada619 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt @@ -5,9 +5,11 @@ import android.content.Intent import android.os.IBinder import android.util.Log import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.network.account.AccountDataSource import com.meloda.fast.api.network.account.AccountSetOfflineRequest import com.meloda.fast.api.network.account.AccountSetOnlineRequest +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.data.account.AccountsRepository +import com.meloda.fast.screens.settings.SettingsPrefsFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import java.util.* @@ -18,14 +20,10 @@ import kotlin.coroutines.CoroutineContext @AndroidEntryPoint class OnlineService : Service(), CoroutineScope { - private companion object { - private const val TAG = "OnlineService" - } - private val job = SupervisorJob() private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.d(MessagesUpdateService.TAG, "error: $throwable") + Log.d(LongPollService.TAG, "error: $throwable") throwable.printStackTrace() } @@ -33,43 +31,72 @@ class OnlineService : Service(), CoroutineScope { get() = Dispatchers.Default + job + exceptionHandler @Inject - lateinit var dataSource: AccountDataSource + lateinit var repository: AccountsRepository private var timer: Timer? = null override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - timer = Timer().apply { - schedule(delay = 0, period = 60_000) { - launch { - setOffline() - delay(5000) - setOnline() - } - } - } + Log.d("OnlineService", "onStartCommand: flags: $flags; startId: $startId") + createTimer() return START_STICKY_COMPATIBILITY } + private fun createTimer() { + timer = Timer().apply { + schedule(delay = 0, period = 60 * 1000L) { + launch { performJob() } + } + } + } + + private suspend fun performJob() { + if (!AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefSendOnlineStatus, true)) { + return + } + + setOffline() + delay(5000) + setOnline() + } + private suspend fun setOnline() { - println("$TAG: setOnline()") - val response = dataSource.setOnline( + Log.d("OnlineService", "setOnline()") + + val fastToken = UserConfig.fastToken + + val token = + if (fastToken == null) { + Log.d("OnlineService", "setOnline: Fast token is null. Using VK token") + UserConfig.accessToken + } else { + fastToken + } + + val response = repository.setOnline( AccountSetOnlineRequest( voip = false, - accessToken = UserConfig.fastToken + accessToken = token ) ) + Log.d("OnlineService", "setOnline: response: $response") } private suspend fun setOffline() { - println("$TAG: setOffline()") - val response = dataSource.setOffline( + Log.d("OnlineService", "setOffline()") + val response = repository.setOffline( AccountSetOfflineRequest( accessToken = UserConfig.accessToken ) ) + Log.d("OnlineService", "setOffline: response: $response") + } + + override fun onDestroy() { + super.onDestroy() + Log.d("OnlineService", "onDestroy") } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt index f9a04c30..ece66913 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt @@ -2,11 +2,20 @@ package com.meloda.fast.util import android.content.ClipData import android.content.Context +import android.content.Intent import android.content.res.Configuration +import android.content.res.Resources import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.provider.Settings import android.util.TypedValue import androidx.annotation.AttrRes +import androidx.core.content.FileProvider +import com.meloda.fast.BuildConfig +import com.meloda.fast.common.AppConstants import com.meloda.fast.common.AppGlobal +import java.io.File object AndroidUtils { @@ -33,11 +42,11 @@ object AndroidUtils { } fun getDisplayWidth(): Int { - return AppGlobal.resources.displayMetrics.widthPixels + return Resources.getSystem().displayMetrics.widthPixels } fun getDisplayHeight(): Int { - return AppGlobal.resources.displayMetrics.heightPixels + return Resources.getSystem().displayMetrics.heightPixels } fun copyText(label: String? = "", text: String) { @@ -58,11 +67,61 @@ object AndroidUtils { return color } - fun bytesToHumanReadableSize(bytes: Double) = when { + fun bytesToMegabytes(bytes: Double): Double { + return bytes / 1024 / 1024 + } + + fun bytesToHumanReadableSize(bytes: Double): String = when { bytes >= 1 shl 30 -> "%.1f GB".format(bytes / (1 shl 30)) bytes >= 1 shl 20 -> "%.1f MB".format(bytes / (1 shl 20)) bytes >= 1 shl 10 -> "%.0f KB".format(bytes / (1 shl 10)) else -> "$bytes B" } + @Suppress("DEPRECATION") + fun isCanInstallUnknownApps(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AppGlobal.packageManager.canRequestPackageInstalls() + } else { + Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.INSTALL_NON_MARKET_APPS + ) == 1 + } + } + + fun openInstallUnknownAppsScreen(context: Context) { + context.startActivity(Intent().apply { + action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Settings.ACTION_SECURITY_SETTINGS + } else { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES + } + }) + } + + fun getInstallPackageIntent( + context: Context, + providerPath: String, + fileToRead: File + ): Intent { + val intent = Intent(Intent.ACTION_VIEW) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + intent.data = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + providerPath, + fileToRead + ) + } else { + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.setDataAndType(Uri.fromFile(fileToRead), AppConstants.INSTALL_APP_MIME_TYPE) + } + + return intent + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt deleted file mode 100644 index 2c76a379..00000000 --- a/app/src/main/kotlin/com/meloda/fast/util/KeyboardUtils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.meloda.fast.util - -import android.view.View -import com.meloda.fast.common.AppGlobal - -object KeyboardUtils { - - fun hideKeyboardFrom(focusedView: View?) { - AppGlobal.inputMethodManager.hideSoftInputFromWindow(focusedView?.windowToken, 0) - } - - fun showKeyboard(viewToFocus: View) { - AppGlobal.inputMethodManager.showSoftInput(viewToFocus, 0) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt new file mode 100644 index 00000000..e3833efc --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt @@ -0,0 +1,64 @@ +package com.meloda.fast.util + +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.meloda.fast.R + +object NotificationsUtils { + + fun createNotification( + context: Context, + title: String? = null, + contentText: String? = null, + bigText: String? = null, + customNotificationId: Int? = null, + showWhen: Boolean = false, + timeStampWhen: Long? = null, + notify: Boolean = false, + notRemovable: Boolean = false, + channelId: String = "simple_notifications", + priority: NotificationPriority = NotificationPriority.Default, + contentIntent: PendingIntent? = null, + category: String? = null + ): NotificationCompat.Builder { + var builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_fast_logo) + .setContentTitle(title) + .setPriority(priority.value) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setShowWhen(showWhen) + .setOngoing(notRemovable) + + if (category != null) { + builder = builder.setCategory(category) + } + + if (contentText != null) { + builder = builder.setContentText(contentText) + } + + if (bigText != null) { + builder = builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + } + + if (timeStampWhen != null) { + builder = builder.setWhen(timeStampWhen) + } + + if (notify) { + with(NotificationManagerCompat.from(context)) { + notify(customNotificationId ?: -1, builder.build()) + } + } + + return builder + } + + enum class NotificationPriority(val value: Int) { + Default(0), Low(-1), Min(-2), High(1), Max(2) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt index f934a9e4..494655bd 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/TimeUtils.kt @@ -4,10 +4,11 @@ import android.content.Context import com.meloda.fast.R import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.TimeUnit object TimeUtils { - const val ONE_DAY_IN_SECONDS = 86400 + val OneDayInSeconds get() = TimeUnit.DAYS.toSeconds(1) fun removeTime(date: Date): Long { return Calendar.getInstance().apply { @@ -23,20 +24,18 @@ object TimeUtils { val now = Calendar.getInstance() val then = Calendar.getInstance().also { it.timeInMillis = date } - val pattern = - if (now[Calendar.YEAR] != then[Calendar.YEAR]) { - "dd MMM yyyy" - } else if (now[Calendar.MONTH] != then[Calendar.MONTH]) { - "dd MMMM" - } else if (now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH]) { + val pattern = when { + now[Calendar.YEAR] != then[Calendar.YEAR] -> "dd MMM yyyy" + now[Calendar.MONTH] != then[Calendar.MONTH] -> "dd MMMM" + now[Calendar.DAY_OF_MONTH] != then[Calendar.DAY_OF_MONTH] -> { if (now[Calendar.DAY_OF_MONTH] - then[Calendar.DAY_OF_MONTH] == 1) { return context.getString(R.string.yesterday) } else { "dd MMMM" } - } else { - return context.getString(R.string.today) } + else -> return context.getString(R.string.today) + } return SimpleDateFormat(pattern, Locale.getDefault()).format(date) } diff --git a/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt new file mode 100644 index 00000000..872d7dbb --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt @@ -0,0 +1,40 @@ +package com.meloda.fast.util + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.R + +object ViewUtils { + + fun Context.showErrorDialog( + message: String, + showErrorPrefix: Boolean = true, + isCancelable: Boolean? = null, + positiveText: Int? = null, + positiveAction: (() -> Unit)? = null, + negativeText: Int? = null, + negativeAction: (() -> Unit)? = null, + ): AlertDialog { + val builder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage( + if (showErrorPrefix) getString(R.string.error, message) + else message + ) + .setPositiveButton(positiveText ?: R.string.ok) { _, _ -> + positiveAction?.invoke() + } + + negativeAction?.run { + builder.setNegativeButton( + negativeText ?: R.string.cancel + ) { _, _ -> this.invoke() } + } + + isCancelable?.run { builder.setCancelable(this) } + + return builder.show() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt b/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt new file mode 100644 index 00000000..9db7d758 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/view/BoundedLinearLayout.kt @@ -0,0 +1,62 @@ +package com.meloda.fast.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.content.withStyledAttributes +import com.meloda.fast.R + +@SuppressLint("CustomViewStyleable") +class BoundedLinearLayout : LinearLayoutCompat { + private var mBoundedWidth: Int = 0 + private var mBoundedHeight: Int = 0 + + constructor(context: Context) : super(context) { + mBoundedWidth = 0 + mBoundedHeight = 0 + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + context.withStyledAttributes(attrs, R.styleable.BoundedView) { + mBoundedWidth = getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) + mBoundedHeight = getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) + } + } + + var maxWidth: Int + get() = mBoundedWidth + set(width) { + if (mBoundedWidth != width) { + mBoundedWidth = width + requestLayout() + } + } + + var maxHeight: Int + get() = mBoundedHeight + set(height) { + if (mBoundedHeight != height) { + mBoundedHeight = height + requestLayout() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var newWidthMeasureSpec = widthMeasureSpec + var newHeightMeasureSpec = heightMeasureSpec + + val measuredWidth = MeasureSpec.getSize(newWidthMeasureSpec) + if (mBoundedWidth in 1 until measuredWidth) { + val measureMode = MeasureSpec.getMode(newWidthMeasureSpec) + newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) + } + + val measuredHeight = MeasureSpec.getSize(newHeightMeasureSpec) + if (mBoundedHeight in 1 until measuredHeight) { + val measureMode = MeasureSpec.getMode(newHeightMeasureSpec) + newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) + } + super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt b/app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt similarity index 56% rename from app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt rename to app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt index 3aa3579c..6e574c3f 100644 --- a/app/src/main/kotlin/com/meloda/fast/widget/CircleImageView.kt +++ b/app/src/main/kotlin/com/meloda/fast/view/CircleImageView.kt @@ -1,12 +1,12 @@ -package com.meloda.fast.widget +package com.meloda.fast.view import android.content.Context import android.graphics.Canvas import android.graphics.Path import android.graphics.RectF import android.util.AttributeSet -import android.view.ViewTreeObserver import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.doOnPreDraw class CircleImageView : AppCompatImageView { @@ -30,38 +30,32 @@ class CircleImageView : AppCompatImageView { } - override fun onDraw(canvas: Canvas?) { - rect ?: return - canvas ?: return - - if (rect!!.right == 0f || rect!!.bottom == 0f) { - createRect(width, height) + override fun onDraw(canvas: Canvas) { + rect?.let { rect -> + if (rect.right == 0F || rect.bottom == 0F) { + createRect(width, height) + } } - canvas.clipPath(path!!) + path?.run { canvas.clipPath(this) } super.onDraw(canvas) } private fun init() { scaleType = SCALE_TYPE - viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - createRect(width, height) - viewTreeObserver.removeOnPreDrawListener(this) - return false - } - }) + doOnPreDraw { createRect(width, height) } } private fun createRect(width: Int, height: Int) { - rect = RectF(0f, 0f, width.toFloat(), height.toFloat()) path = Path() - path!!.addRoundRect( - rect!!, - (width / 2).toFloat(), - (height / 2).toFloat(), - Path.Direction.CW - ) + rect = RectF(0f, 0f, width.toFloat(), height.toFloat()).apply { + path?.addRoundRect( + this, + width.toFloat() / 2F, + height.toFloat() / 2F, + Path.Direction.CW + ) + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt b/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt new file mode 100644 index 00000000..874a7a24 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/view/SpaceItemDecoration.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.view + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class SpaceItemDecoration( + private val topMargin: Int? = null, + private val endMargin: Int? = null, + private val bottomMargin: Int? = null, + private val startMargin: Int? = null +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + topMargin?.run { outRect.top = this } + endMargin?.run { outRect.right = this } + bottomMargin?.run { outRect.bottom = this } + startMargin?.run { outRect.left = this } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt deleted file mode 100644 index 8781b954..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/BoundedFrameLayout.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.meloda.fast.widget - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import com.meloda.fast.R - -class BoundedFrameLayout : FrameLayout { - private var mBoundedWidth: Int - private var mBoundedHeight: Int - - constructor(context: Context) : super(context) { - mBoundedWidth = 0 - mBoundedHeight = 0 - } - - @SuppressLint("CustomViewStyleable") - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - val a = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) - mBoundedWidth = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) - mBoundedHeight = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) - a.recycle() - } - - var maxWidth: Int - get() = mBoundedWidth - set(width) { - if (mBoundedWidth != width) { - mBoundedWidth = width - requestLayout() - } - } - - var maxHeight: Int - get() = mBoundedHeight - set(height) { - if (mBoundedHeight != height) { - mBoundedHeight = height - requestLayout() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Adjust width as necessary - var widthMeasureSpec = widthMeasureSpec - var heightMeasureSpec = heightMeasureSpec - val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) - - if (mBoundedWidth in 1 until measuredWidth) { - val measureMode = MeasureSpec.getMode(widthMeasureSpec) - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) - } - - // Adjust height as necessary - val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) - if (mBoundedHeight in 1 until measuredHeight) { - val measureMode = MeasureSpec.getMode(heightMeasureSpec) - heightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt deleted file mode 100644 index 754cc4b5..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/BoundedLinearLayout.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.meloda.fast.widget - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.widget.LinearLayout -import com.meloda.fast.R - -class BoundedLinearLayout : LinearLayout { - private var mBoundedWidth: Int - private var mBoundedHeight: Int - - constructor(context: Context) : super(context) { - mBoundedWidth = 0 - mBoundedHeight = 0 - } - - @SuppressLint("CustomViewStyleable") - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - val a = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) - mBoundedWidth = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) - mBoundedHeight = a.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) - a.recycle() - } - - var maxWidth: Int - get() = mBoundedWidth - set(width) { - if (mBoundedWidth != width) { - mBoundedWidth = width - requestLayout() - } - } - - var maxHeight: Int - get() = mBoundedHeight - set(height) { - if (mBoundedHeight != height) { - mBoundedHeight = height - requestLayout() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Adjust width as necessary - var widthMeasureSpec = widthMeasureSpec - var heightMeasureSpec = heightMeasureSpec - val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) - if (mBoundedWidth in 1 until measuredWidth) { - val measureMode = MeasureSpec.getMode(widthMeasureSpec) - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedWidth, measureMode) - } - // Adjust height as necessary - val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) - if (mBoundedHeight in 1 until measuredHeight) { - val measureMode = MeasureSpec.getMode(heightMeasureSpec) - heightMeasureSpec = MeasureSpec.makeMeasureSpec(mBoundedHeight, measureMode) - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt b/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt deleted file mode 100644 index 94caf9a0..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/NoItemsView.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.Gravity -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.isVisible -import com.meloda.fast.R -import com.meloda.fast.extensions.dpToPx - -@Suppress("UNCHECKED_CAST") -class NoItemsView @JvmOverloads constructor( - context: Context, private var attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr) { - - private lateinit var noItemsPicture: ImageView - private lateinit var noItemsTextView: TextView - - private val textViewParams - get() = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT - ) - - private val imageViewParams - get() = LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ) - - init { - create() - } - - private fun create() { - val a = context.obtainStyledAttributes(attrs, R.styleable.NoItemsView) - - minimumWidth = 256.dpToPx() - minimumHeight = minimumWidth - - orientation = VERTICAL - gravity = Gravity.CENTER - - noItemsPicture = ImageView(context) - - val imageViewSize = 64.dpToPx() - - val params = imageViewParams.apply { - height = imageViewSize - width = imageViewSize - } - - noItemsPicture.layoutParams = params - - val noItemsDrawable = a.getDrawable(R.styleable.NoItemsView_noItemsImage) - noItemsDrawable?.let { - val noItemsDrawableTintColor = a.getColor(R.styleable.NoItemsView_noItemsImageTint, -1) - if (noItemsDrawableTintColor != -1) { - it.setTint(noItemsDrawableTintColor) - } - - setNoItemsImage(it) - } - - addView(noItemsPicture) - - noItemsTextView = TextView(context) - - val textParams = textViewParams - textParams.width = 256.dpToPx() - - if (noItemsDrawable != null) { - textParams.topMargin = 8.dpToPx() - } - - noItemsTextView.layoutParams = textParams - - noItemsTextView.gravity = Gravity.CENTER - noItemsTextView.setTextAppearance(R.style.TextAppearance_MaterialComponents_Body1) - - val noItemsTextColor = a.getColor(R.styleable.NoItemsView_noItemsTextColor, -1) - if (noItemsTextColor != -1) { - setNoItemsTextColor(noItemsTextColor) - } - - val noItemsText = a.getString(R.styleable.NoItemsView_noItemsText) - noItemsText?.let { - setNoItemsText(it) - } - - addView(noItemsTextView) - - val isVisibleByDefault = a.getBoolean(R.styleable.NoItemsView_isVisibleByDefault, true) - isVisible = isVisibleByDefault - - a.recycle() - } - - fun setNoItemsImage(@DrawableRes resId: Int) { - setNoItemsImage(AppCompatResources.getDrawable(context, resId)) - } - - fun setNoItemsImage(drawable: Drawable?) { - noItemsPicture.setImageDrawable(drawable) - } - - fun setNoItemsImageTint(@ColorInt color: Int) { - noItemsPicture.drawable?.setTint(color) - } - - fun setNoItemsText(@StringRes resId: Int) { - noItemsTextView.setText(resId) - } - - fun setNoItemsText(text: String) { - noItemsTextView.text = text - } - - fun setNoItemsTextColor(@ColorInt color: Int) { - noItemsTextView.setTextColor(color) - } - - fun show() { - isVisible = true - } - - fun hide() { - isVisible = false - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java b/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java deleted file mode 100644 index b97454e9..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/RoundedCornerLayout.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.meloda.fast.widget; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.widget.FrameLayout; - -public class RoundedCornerLayout extends FrameLayout { - private final static float CORNER_RADIUS = 40.0f; - - private Bitmap maskBitmap; - private Paint paint, maskPaint; - private float cornerRadius; - - public RoundedCornerLayout(Context context) { - super(context); - init(context, null, 0); - } - - public RoundedCornerLayout(Context context, AttributeSet attrs) { - super(context, attrs); - init(context, attrs, 0); - } - - public RoundedCornerLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(context, attrs, defStyle); - } - - private void init(Context context, AttributeSet attrs, int defStyle) { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, CORNER_RADIUS, metrics); - - paint = new Paint(Paint.ANTI_ALIAS_FLAG); - - maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); - maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - - setWillNotDraw(false); - } - - @Override - public void draw(Canvas canvas) { - Bitmap offscreenBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - Canvas offscreenCanvas = new Canvas(offscreenBitmap); - - super.draw(offscreenCanvas); - - if (maskBitmap == null) { - maskBitmap = createMask(getWidth(), getHeight()); - } - - offscreenCanvas.drawBitmap(maskBitmap, 0f, 0f, maskPaint); - canvas.drawBitmap(offscreenBitmap, 0f, 0f, paint); - } - - private Bitmap createMask(int width, int height) { - Bitmap mask = Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8); - Canvas canvas = new Canvas(mask); - - Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); - paint.setColor(Color.WHITE); - - canvas.drawRect(0, 0, width, height, paint); - - paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - canvas.drawRoundRect(new RectF(0, 0, width, height), cornerRadius, cornerRadius, paint); - - return mask; - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt b/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt deleted file mode 100644 index fe2ee3ba..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/RoundedFrameLayout.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Region -import android.util.AttributeSet -import android.widget.FrameLayout -import com.meloda.fast.R - -class RoundedFrameLayout : FrameLayout { - /** - * The corners than can be changed - */ - private var topLeftCornerRadius = 0f - private var topRightCornerRadius = 0f - private var bottomLeftCornerRadius = 0f - private var bottomRightCornerRadius = 0f - - constructor(context: Context) : super(context) { - init(context, null, 0) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context, attrs, 0) - } - - constructor( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int - ) : super(context, attrs, defStyleAttr) { - init(context, attrs, defStyleAttr) - } - - private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) { - val typedArray = context.obtainStyledAttributes( - attrs, - R.styleable.RoundedFrameLayout, 0, 0 - ) - - topLeftCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_topLeftCornerRadius, 0f) - topRightCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_topRightCornerRadius, 0f) - bottomLeftCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_bottomLeftCornerRadius, 0f) - bottomRightCornerRadius = - typedArray.getDimension(R.styleable.RoundedFrameLayout_bottomRightCornerRadius, 0f) - - typedArray.recycle() - setLayerType(LAYER_TYPE_HARDWARE, null) - } - - override fun dispatchDraw(canvas: Canvas) { - val count: Int = canvas.save() - val path = Path() - val cornerDimensions = floatArrayOf( - topLeftCornerRadius, topLeftCornerRadius, - topRightCornerRadius, topRightCornerRadius, - bottomRightCornerRadius, bottomRightCornerRadius, - bottomLeftCornerRadius, bottomLeftCornerRadius - ) - path.addRoundRect( - RectF(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat()), - cornerDimensions, - Path.Direction.CW - ) - canvas.clipPath(path, Region.Op.INTERSECT) - canvas.clipPath(path) - super.dispatchDraw(canvas) - canvas.restoreToCount(count) - } - - fun setTopLeftCornerRadius(topLeftCornerRadius: Float) { - this.topLeftCornerRadius = topLeftCornerRadius - invalidate() - } - - fun setTopRightCornerRadius(topRightCornerRadius: Float) { - this.topRightCornerRadius = topRightCornerRadius - invalidate() - } - - fun setBottomLeftCornerRadius(bottomLeftCornerRadius: Float) { - this.bottomLeftCornerRadius = bottomLeftCornerRadius - invalidate() - } - - fun setBottomRightCornerRadius(bottomRightCornerRadius: Float) { - this.bottomRightCornerRadius = bottomRightCornerRadius - invalidate() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt b/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt deleted file mode 100644 index 3431a7e1..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/ScrollingTextView.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.graphics.Rect -import android.util.AttributeSet -import com.google.android.material.textview.MaterialTextView - -class ScrollingTextView : MaterialTextView { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { - if (focused) super.onFocusChanged(focused, direction, previouslyFocusedRect) - } - - override fun onWindowFocusChanged(focused: Boolean) { - if (focused) super.onWindowFocusChanged(true) - } - - override fun isFocused(): Boolean { - return true - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt b/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt deleted file mode 100644 index ff134f32..00000000 --- a/app/src/main/kotlin/com/meloda/fast/widget/WrapTextView.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.meloda.fast.widget - -import android.content.Context -import android.text.Layout -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatTextView -import com.meloda.fast.R -import kotlin.math.ceil -import kotlin.math.max - -class WrapTextView(context: Context, attrs: AttributeSet? = null) : - AppCompatTextView(context, attrs) { - - private var fixWrapText = false - - constructor(context: Context) : this(context, null) - - init { - init(context, attrs) - } - - private fun init(context: Context, attrs: AttributeSet?) { - val a = context.theme.obtainStyledAttributes(attrs, R.styleable.WrapTextView, 0, 0) - - try { - fixWrapText = a.getBoolean(R.styleable.WrapTextView_fixWrap, false) - } finally { - a.recycle() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - if (fixWrapText && MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) { - val width = getMaxWidth(layout) - if (width in 1 until measuredWidth) { - super.onMeasure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), - heightMeasureSpec - ) - } - } - } - - private fun getMaxWidth(layout: Layout): Int { - if (layout.lineCount < 2) return 0 - - var maxWidth = 0.0f - for (i in 0 until layout.lineCount) { - maxWidth = max(maxWidth, layout.getLineWidth(i)) - } - - return ceil(maxWidth).toInt() - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml b/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml new file mode 100644 index 00000000..776ab808 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_notification_new_message.png b/app/src/main/res/drawable-hdpi/ic_notification_new_message.png new file mode 100644 index 0000000000000000000000000000000000000000..61433bfc10ee4981a04fcd8b81da3a3901813dae GIT binary patch literal 373 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$UKzz-Z&?;uum9_jc;V+(QlmE}D%& zmmD=3h3+_BVpMfdU(%ezzJ$fggZ*^x8~!ifl;-ZV-GH6@dMtcR};8yM0V2z(^ z(+XDDZ@AF9Z{-w8dzn}KIx6Q}+h@Ymlz+(E@=CwwFXPM^pZ@*Lf0^rgb5Z;m5x0B? zwPk-?-(R@+_-@A3O{QQ4UPo@oPw3QqfB(Pq-nAOmXB5Dy_NZDk0j=16W@_ik#{Na$ z;YN2#{AM;XoZ10)3zT!B^Dkq3o?`9J)KeDBZtzpOH5#p3k94FShLAKe+f s;_=O339(l{{$&1`!o7Xdr=~j$c5&rJ!gnGT1A~*n)78&qol`;+0FhFtqW}N^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_notification_new_message.png b/app/src/main/res/drawable-mdpi/ic_notification_new_message.png new file mode 100644 index 0000000000000000000000000000000000000000..aa174d476c595cff716dee6d012bb3d45265fc4d GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjyF6VSLn`9lPC3ZiY{26xxm0-r zqnzqu11I+vY#SWIU#O)rU+0rK| - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v21/ic_message_outline.xml b/app/src/main/res/drawable-v21/ic_message_outline.xml deleted file mode 100644 index dbb492ab..00000000 --- a/app/src/main/res/drawable-v21/ic_message_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_notification_new_message.png b/app/src/main/res/drawable-xhdpi/ic_notification_new_message.png new file mode 100644 index 0000000000000000000000000000000000000000..760af05f8688c76125ff7dd464b016a5ed0cfae9 GIT binary patch literal 484 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(Fs}A=aSW-5dwcz%FO#Fd(L%2c zEM5zkU$ne+uu1^3tv0fFHSld|E{wOCd|Lh)-;Q3sXLTp*E`Co-7Zy(3IOA;E|Jl3O zzph-H{{OUl`MRCw&t_GIp1=5iURgxp{=itnZsqUNA>7h_9tnqSolW;7TQ|yn=efFJ zYQ{p?1<{2}Y8#BF(D>)^s6;xZ=g{PuKMA zg>0&}+5MBfvm+hoN@0U{l0h@xvh-B9iKJOGKAs2E?zf=e{DkK<2j0pn*xqNpx8t$G z`OgiT?|FVdQ&MBb@0JIa{-v9sr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png b/app/src/main/res/drawable-xxhdpi/ic_notification_new_message.png new file mode 100644 index 0000000000000000000000000000000000000000..6499dce6dcb4929998ce511dbdeb6a2b1dbb0e41 GIT binary patch literal 711 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2VCwU9aSW-5dwc6+;1olFwvXD4 zLOKWgJlLl_nC8HKp|OlXHlnFyf$W;f_QQ#CCO^Mf9J*8Yd-h5DqRM?a&jQ%r6=*D- z66B>B{I2fm_0srw{gT-4rAK$3ThtyMzyHst%(i1c@3}bIsWu+7^nc`kKTPtT<&v5A z{=I!>dEfTD)N|g~g2|?WkM|^YZJ5oFyHN7;eb> zaO3*nM_*qQ9;*atUbJyz*SxcbeI%rTDy<5R|6To($L2~yVLi~fd=lT+FEjoSz^5*{Z8>hflaLRtT7GGcS za_{xE$MxKi%_k0rNqyG5@YwZ#C_d9T=eAmzx9=xphUEjqn_b^t(2Nmy74z)0%h=Y^>bP0 Hl+XkK4g_G? literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_close_in_circle.xml b/app/src/main/res/drawable/ic_close_in_circle.xml new file mode 100644 index 00000000..96c95cab --- /dev/null +++ b/app/src/main/res/drawable/ic_close_in_circle.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground_splash.xml b/app/src/main/res/drawable/ic_launcher_foreground_splash.xml new file mode 100644 index 00000000..0bf84b05 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_splash.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_message_in_background_middle.xml b/app/src/main/res/drawable/ic_message_in_background_middle.xml index f44081fd..2000155e 100644 --- a/app/src/main/res/drawable/ic_message_in_background_middle.xml +++ b/app/src/main/res/drawable/ic_message_in_background_middle.xml @@ -4,10 +4,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_out_background_middle.xml b/app/src/main/res/drawable/ic_message_out_background_middle.xml index 16ef3543..d1c61162 100644 --- a/app/src/main/res/drawable/ic_message_out_background_middle.xml +++ b/app/src/main/res/drawable/ic_message_out_background_middle.xml @@ -8,10 +8,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_panel_background.xml b/app/src/main/res/drawable/ic_message_panel_background.xml index fdcd1463..ff07f7a5 100644 --- a/app/src/main/res/drawable/ic_message_panel_background.xml +++ b/app/src/main/res/drawable/ic_message_panel_background.xml @@ -2,11 +2,7 @@ - + diff --git a/app/src/main/res/drawable/ic_online_pc.xml b/app/src/main/res/drawable/ic_online_pc.xml index 74261079..1f7e959a 100644 --- a/app/src/main/res/drawable/ic_online_pc.xml +++ b/app/src/main/res/drawable/ic_online_pc.xml @@ -5,7 +5,7 @@ android:height="14dp"> - + + + + + + diff --git a/app/src/main/res/drawable/ic_round_access_time_24.xml b/app/src/main/res/drawable/ic_round_access_time_24.xml new file mode 100644 index 00000000..b0aaa17c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_access_time_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml new file mode 100644 index 00000000..855e6815 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_add_circle_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_bookmark_border_24.xml b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml new file mode 100644 index 00000000..3429f276 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_bookmark_border_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_close_24.xml b/app/src/main/res/drawable/ic_round_close_24.xml new file mode 100644 index 00000000..0aa41eb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_close_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_done_all_24.xml b/app/src/main/res/drawable/ic_round_done_all_24.xml new file mode 100644 index 00000000..f134c697 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_done_all_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml new file mode 100644 index 00000000..c8e85353 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_emoji_emotions_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_error_outline_24.xml b/app/src/main/res/drawable/ic_round_error_outline_24.xml new file mode 100644 index 00000000..7d643610 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_error_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_group_24.xml b/app/src/main/res/drawable/ic_round_group_24.xml new file mode 100644 index 00000000..7704f2b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_group_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml new file mode 100644 index 00000000..a8340c19 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_keyboard_arrow_down_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_mail_24.xml b/app/src/main/res/drawable/ic_round_mail_24.xml new file mode 100644 index 00000000..d9a337d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mail_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_mic_none_24.xml b/app/src/main/res/drawable/ic_round_mic_none_24.xml new file mode 100644 index 00000000..a18159f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_mic_none_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_settings_24.xml b/app/src/main/res/drawable/ic_round_settings_24.xml new file mode 100644 index 00000000..a277a99d --- /dev/null +++ b/app/src/main/res/drawable/ic_round_settings_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_star_24.xml b/app/src/main/res/drawable/ic_round_star_24.xml new file mode 100644 index 00000000..f62410ba --- /dev/null +++ b/app/src/main/res/drawable/ic_round_star_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml b/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml new file mode 100644 index 00000000..cf263555 --- /dev/null +++ b/app/src/main/res/drawable/time_read_indicator_on_attachments_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 124de6fa..d634b0e7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,42 @@ - \ No newline at end of file + android:layout_height="match_parent"> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_captcha.xml b/app/src/main/res/layout/dialog_captcha.xml index 31348ebd..0cb45b0a 100644 --- a/app/src/main/res/layout/dialog_captcha.xml +++ b/app/src/main/res/layout/dialog_captcha.xml @@ -10,7 +10,8 @@ @@ -25,12 +26,15 @@ android:id="@+id/captchaImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_gravity="center_vertical" + android:layout_marginTop="2dp" + android:layout_marginEnd="16dp" android:src="@drawable/ic_security" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> @@ -51,27 +55,26 @@ diff --git a/app/src/main/res/layout/dialog_fast_login.xml b/app/src/main/res/layout/dialog_fast_login.xml new file mode 100644 index 00000000..f20d70b0 --- /dev/null +++ b/app/src/main/res/layout/dialog_fast_login.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_validation.xml b/app/src/main/res/layout/dialog_validation.xml index 9bf823a9..6f636d65 100644 --- a/app/src/main/res/layout/dialog_validation.xml +++ b/app/src/main/res/layout/dialog_validation.xml @@ -10,19 +10,21 @@ android:id="@+id/codeContainer" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" android:orientation="horizontal"> + app:tint="?colorPrimary" /> @@ -40,31 +42,29 @@ - diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml new file mode 100644 index 00000000..915e4c3f --- /dev/null +++ b/app/src/main/res/layout/drawer_header.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_conversations.xml b/app/src/main/res/layout/fragment_conversations.xml index 59042084..d0d5c48c 100644 --- a/app/src/main/res/layout/fragment_conversations.xml +++ b/app/src/main/res/layout/fragment_conversations.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:elevation="0dp"> - - - - - - - - - - + app:titleTextColor="?colorOnBackground" /> @@ -62,6 +34,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" + android:overScrollMode="ifContentScrolls" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/item_conversation" /> @@ -69,7 +42,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml index 4b17b183..a80a4e37 100644 --- a/app/src/main/res/layout/fragment_login.xml +++ b/app/src/main/res/layout/fragment_login.xml @@ -7,11 +7,33 @@ android:layout_height="match_parent" android:orientation="vertical"> - + android:visibility="gone"> + + + + + + + + - + android:src="@drawable/ic_launcher_foreground" + android:tint="?colorPrimary" /> @@ -61,20 +83,22 @@ android:id="@+id/loginImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="18dp" android:src="@drawable/ic_baseline_account_circle_24" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> @@ -94,25 +118,28 @@ android:id="@+id/passwordImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="18dp" android:src="@drawable/ic_key" - app:tint="?colorAccent" /> + app:tint="?colorPrimary" /> + app:passwordToggleTint="?colorPrimary"> @@ -124,8 +151,7 @@ android:layout_width="wrap_content" android:layout_height="60dp" android:layout_gravity="center_horizontal|bottom" - android:layout_marginTop="12dp" - android:backgroundTint="@color/a1_600" + android:layout_marginTop="48dp" android:fontFamily="@font/google_sans_medium" android:letterSpacing="0" android:paddingStart="24dp" @@ -135,7 +161,8 @@ app:cornerRadius="50dp" app:elevation="16dp" app:icon="@drawable/ic_arrow_end" - app:iconGravity="end" /> + app:iconGravity="end" + app:tint="?colorPrimary" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_messages_history.xml b/app/src/main/res/layout/fragment_messages_history.xml index c157381a..4967b7e1 100644 --- a/app/src/main/res/layout/fragment_messages_history.xml +++ b/app/src/main/res/layout/fragment_messages_history.xml @@ -1,250 +1,120 @@ - + android:id="@+id/refresh_layout" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/list_anchor" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/app_bar"> - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - + app:navigationIcon="@drawable/ic_round_arrow_back_24" + app:navigationIconTint="?colorOnBackground" + app:subtitleCentered="true" + app:titleCentered="true" + tools:subtitle="Last seen at 05.26.21, 17:55" + tools:title="@tools:sample/full_names" /> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + tools:visibility="visible"> - + android:layout_marginHorizontal="16dp" + tools:visibility="gone"> - + android:ellipsize="end" + android:maxLines="1" + android:textColor="?colorOnBackground" + app:fontFamily="@font/google_sans_regular" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toStartOf="@id/dismissReply" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="Michael Bae" /> - - - - - + - + + + + - + android:backgroundTint="?colorBackground" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> - + + + + - - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> + android:padding="12dp" + android:src="@drawable/ic_round_add_circle_outline_24" + android:tint="?colorPrimary" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + android:layout_height="18dp" + android:layout_weight="0" + android:background="@drawable/ic_back" + android:backgroundTint="?colorOnBackgroundVariantContainer" + android:gravity="center" + android:minWidth="18dp" + android:paddingHorizontal="2dp" + android:textColor="?colorOnBackgroundVariantOnContainer" + android:textSize="11sp" + android:visibility="gone" + app:layout_constraintEnd_toEndOf="@id/attach" + app:layout_constraintTop_toTopOf="@id/attach" + tools:text="3" + tools:visibility="visible" /> - + + + + + + + + + + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings_root.xml b/app/src/main/res/layout/fragment_settings_root.xml new file mode 100644 index 00000000..72ed28c1 --- /dev/null +++ b/app/src/main/res/layout/fragment_settings_root.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_updates.xml b/app/src/main/res/layout/fragment_updates.xml new file mode 100644 index 00000000..7dea74e9 --- /dev/null +++ b/app/src/main/res/layout/fragment_updates.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 5ac5b951..00c71acf 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -11,19 +11,19 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="20dp" + android:layout_marginStart="16dp" android:backgroundTint="?colorBackgroundVariant" android:orientation="horizontal" android:paddingVertical="8dp" android:paddingStart="8dp" - android:paddingEnd="32dp" + android:paddingEnd="24dp" tools:background="@drawable/ic_message_unread"> - - - - - - - - + - - + diff --git a/app/src/main/res/layout/item_message_attachment_call.xml b/app/src/main/res/layout/item_message_attachment_call.xml index 843c0078..750f452f 100644 --- a/app/src/main/res/layout/item_message_attachment_call.xml +++ b/app/src/main/res/layout/item_message_attachment_call.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_file.xml b/app/src/main/res/layout/item_message_attachment_file.xml index cadbb2a9..0d83ba32 100644 --- a/app/src/main/res/layout/item_message_attachment_file.xml +++ b/app/src/main/res/layout/item_message_attachment_file.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_forwards.xml b/app/src/main/res/layout/item_message_attachment_forwards.xml new file mode 100644 index 00000000..bb0f2f9a --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_forwards.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_geo.xml b/app/src/main/res/layout/item_message_attachment_geo.xml new file mode 100644 index 00000000..950b9f77 --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_geo.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_gift.xml b/app/src/main/res/layout/item_message_attachment_gift.xml index 56adcaca..8b89d013 100644 --- a/app/src/main/res/layout/item_message_attachment_gift.xml +++ b/app/src/main/res/layout/item_message_attachment_gift.xml @@ -1,5 +1,5 @@ - @@ -8,4 +8,4 @@ android:id="@+id/image" android:layout_width="wrap_content" android:layout_height="wrap_content" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_graffiti.xml b/app/src/main/res/layout/item_message_attachment_graffiti.xml index 56adcaca..8b89d013 100644 --- a/app/src/main/res/layout/item_message_attachment_graffiti.xml +++ b/app/src/main/res/layout/item_message_attachment_graffiti.xml @@ -1,5 +1,5 @@ - @@ -8,4 +8,4 @@ android:id="@+id/image" android:layout_width="wrap_content" android:layout_height="wrap_content" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_link.xml b/app/src/main/res/layout/item_message_attachment_link.xml index c97a1d4b..584e5008 100644 --- a/app/src/main/res/layout/item_message_attachment_link.xml +++ b/app/src/main/res/layout/item_message_attachment_link.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_photo.xml b/app/src/main/res/layout/item_message_attachment_photo.xml index 0a6ce835..97f91bbd 100644 --- a/app/src/main/res/layout/item_message_attachment_photo.xml +++ b/app/src/main/res/layout/item_message_attachment_photo.xml @@ -20,7 +20,7 @@ android:id="@+id/image" android:layout_width="0dp" android:layout_height="0dp" - android:layout_margin="2dp" + android:layout_margin="0dp" android:adjustViewBounds="true" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="@id/border" diff --git a/app/src/main/res/layout/item_message_attachment_reply.xml b/app/src/main/res/layout/item_message_attachment_reply.xml new file mode 100644 index 00000000..0181df75 --- /dev/null +++ b/app/src/main/res/layout/item_message_attachment_reply.xml @@ -0,0 +1,49 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_video.xml b/app/src/main/res/layout/item_message_attachment_video.xml index 580a09d6..1829dd48 100644 --- a/app/src/main/res/layout/item_message_attachment_video.xml +++ b/app/src/main/res/layout/item_message_attachment_video.xml @@ -20,7 +20,7 @@ android:id="@+id/image" android:layout_width="0dp" android:layout_height="0dp" - android:layout_margin="2dp" + android:layout_margin="0dp" android:adjustViewBounds="true" android:scaleType="centerCrop" app:layout_constraintBottom_toBottomOf="@id/border" diff --git a/app/src/main/res/layout/item_message_attachment_voice.xml b/app/src/main/res/layout/item_message_attachment_voice.xml index 774b8789..4532d531 100644 --- a/app/src/main/res/layout/item_message_attachment_voice.xml +++ b/app/src/main/res/layout/item_message_attachment_voice.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_attachment_wall_post.xml b/app/src/main/res/layout/item_message_attachment_wall_post.xml index 056f58e1..a4e0a877 100644 --- a/app/src/main/res/layout/item_message_attachment_wall_post.xml +++ b/app/src/main/res/layout/item_message_attachment_wall_post.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_in.xml b/app/src/main/res/layout/item_message_in.xml index 332e3c67..f955d8d9 100644 --- a/app/src/main/res/layout/item_message_in.xml +++ b/app/src/main/res/layout/item_message_in.xml @@ -1,100 +1,140 @@ - - - + + - - + android:layout_marginStart="6dp" + android:background="@drawable/ic_message_in_background_middle" + android:backgroundTint="?colorSurfaceVariant" + android:minWidth="60dp" + android:minHeight="36dp" + app:layout_constrainedWidth="true" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toEndOf="@id/avatar" + app:layout_constraintTop_toBottomOf="@id/spacer"> - + android:layout_margin="4dp" + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/text" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/title" /> - - - - - - - - - - - - - - - - + + + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text" /> - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_out.xml b/app/src/main/res/layout/item_message_out.xml index ca88b6a6..95c03742 100644 --- a/app/src/main/res/layout/item_message_out.xml +++ b/app/src/main/res/layout/item_message_out.xml @@ -1,74 +1,114 @@ - - + - + android:layout_marginEnd="6dp" + android:background="@drawable/ic_message_in_background_middle" + android:backgroundTint="?colorTertiaryContainer" + android:minWidth="60dp" + android:minHeight="36dp" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintTop_toBottomOf="@id/spacer"> - - - + android:layout_margin="4dp" + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/text" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:autoLink="all" + android:padding="6dp" + android:textColor="?colorOnBackground" + android:textSize="16sp" + app:layout_constrainedHeight="true" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toTopOf="@id/attachment_container" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/reply_container" + app:layout_constraintVertical_bias="0" + app:layout_constraintVertical_chainStyle="packed" + tools:text="This is some kind of a text\ " /> + android:visibility="gone" + app:layout_constrainedWidth="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text" /> - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_audio.xml b/app/src/main/res/layout/item_uploaded_attachment_audio.xml new file mode 100644 index 00000000..a85ffdca --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_audio.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_file.xml b/app/src/main/res/layout/item_uploaded_attachment_file.xml new file mode 100644 index 00000000..b5fd2bab --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_file.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_photo.xml b/app/src/main/res/layout/item_uploaded_attachment_photo.xml new file mode 100644 index 00000000..1e02c66e --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_photo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_uploaded_attachment_video.xml b/app/src/main/res/layout/item_uploaded_attachment_video.xml new file mode 100644 index 00000000..6a36733e --- /dev/null +++ b/app/src/main/res/layout/item_uploaded_attachment_video.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_menu_item_avatar.xml b/app/src/main/res/layout/toolbar_menu_item_avatar.xml new file mode 100644 index 00000000..68ca49af --- /dev/null +++ b/app/src/main/res/layout/toolbar_menu_item_avatar.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_bottom.xml b/app/src/main/res/menu/activity_main_bottom.xml index fac928d2..36b364d7 100644 --- a/app/src/main/res/menu/activity_main_bottom.xml +++ b/app/src/main/res/menu/activity_main_bottom.xml @@ -1,9 +1,19 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml new file mode 100644 index 00000000..3da21556 --- /dev/null +++ b/app/src/main/res/menu/activity_main_drawer.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_conversations.xml b/app/src/main/res/menu/fragment_conversations.xml index e006374c..07727f64 100644 --- a/app/src/main/res/menu/fragment_conversations.xml +++ b/app/src/main/res/menu/fragment_conversations.xml @@ -2,10 +2,11 @@ - - - - - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1..ef49c991 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml new file mode 100644 index 00000000..90a3711b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml new file mode 100644 index 00000000..2f2de293 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7353dbd1..ef49c991 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml new file mode 100644 index 00000000..ab0e9e61 --- /dev/null +++ b/app/src/main/res/values-v27/themes.xml @@ -0,0 +1,12 @@ + + + + + + - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d606e5f0..b570edc2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ - + - + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml new file mode 100644 index 00000000..d8cf32d5 --- /dev/null +++ b/app/src/main/res/xml/preferences.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 5b9dd9fb..a7fa4c3b 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -3,7 +3,10 @@ - + InKkE~^UnAEs2gk5 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 literal 54329 zcmagFV|ZrKvM!pAZQHhO+qP}9lTNj?q^^Y^VFp)SH8qbSJ)2BQ2giqr}t zFG7D6)c?v~^Z#E_K}1nTQbJ9gQ9<%vVRAxVj)8FwL5_iTdUB>&m3fhE=kRWl;g`&m z!W5kh{WsV%fO*%je&j+Lv4xxK~zsEYQls$Q-p&dwID|A)!7uWtJF-=Tm1{V@#x*+kUI$=%KUuf2ka zjiZ{oiL1MXE2EjciJM!jrjFNwCh`~hL>iemrqwqnX?T*MX;U>>8yRcZb{Oy+VKZos zLiFKYPw=LcaaQt8tj=eoo3-@bG_342HQ%?jpgAE?KCLEHC+DmjxAfJ%Og^$dpC8Xw zAcp-)tfJm}BPNq_+6m4gBgBm3+CvmL>4|$2N$^Bz7W(}fz1?U-u;nE`+9`KCLuqg} zwNstNM!J4Uw|78&Y9~9>MLf56to!@qGkJw5Thx%zkzj%Ek9Nn1QA@8NBXbwyWC>9H z#EPwjMNYPigE>*Ofz)HfTF&%PFj$U6mCe-AFw$U%-L?~-+nSXHHKkdgC5KJRTF}`G zE_HNdrE}S0zf4j{r_f-V2imSqW?}3w-4=f@o@-q+cZgaAbZ((hn))@|eWWhcT2pLpTpL!;_5*vM=sRL8 zqU##{U#lJKuyqW^X$ETU5ETeEVzhU|1m1750#f}38_5N9)B_2|v@1hUu=Kt7-@dhA zq_`OMgW01n`%1dB*}C)qxC8q;?zPeF_r;>}%JYmlER_1CUbKa07+=TV45~symC*g8 zW-8(gag#cAOuM0B1xG8eTp5HGVLE}+gYTmK=`XVVV*U!>H`~j4+ROIQ+NkN$LY>h4 zqpwdeE_@AX@PL};e5vTn`Ro(EjHVf$;^oiA%@IBQq>R7_D>m2D4OwwEepkg}R_k*M zM-o;+P27087eb+%*+6vWFCo9UEGw>t&WI17Pe7QVuoAoGHdJ(TEQNlJOqnjZ8adCb zI`}op16D@v7UOEo%8E-~m?c8FL1utPYlg@m$q@q7%mQ4?OK1h%ODjTjFvqd!C z-PI?8qX8{a@6d&Lb_X+hKxCImb*3GFemm?W_du5_&EqRq!+H?5#xiX#w$eLti-?E$;Dhu`{R(o>LzM4CjO>ICf z&DMfES#FW7npnbcuqREgjPQM#gs6h>`av_oEWwOJZ2i2|D|0~pYd#WazE2Bbsa}X@ zu;(9fi~%!VcjK6)?_wMAW-YXJAR{QHxrD5g(ou9mR6LPSA4BRG1QSZT6A?kelP_g- zH(JQjLc!`H4N=oLw=f3{+WmPA*s8QEeEUf6Vg}@!xwnsnR0bl~^2GSa5vb!Yl&4!> zWb|KQUsC$lT=3A|7vM9+d;mq=@L%uWKwXiO9}a~gP4s_4Yohc!fKEgV7WbVo>2ITbE*i`a|V!^p@~^<={#?Gz57 zyPWeM2@p>D*FW#W5Q`1`#5NW62XduP1XNO(bhg&cX`-LYZa|m-**bu|>}S;3)eP8_ zpNTnTfm8 ze+7wDH3KJ95p)5tlwk`S7mbD`SqHnYD*6`;gpp8VdHDz%RR_~I_Ar>5)vE-Pgu7^Y z|9Px+>pi3!DV%E%4N;ii0U3VBd2ZJNUY1YC^-e+{DYq+l@cGtmu(H#Oh%ibUBOd?C z{y5jW3v=0eV0r@qMLgv1JjZC|cZ9l9Q)k1lLgm))UR@#FrJd>w^`+iy$c9F@ic-|q zVHe@S2UAnc5VY_U4253QJxm&Ip!XKP8WNcnx9^cQ;KH6PlW8%pSihSH2(@{2m_o+m zr((MvBja2ctg0d0&U5XTD;5?d?h%JcRJp{_1BQW1xu&BrA3(a4Fh9hon-ly$pyeHq zG&;6q?m%NJ36K1Sq_=fdP(4f{Hop;_G_(i?sPzvB zDM}>*(uOsY0I1j^{$yn3#U(;B*g4cy$-1DTOkh3P!LQ;lJlP%jY8}Nya=h8$XD~%Y zbV&HJ%eCD9nui-0cw!+n`V~p6VCRqh5fRX z8`GbdZ@73r7~myQLBW%db;+BI?c-a>Y)m-FW~M=1^|<21_Sh9RT3iGbO{o-hpN%d6 z7%++#WekoBOP^d0$$|5npPe>u3PLvX_gjH2x(?{&z{jJ2tAOWTznPxv-pAv<*V7r$ z6&glt>7CAClWz6FEi3bToz-soY^{ScrjwVPV51=>n->c(NJngMj6TyHty`bfkF1hc zkJS%A@cL~QV0-aK4>Id!9dh7>0IV;1J9(myDO+gv76L3NLMUm9XyPauvNu$S<)-|F zZS}(kK_WnB)Cl`U?jsdYfAV4nrgzIF@+%1U8$poW&h^c6>kCx3;||fS1_7JvQT~CV zQ8Js+!p)3oW>Df(-}uqC`Tcd%E7GdJ0p}kYj5j8NKMp(KUs9u7?jQ94C)}0rba($~ zqyBx$(1ae^HEDG`Zc@-rXk1cqc7v0wibOR4qpgRDt#>-*8N3P;uKV0CgJE2SP>#8h z=+;i_CGlv+B^+$5a}SicVaSeaNn29K`C&=}`=#Nj&WJP9Xhz4mVa<+yP6hkrq1vo= z1rX4qg8dc4pmEvq%NAkpMK>mf2g?tg_1k2%v}<3`$6~Wlq@ItJ*PhHPoEh1Yi>v57 z4k0JMO)*=S`tKvR5gb-(VTEo>5Y>DZJZzgR+j6{Y`kd|jCVrg!>2hVjz({kZR z`dLlKhoqT!aI8=S+fVp(5*Dn6RrbpyO~0+?fy;bm$0jmTN|t5i6rxqr4=O}dY+ROd zo9Et|x}!u*xi~>-y>!M^+f&jc;IAsGiM_^}+4|pHRn{LThFFpD{bZ|TA*wcGm}XV^ zr*C6~@^5X-*R%FrHIgo-hJTBcyQ|3QEj+cSqp#>&t`ZzB?cXM6S(lRQw$I2?m5=wd z78ki`R?%;o%VUhXH?Z#(uwAn9$m`npJ=cA+lHGk@T7qq_M6Zoy1Lm9E0UUysN)I_x zW__OAqvku^>`J&CB=ie@yNWsaFmem}#L3T(x?a`oZ+$;3O-icj2(5z72Hnj=9Z0w% z<2#q-R=>hig*(t0^v)eGq2DHC%GymE-_j1WwBVGoU=GORGjtaqr0BNigOCqyt;O(S zKG+DoBsZU~okF<7ahjS}bzwXxbAxFfQAk&O@>LsZMsZ`?N?|CDWM(vOm%B3CBPC3o z%2t@%H$fwur}SSnckUm0-k)mOtht`?nwsDz=2#v=RBPGg39i#%odKq{K^;bTD!6A9 zskz$}t)sU^=a#jLZP@I=bPo?f-L}wpMs{Tc!m7-bi!Ldqj3EA~V;4(dltJmTXqH0r z%HAWKGutEc9vOo3P6Q;JdC^YTnby->VZ6&X8f{obffZ??1(cm&L2h7q)*w**+sE6dG*;(H|_Q!WxU{g)CeoT z(KY&bv!Usc|m+Fqfmk;h&RNF|LWuNZ!+DdX*L=s-=_iH=@i` z?Z+Okq^cFO4}_n|G*!)Wl_i%qiMBaH8(WuXtgI7EO=M>=i_+;MDjf3aY~6S9w0K zUuDO7O5Ta6+k40~xh~)D{=L&?Y0?c$s9cw*Ufe18)zzk%#ZY>Tr^|e%8KPb0ht`b( zuP@8#Ox@nQIqz9}AbW0RzE`Cf>39bOWz5N3qzS}ocxI=o$W|(nD~@EhW13Rj5nAp; zu2obEJa=kGC*#3=MkdkWy_%RKcN=?g$7!AZ8vBYKr$ePY(8aIQ&yRPlQ=mudv#q$q z4%WzAx=B{i)UdLFx4os?rZp6poShD7Vc&mSD@RdBJ=_m^&OlkEE1DFU@csgKcBifJ zz4N7+XEJhYzzO=86 z#%eBQZ$Nsf2+X0XPHUNmg#(sNt^NW1Y0|M(${e<0kW6f2q5M!2YE|hSEQ*X-%qo(V zHaFwyGZ0on=I{=fhe<=zo{=Og-_(to3?cvL4m6PymtNsdDINsBh8m>a%!5o3s(en) z=1I z6O+YNertC|OFNqd6P=$gMyvmfa`w~p9*gKDESFqNBy(~Zw3TFDYh}$iudn)9HxPBi zdokK@o~nu?%imcURr5Y~?6oo_JBe}t|pU5qjai|#JDyG=i^V~7+a{dEnO<(y>ahND#_X_fcEBNiZ)uc&%1HVtx8Ts z*H_Btvx^IhkfOB#{szN*n6;y05A>3eARDXslaE>tnLa>+`V&cgho?ED+&vv5KJszf zG4@G;7i;4_bVvZ>!mli3j7~tPgybF5|J6=Lt`u$D%X0l}#iY9nOXH@(%FFJLtzb%p zzHfABnSs;v-9(&nzbZytLiqqDIWzn>JQDk#JULcE5CyPq_m#4QV!}3421haQ+LcfO*>r;rg6K|r#5Sh|y@h1ao%Cl)t*u`4 zMTP!deC?aL7uTxm5^nUv#q2vS-5QbBKP|drbDXS%erB>fYM84Kpk^au99-BQBZR z7CDynflrIAi&ahza+kUryju5LR_}-Z27g)jqOc(!Lx9y)e z{cYc&_r947s9pteaa4}dc|!$$N9+M38sUr7h(%@Ehq`4HJtTpA>B8CLNO__@%(F5d z`SmX5jbux6i#qc}xOhumzbAELh*Mfr2SW99=WNOZRZgoCU4A2|4i|ZVFQt6qEhH#B zK_9G;&h*LO6tB`5dXRSBF0hq0tk{2q__aCKXYkP#9n^)@cq}`&Lo)1KM{W+>5mSed zKp~=}$p7>~nK@va`vN{mYzWN1(tE=u2BZhga5(VtPKk(*TvE&zmn5vSbjo zZLVobTl%;t@6;4SsZ>5+U-XEGUZGG;+~|V(pE&qqrp_f~{_1h@5ZrNETqe{bt9ioZ z#Qn~gWCH!t#Ha^n&fT2?{`}D@s4?9kXj;E;lWV9Zw8_4yM0Qg-6YSsKgvQ*fF{#Pq z{=(nyV>#*`RloBVCs;Lp*R1PBIQOY=EK4CQa*BD0MsYcg=opP?8;xYQDSAJBeJpw5 zPBc_Ft9?;<0?pBhCmOtWU*pN*;CkjJ_}qVic`}V@$TwFi15!mF1*m2wVX+>5p%(+R zQ~JUW*zWkalde{90@2v+oVlkxOZFihE&ZJ){c?hX3L2@R7jk*xjYtHi=}qb+4B(XJ z$gYcNudR~4Kz_WRq8eS((>ALWCO)&R-MXE+YxDn9V#X{_H@j616<|P(8h(7z?q*r+ zmpqR#7+g$cT@e&(%_|ipI&A%9+47%30TLY(yuf&*knx1wNx|%*H^;YB%ftt%5>QM= z^i;*6_KTSRzQm%qz*>cK&EISvF^ovbS4|R%)zKhTH_2K>jP3mBGn5{95&G9^a#4|K zv+!>fIsR8z{^x4)FIr*cYT@Q4Z{y}};rLHL+atCgHbfX*;+k&37DIgENn&=k(*lKD zG;uL-KAdLn*JQ?@r6Q!0V$xXP=J2i~;_+i3|F;_En;oAMG|I-RX#FwnmU&G}w`7R{ z788CrR-g1DW4h_`&$Z`ctN~{A)Hv_-Bl!%+pfif8wN32rMD zJDs$eVWBYQx1&2sCdB0!vU5~uf)=vy*{}t{2VBpcz<+~h0wb7F3?V^44*&83Z2#F` z32!rd4>uc63rQP$3lTH3zb-47IGR}f)8kZ4JvX#toIpXH`L%NnPDE~$QI1)0)|HS4 zVcITo$$oWWwCN@E-5h>N?Hua!N9CYb6f8vTFd>h3q5Jg-lCI6y%vu{Z_Uf z$MU{{^o~;nD_@m2|E{J)q;|BK7rx%`m``+OqZAqAVj-Dy+pD4-S3xK?($>wn5bi90CFAQ+ACd;&m6DQB8_o zjAq^=eUYc1o{#+p+ zn;K<)Pn*4u742P!;H^E3^Qu%2dM{2slouc$AN_3V^M7H_KY3H)#n7qd5_p~Za7zAj|s9{l)RdbV9e||_67`#Tu*c<8!I=zb@ z(MSvQ9;Wrkq6d)!9afh+G`!f$Ip!F<4ADdc*OY-y7BZMsau%y?EN6*hW4mOF%Q~bw z2==Z3^~?q<1GTeS>xGN-?CHZ7a#M4kDL zQxQr~1ZMzCSKFK5+32C%+C1kE#(2L=15AR!er7GKbp?Xd1qkkGipx5Q~FI-6zt< z*PTpeVI)Ngnnyaz5noIIgNZtb4bQdKG{Bs~&tf)?nM$a;7>r36djllw%hQxeCXeW^ z(i6@TEIuxD<2ulwLTt|&gZP%Ei+l!(%p5Yij6U(H#HMkqM8U$@OKB|5@vUiuY^d6X zW}fP3;Kps6051OEO(|JzmVU6SX(8q>*yf*x5QoxDK={PH^F?!VCzES_Qs>()_y|jg6LJlJWp;L zKM*g5DK7>W_*uv}{0WUB0>MHZ#oJZmO!b3MjEc}VhsLD~;E-qNNd?x7Q6~v zR=0$u>Zc2Xr}>x_5$-s#l!oz6I>W?lw;m9Ae{Tf9eMX;TI-Wf_mZ6sVrMnY#F}cDd z%CV*}fDsXUF7Vbw>PuDaGhu631+3|{xp<@Kl|%WxU+vuLlcrklMC!Aq+7n~I3cmQ! z`e3cA!XUEGdEPSu``&lZEKD1IKO(-VGvcnSc153m(i!8ohi`)N2n>U_BemYJ`uY>8B*Epj!oXRLV}XK}>D*^DHQ7?NY*&LJ9VSo`Ogi9J zGa;clWI8vIQqkngv2>xKd91K>?0`Sw;E&TMg&6dcd20|FcTsnUT7Yn{oI5V4@Ow~m zz#k~8TM!A9L7T!|colrC0P2WKZW7PNj_X4MfESbt<-soq*0LzShZ}fyUx!(xIIDwx zRHt^_GAWe0-Vm~bDZ(}XG%E+`XhKpPlMBo*5q_z$BGxYef8O!ToS8aT8pmjbPq)nV z%x*PF5ZuSHRJqJ!`5<4xC*xb2vC?7u1iljB_*iUGl6+yPyjn?F?GOF2_KW&gOkJ?w z3e^qc-te;zez`H$rsUCE0<@7PKGW?7sT1SPYWId|FJ8H`uEdNu4YJjre`8F*D}6Wh z|FQ`xf7yiphHIAkU&OYCn}w^ilY@o4larl?^M7&8YI;hzBIsX|i3UrLsx{QDKwCX< zy;a>yjfJ6!sz`NcVi+a!Fqk^VE^{6G53L?@Tif|j!3QZ0fk9QeUq8CWI;OmO-Hs+F zuZ4sHLA3{}LR2Qlyo+{d@?;`tpp6YB^BMoJt?&MHFY!JQwoa0nTSD+#Ku^4b{5SZVFwU9<~APYbaLO zu~Z)nS#dxI-5lmS-Bnw!(u15by(80LlC@|ynj{TzW)XcspC*}z0~8VRZq>#Z49G`I zgl|C#H&=}n-ajxfo{=pxPV(L*7g}gHET9b*s=cGV7VFa<;Htgjk>KyW@S!|z`lR1( zGSYkEl&@-bZ*d2WQ~hw3NpP=YNHF^XC{TMG$Gn+{b6pZn+5=<()>C!N^jncl0w6BJ zdHdnmSEGK5BlMeZD!v4t5m7ct7{k~$1Ie3GLFoHjAH*b?++s<|=yTF+^I&jT#zuMx z)MLhU+;LFk8bse|_{j+d*a=&cm2}M?*arjBPnfPgLwv)86D$6L zLJ0wPul7IenMvVAK$z^q5<^!)7aI|<&GGEbOr=E;UmGOIa}yO~EIr5xWU_(ol$&fa zR5E(2vB?S3EvJglTXdU#@qfDbCYs#82Yo^aZN6`{Ex#M)easBTe_J8utXu(fY1j|R z9o(sQbj$bKU{IjyhosYahY{63>}$9_+hWxB3j}VQkJ@2$D@vpeRSldU?&7I;qd2MF zSYmJ>zA(@N_iK}m*AMPIJG#Y&1KR)6`LJ83qg~`Do3v^B0>fU&wUx(qefuTgzFED{sJ65!iw{F2}1fQ3= ziFIP{kezQxmlx-!yo+sC4PEtG#K=5VM9YIN0z9~c4XTX?*4e@m;hFM!zVo>A`#566 z>f&3g94lJ{r)QJ5m7Xe3SLau_lOpL;A($wsjHR`;xTXgIiZ#o&vt~ zGR6KdU$FFbLfZCC3AEu$b`tj!9XgOGLSV=QPIYW zjI!hSP#?8pn0@ezuenOzoka8!8~jXTbiJ6+ZuItsWW03uzASFyn*zV2kIgPFR$Yzm zE<$cZlF>R8?Nr2_i?KiripBc+TGgJvG@vRTY2o?(_Di}D30!k&CT`>+7ry2!!iC*X z<@=U0_C#16=PN7bB39w+zPwDOHX}h20Ap);dx}kjXX0-QkRk=cr};GYsjSvyLZa-t zzHONWddi*)RDUH@RTAsGB_#&O+QJaaL+H<<9LLSE+nB@eGF1fALwjVOl8X_sdOYme z0lk!X=S(@25=TZHR7LlPp}fY~yNeThMIjD}pd9+q=j<_inh0$>mIzWVY+Z9p<{D^#0Xk+b_@eNSiR8;KzSZ#7lUsk~NGMcB8C2c=m2l5paHPq`q{S(kdA7Z1a zyfk2Y;w?^t`?@yC5Pz9&pzo}Hc#}mLgDmhKV|PJ3lKOY(Km@Fi2AV~CuET*YfUi}u zfInZnqDX(<#vaS<^fszuR=l)AbqG{}9{rnyx?PbZz3Pyu!eSJK`uwkJU!ORQXy4x83r!PNgOyD33}}L=>xX_93l6njNTuqL8J{l%*3FVn3MG4&Fv*`lBXZ z?=;kn6HTT^#SrPX-N)4EZiIZI!0ByXTWy;;J-Tht{jq1mjh`DSy7yGjHxIaY%*sTx zuy9#9CqE#qi>1misx=KRWm=qx4rk|}vd+LMY3M`ow8)}m$3Ggv&)Ri*ON+}<^P%T5 z_7JPVPfdM=Pv-oH<tecoE}(0O7|YZc*d8`Uv_M*3Rzv7$yZnJE6N_W=AQ3_BgU_TjA_T?a)U1csCmJ&YqMp-lJe`y6>N zt++Bi;ZMOD%%1c&-Q;bKsYg!SmS^#J@8UFY|G3!rtyaTFb!5@e(@l?1t(87ln8rG? z--$1)YC~vWnXiW3GXm`FNSyzu!m$qT=Eldf$sMl#PEfGmzQs^oUd=GIQfj(X=}dw+ zT*oa0*oS%@cLgvB&PKIQ=Ok?>x#c#dC#sQifgMwtAG^l3D9nIg(Zqi;D%807TtUUCL3_;kjyte#cAg?S%e4S2W>9^A(uy8Ss0Tc++ZTjJw1 z&Em2g!3lo@LlDyri(P^I8BPpn$RE7n*q9Q-c^>rfOMM6Pd5671I=ZBjAvpj8oIi$! zl0exNl(>NIiQpX~FRS9UgK|0l#s@#)p4?^?XAz}Gjb1?4Qe4?j&cL$C8u}n)?A@YC zfmbSM`Hl5pQFwv$CQBF=_$Sq zxsV?BHI5bGZTk?B6B&KLdIN-40S426X3j_|ceLla*M3}3gx3(_7MVY1++4mzhH#7# zD>2gTHy*%i$~}mqc#gK83288SKp@y3wz1L_e8fF$Rb}ex+`(h)j}%~Ld^3DUZkgez zOUNy^%>>HHE|-y$V@B}-M|_{h!vXpk01xaD%{l{oQ|~+^>rR*rv9iQen5t?{BHg|% zR`;S|KtUb!X<22RTBA4AAUM6#M?=w5VY-hEV)b`!y1^mPNEoy2K)a>OyA?Q~Q*&(O zRzQI~y_W=IPi?-OJX*&&8dvY0zWM2%yXdFI!D-n@6FsG)pEYdJbuA`g4yy;qrgR?G z8Mj7gv1oiWq)+_$GqqQ$(ZM@#|0j7})=#$S&hZwdoijFI4aCFLVI3tMH5fLreZ;KD zqA`)0l~D2tuIBYOy+LGw&hJ5OyE+@cnZ0L5+;yo2pIMdt@4$r^5Y!x7nHs{@>|W(MzJjATyWGNwZ^4j+EPU0RpAl-oTM@u{lx*i0^yyWPfHt6QwPvYpk9xFMWfBFt!+Gu6TlAmr zeQ#PX71vzN*_-xh&__N`IXv6`>CgV#eA_%e@7wjgkj8jlKzO~Ic6g$cT`^W{R{606 zCDP~+NVZ6DMO$jhL~#+!g*$T!XW63#(ngDn#Qwy71yj^gazS{e;3jGRM0HedGD@pt z?(ln3pCUA(ekqAvvnKy0G@?-|-dh=eS%4Civ&c}s%wF@0K5Bltaq^2Os1n6Z3%?-Q zAlC4goQ&vK6TpgtzkHVt*1!tBYt-`|5HLV1V7*#45Vb+GACuU+QB&hZ=N_flPy0TY zR^HIrdskB#<$aU;HY(K{a3(OQa$0<9qH(oa)lg@Uf>M5g2W0U5 zk!JSlhrw8quBx9A>RJ6}=;W&wt@2E$7J=9SVHsdC?K(L(KACb#z)@C$xXD8^!7|uv zZh$6fkq)aoD}^79VqdJ!Nz-8$IrU(_-&^cHBI;4 z^$B+1aPe|LG)C55LjP;jab{dTf$0~xbXS9!!QdcmDYLbL^jvxu2y*qnx2%jbL%rB z{aP85qBJe#(&O~Prk%IJARcdEypZ)vah%ZZ%;Zk{eW(U)Bx7VlzgOi8)x z`rh4l`@l_Ada7z&yUK>ZF;i6YLGwI*Sg#Fk#Qr0Jg&VLax(nNN$u-XJ5=MsP3|(lEdIOJ7|(x3iY;ea)5#BW*mDV%^=8qOeYO&gIdJVuLLN3cFaN=xZtFB=b zH{l)PZl_j^u+qx@89}gAQW7ofb+k)QwX=aegihossZq*+@PlCpb$rpp>Cbk9UJO<~ zDjlXQ_Ig#W0zdD3&*ei(FwlN#3b%FSR%&M^ywF@Fr>d~do@-kIS$e%wkIVfJ|Ohh=zc zF&Rnic^|>@R%v?@jO}a9;nY3Qrg_!xC=ZWUcYiA5R+|2nsM*$+c$TOs6pm!}Z}dfM zGeBhMGWw3$6KZXav^>YNA=r6Es>p<6HRYcZY)z{>yasbC81A*G-le8~QoV;rtKnkx z;+os8BvEe?0A6W*a#dOudsv3aWs?d% z0oNngyVMjavLjtjiG`!007#?62ClTqqU$@kIY`=x^$2e>iqIy1>o|@Tw@)P)B8_1$r#6>DB_5 zmaOaoE~^9TolgDgooKFuEFB#klSF%9-~d2~_|kQ0Y{Ek=HH5yq9s zDq#1S551c`kSiWPZbweN^A4kWiP#Qg6er1}HcKv{fxb1*BULboD0fwfaNM_<55>qM zETZ8TJDO4V)=aPp_eQjX%||Ud<>wkIzvDlpNjqW>I}W!-j7M^TNe5JIFh#-}zAV!$ICOju8Kx)N z0vLtzDdy*rQN!7r>Xz7rLw8J-(GzQlYYVH$WK#F`i_i^qVlzTNAh>gBWKV@XC$T-` z3|kj#iCquDhiO7NKum07i|<-NuVsX}Q}mIP$jBJDMfUiaWR3c|F_kWBMw0_Sr|6h4 zk`_r5=0&rCR^*tOy$A8K;@|NqwncjZ>Y-75vlpxq%Cl3EgH`}^^~=u zoll6xxY@a>0f%Ddpi;=cY}fyG!K2N-dEyXXmUP5u){4VnyS^T4?pjN@Ot4zjL(Puw z_U#wMH2Z#8Pts{olG5Dy0tZj;N@;fHheu>YKYQU=4Bk|wcD9MbA`3O4bj$hNRHwzb zSLcG0SLV%zywdbuwl(^E_!@&)TdXge4O{MRWk2RKOt@!8E{$BU-AH(@4{gxs=YAz9LIob|Hzto0}9cWoz6Tp2x0&xi#$ zHh$dwO&UCR1Ob2w00-2eG7d4=cN(Y>0R#$q8?||q@iTi+7-w-xR%uMr&StFIthC<# zvK(aPduwuNB}oJUV8+Zl)%cnfsHI%4`;x6XW^UF^e4s3Z@S<&EV8?56Wya;HNs0E> z`$0dgRdiUz9RO9Au3RmYq>K#G=X%*_dUbSJHP`lSfBaN8t-~@F>)BL1RT*9I851A3 z<-+Gb#_QRX>~av#Ni<#zLswtu-c6{jGHR>wflhKLzC4P@b%8&~u)fosoNjk4r#GvC zlU#UU9&0Hv;d%g72Wq?Ym<&&vtA3AB##L}=ZjiTR4hh7J)e>ei} zt*u+>h%MwN`%3}b4wYpV=QwbY!jwfIj#{me)TDOG`?tI!%l=AwL2G@9I~}?_dA5g6 zCKgK(;6Q0&P&K21Tx~k=o6jwV{dI_G+Ba*Zts|Tl6q1zeC?iYJTb{hel*x>^wb|2RkHkU$!+S4OU4ZOKPZjV>9OVsqNnv5jK8TRAE$A&^yRwK zj-MJ3Pl?)KA~fq#*K~W0l4$0=8GRx^9+?w z!QT8*-)w|S^B0)ZeY5gZPI2G(QtQf?DjuK(s^$rMA!C%P22vynZY4SuOE=wX2f8$R z)A}mzJi4WJnZ`!bHG1=$lwaxm!GOnRbR15F$nRC-M*H<*VfF|pQw(;tbSfp({>9^5 zw_M1-SJ9eGF~m(0dvp*P8uaA0Yw+EkP-SWqu zqal$hK8SmM7#Mrs0@OD+%_J%H*bMyZiWAZdsIBj#lkZ!l2c&IpLu(5^T0Ge5PHzR} zn;TXs$+IQ_&;O~u=Jz+XE0wbOy`=6>m9JVG} zJ~Kp1e5m?K3x@@>!D)piw^eMIHjD4RebtR`|IlckplP1;r21wTi8v((KqNqn%2CB< zifaQc&T}*M&0i|LW^LgdjIaX|o~I$`owHolRqeH_CFrqCUCleN130&vH}dK|^kC>) z-r2P~mApHotL4dRX$25lIcRh_*kJaxi^%ZN5-GAAMOxfB!6flLPY-p&QzL9TE%ho( zRwftE3sy5<*^)qYzKkL|rE>n@hyr;xPqncY6QJ8125!MWr`UCWuC~A#G1AqF1@V$kv>@NBvN&2ygy*{QvxolkRRb%Ui zsmKROR%{*g*WjUUod@@cS^4eF^}yQ1>;WlGwOli z+Y$(8I`0(^d|w>{eaf!_BBM;NpCoeem2>J}82*!em=}}ymoXk>QEfJ>G(3LNA2-46 z5PGvjr)Xh9>aSe>vEzM*>xp{tJyZox1ZRl}QjcvX2TEgNc^(_-hir@Es>NySoa1g^ zFow_twnHdx(j?Q_3q51t3XI7YlJ4_q&(0#)&a+RUy{IcBq?)eaWo*=H2UUVIqtp&lW9JTJiP&u zw8+4vo~_IJXZIJb_U^&=GI1nSD%e;P!c{kZALNCm5c%%oF+I3DrA63_@4)(v4(t~JiddILp7jmoy+>cD~ivwoctFfEL zP*#2Rx?_&bCpX26MBgp^4G>@h`Hxc(lnqyj!*t>9sOBcXN(hTwEDpn^X{x!!gPX?1 z*uM$}cYRwHXuf+gYTB}gDTcw{TXSOUU$S?8BeP&sc!Lc{{pEv}x#ELX>6*ipI1#>8 zKes$bHjiJ1OygZge_ak^Hz#k;=od1wZ=o71ba7oClBMq>Uk6hVq|ePPt)@FM5bW$I z;d2Or@wBjbTyZj|;+iHp%Bo!Vy(X3YM-}lasMItEV_QrP-Kk_J4C>)L&I3Xxj=E?| zsAF(IfVQ4w+dRRnJ>)}o^3_012YYgFWE)5TT=l2657*L8_u1KC>Y-R{7w^S&A^X^U}h20jpS zQsdeaA#WIE*<8KG*oXc~$izYilTc#z{5xhpXmdT-YUnGh9v4c#lrHG6X82F2-t35} zB`jo$HjKe~E*W$=g|j&P>70_cI`GnOQ;Jp*JK#CT zuEGCn{8A@bC)~0%wsEv?O^hSZF*iqjO~_h|>xv>PO+?525Nw2472(yqS>(#R)D7O( zg)Zrj9n9$}=~b00=Wjf?E418qP-@8%MQ%PBiCTX=$B)e5cHFDu$LnOeJ~NC;xmOk# z>z&TbsK>Qzk)!88lNI8fOE2$Uxso^j*1fz>6Ot49y@=po)j4hbTIcVR`ePHpuJSfp zxaD^Dn3X}Na3@<_Pc>a;-|^Pon(>|ytG_+U^8j_JxP=_d>L$Hj?|0lz>_qQ#a|$+( z(x=Lipuc8p4^}1EQhI|TubffZvB~lu$zz9ao%T?%ZLyV5S9}cLeT?c} z>yCN9<04NRi~1oR)CiBakoNhY9BPnv)kw%*iv8vdr&&VgLGIs(-FbJ?d_gfbL2={- zBk4lkdPk~7+jIxd4{M(-W1AC_WcN&Oza@jZoj zaE*9Y;g83#m(OhA!w~LNfUJNUuRz*H-=$s*z+q+;snKPRm9EptejugC-@7-a-}Tz0 z@KHra#Y@OXK+KsaSN9WiGf?&jlZ!V7L||%KHP;SLksMFfjkeIMf<1e~t?!G3{n)H8 zQAlFY#QwfKuj;l@<$YDATAk;%PtD%B(0<|8>rXU< zJ66rkAVW_~Dj!7JGdGGi4NFuE?7ZafdMxIh65Sz7yQoA7fBZCE@WwysB=+`kT^LFX zz8#FlSA5)6FG9(qL3~A24mpzL@@2D#>0J7mMS1T*9UJ zvOq!!a(%IYY69+h45CE?(&v9H4FCr>gK0>mK~F}5RdOuH2{4|}k@5XpsX7+LZo^Qa4sH5`eUj>iffoBVm+ zz4Mtf`h?NW$*q1yr|}E&eNl)J``SZvTf6Qr*&S%tVv_OBpbjnA0&Vz#(;QmGiq-k! zgS0br4I&+^2mgA15*~Cd00cXLYOLA#Ep}_)eED>m+K@JTPr_|lSN}(OzFXQSBc6fM z@f-%2;1@BzhZa*LFV z-LrLmkmB%<<&jEURBEW>soaZ*rSIJNwaV%-RSaCZi4X)qYy^PxZ=oL?6N-5OGOMD2 z;q_JK?zkwQ@b3~ln&sDtT5SpW9a0q+5Gm|fpVY2|zqlNYBR}E5+ahgdj!CvK$Tlk0 z9g$5N;aar=CqMsudQV>yb4l@hN(9Jcc=1(|OHsqH6|g=K-WBd8GxZ`AkT?OO z-z_Ued-??Z*R4~L7jwJ%-`s~FK|qNAJ;EmIVDVpk{Lr7T4l{}vL)|GuUuswe9c5F| zv*5%u01hlv08?00Vpwyk*Q&&fY8k6MjOfpZfKa@F-^6d=Zv|0@&4_544RP5(s|4VPVP-f>%u(J@23BHqo2=zJ#v9g=F!cP((h zpt0|(s++ej?|$;2PE%+kc6JMmJjDW)3BXvBK!h!E`8Y&*7hS{c_Z?4SFP&Y<3evqf z9-ke+bSj$%Pk{CJlJbWwlBg^mEC^@%Ou?o>*|O)rl&`KIbHrjcpqsc$Zqt0^^F-gU2O=BusO+(Op}!jNzLMc zT;0YT%$@ClS%V+6lMTfhuzzxomoat=1H?1$5Ei7&M|gxo`~{UiV5w64Np6xV zVK^nL$)#^tjhCpTQMspXI({TW^U5h&Wi1Jl8g?P1YCV4=%ZYyjSo#5$SX&`r&1PyC zzc;uzCd)VTIih|8eNqFNeBMe#j_FS6rq81b>5?aXg+E#&$m++Gz9<+2)h=K(xtn}F ziV{rmu+Y>A)qvF}ms}4X^Isy!M&1%$E!rTO~5(p+8{U6#hWu>(Ll1}eD64Xa>~73A*538wry?v$vW z>^O#FRdbj(k0Nr&)U`Tl(4PI*%IV~;ZcI2z&rmq=(k^}zGOYZF3b2~Klpzd2eZJl> zB=MOLwI1{$RxQ7Y4e30&yOx?BvAvDkTBvWPpl4V8B7o>4SJn*+h1Ms&fHso%XLN5j z-zEwT%dTefp~)J_C8;Q6i$t!dnlh-!%haR1X_NuYUuP-)`IGWjwzAvp!9@h`kPZhf zwLwFk{m3arCdx8rD~K2`42mIN4}m%OQ|f)4kf%pL?Af5Ul<3M2fv>;nlhEPR8b)u} zIV*2-wyyD%%) zl$G@KrC#cUwoL?YdQyf9WH)@gWB{jd5w4evI& zOFF)p_D8>;3-N1z6mES!OPe>B^<;9xsh)){Cw$Vs-ez5nXS95NOr3s$IU;>VZSzKn zBvub8_J~I%(DozZW@{)Vp37-zevxMRZ8$8iRfwHmYvyjOxIOAF2FUngKj289!(uxY zaClWm!%x&teKmr^ABrvZ(ikx{{I-lEzw5&4t3P0eX%M~>$wG0ZjA4Mb&op+0$#SO_ z--R`>X!aqFu^F|a!{Up-iF(K+alKB{MNMs>e(i@Tpy+7Z-dK%IEjQFO(G+2mOb@BO zP>WHlS#fSQm0et)bG8^ZDScGnh-qRKIFz zfUdnk=m){ej0i(VBd@RLtRq3Ep=>&2zZ2%&vvf?Iex01hx1X!8U+?>ER;yJlR-2q4 z;Y@hzhEC=d+Le%=esE>OQ!Q|E%6yG3V_2*uh&_nguPcZ{q?DNq8h_2ahaP6=pP-+x zK!(ve(yfoYC+n(_+chiJ6N(ZaN+XSZ{|H{TR1J_s8x4jpis-Z-rlRvRK#U%SMJ(`C z?T2 zF(NNfO_&W%2roEC2j#v*(nRgl1X)V-USp-H|CwFNs?n@&vpRcj@W@xCJwR6@T!jt377?XjZ06=`d*MFyTdyvW!`mQm~t3luzYzvh^F zM|V}rO>IlBjZc}9Z zd$&!tthvr>5)m;5;96LWiAV0?t)7suqdh0cZis`^Pyg@?t>Ms~7{nCU;z`Xl+raSr zXpp=W1oHB*98s!Tpw=R5C)O{{Inl>9l7M*kq%#w9a$6N~v?BY2GKOVRkXYCgg*d

<5G2M1WZP5 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 F{|7XC?P&l2 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5cd16b22..aa991fce 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Sun Feb 14 01:38:52 MSK 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip diff --git a/gradlew b/gradlew index cccdd3d5..1b6c7873 100644 --- a/gradlew +++ b/gradlew @@ -1,78 +1,129 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -89,84 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done fi +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f9553162..107acd32 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -13,15 +29,18 @@ if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ota_alpha.json b/ota_alpha.json new file mode 100644 index 00000000..07c89ef7 --- /dev/null +++ b/ota_alpha.json @@ -0,0 +1,12 @@ +{ + "version": "1.4.8", + "link": "https://github.com/melod1n/fast-messenger/releases/download/1.4.8/app-alpha.apk", + "changelogs": { + "1.4.3": "* forwards attachment\n* fix empty screen after clear database", + "1.4.4": "* hotfix crash on conversations list", + "1.4.5": "* ability to see changelog on update screen\n* settings screen\n* ACRA Crash Reporter", + "1.4.6": "* hotfix light status bar on android < 8", + "1.4.7": "* multiline dialogs in settings\n* fix crash on 2FA sign in", + "1.4.8": "* Microsoft AppCenter integration with crashes and distribution" + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 47bfbee1..1e8d82b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,116 @@ rootProject.name = "fast-messenger" -include(":app") \ No newline at end of file +include(":app") + +enableFeaturePreview("VERSION_CATALOGS") + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + // androidx - Core + library("androidx-core", "androidx.core:core-ktx:1.8.0") + + // androidx - Lifecycle + version("androidx-lifecycle", "2.5.1") + library("androidx-lifecycle-viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-livedata", "androidx.lifecycle", "lifecycle-livedata-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-runtime", "androidx.lifecycle", "lifecycle-runtime-ktx").versionRef("androidx-lifecycle") + library("androidx-lifecycle-viewmodel-savedstate", "androidx.lifecycle", "lifecycle-viewmodel-savedstate").versionRef("androidx-lifecycle") + library("androidx-lifecycle-common-java8", "androidx.lifecycle", "lifecycle-common-java8").versionRef("androidx-lifecycle") + + // androidx - SplashScreen + library("androidx-splashScreen", "androidx.core:core-splashscreen:1.0.0") + + // androidx - DataStore + library("androidx-dataStore", "androidx.datastore:datastore-preferences:1.0.0") + + // androidx - AppCompat + library("androidx-appCompat", "androidx.appcompat:appcompat:1.5.0") + + // androidx - Activity + library("androidx-activity", "androidx.activity:activity-ktx:1.5.1") + + // androidx - Fragment + library("androidx-fragment", "androidx.fragment:fragment-ktx:1.5.2") + + // androidx - Preference + library("androidx-preference", "androidx.preference:preference-ktx:1.2.0") + + // androidx - SwipeRefreshLayout + library("androidx-swipeRefreshLayout", "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // androidx - RecyclerView + library("androidx-recyclerView", "androidx.recyclerview:recyclerview:1.2.1") + + // androidx - CardView + library("androidx-cardView", "androidx.cardview:cardview:1.0.0") + + // androidx - ConstraintLayout + library("androidx-constraintLayout", "androidx.constraintlayout:constraintlayout:2.1.4") + + // androidx - Room + version("room", "2.4.3") + library("androidx-room", "androidx.room", "room-ktx").versionRef("room") + library("androidx-room-runtime", "androidx.room", "room-runtime").versionRef("room") + library("androidx-room-compiler", "androidx.room", "room-compiler").versionRef("room") + + // Cicerone + library("cicerone", "com.github.terrakok:cicerone:7.1") + + // WaveformSeekBar + library("waveformSeekBar", "com.github.massoudss:waveformSeekBar:5.0.0") + + // Glide + version("glide", "4.13.0") + library("glide", "com.github.bumptech.glide", "glide").versionRef("glide") + library("glide-compiler", "com.github.bumptech.glide", "compiler").versionRef("glide") + + // KPermissions + version("kPermissions", "3.3.0") + library("kPermissions", "com.github.fondesa", "kpermissions").versionRef("kPermissions") + library("kPermissions-coroutines", "com.github.fondesa", "kpermissions-coroutines").versionRef("kPermissions") + + // Microsoft AppCenter + version("appCenterSdk", "4.3.1") + library("appCenter-analytics", "com.microsoft.appcenter", "appcenter-analytics").versionRef("appCenterSdk") + library("appCenter-crashes", "com.microsoft.appcenter", "appcenter-crashes").versionRef("appCenterSdk") + + // Hilt + version("hilt", "2.39.1") + library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") + library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") + + // Retrofit + version("retrofit", "2.9.0") + library("retrofit", "com.squareup.retrofit2", "retrofit").versionRef("retrofit") + library("retrofit-gson-converter", "com.squareup.retrofit2", "converter-gson").versionRef("retrofit") + + // OkHttp3 + version("okhttp3", "5.0.0-alpha.2") + library("okhttp3", "com.squareup.okhttp3", "okhttp").versionRef("okhttp3") + library("okhttp3-interceptor", "com.squareup.okhttp3", "logging-interceptor").versionRef("okhttp3") + + // Coroutines + version("coroutines", "1.6.1") + library("coroutines-core", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").versionRef("coroutines") + library("coroutines-android", "org.jetbrains.kotlinx", "kotlinx-coroutines-android").versionRef("coroutines") + + // ViewBinding Delegate + library("viewBindingDelegate", "com.github.yogacp:android-viewbinding:1.0.4") + + // Google - Gson + library("google-gson", "com.google.code.gson:gson:2.8.9") + + // Google - Guava + library("google-guava", "com.google.guava:guava:31.1-android") + + // Google - Material + library("google-material", "com.google.android.material:material:1.6.1") + + // Jsoup + library("jsoup", "org.jsoup:jsoup:1.15.1") + + // Chucker + library("chucker", "com.github.chuckerteam.chucker:library:3.5.2") + } + } +} \ No newline at end of file