156 Commits

Author SHA1 Message Date
melod1n cb653eddc2 refactor(auth): reuse network ValidationType for validation flow
Remove duplicate auth ValidationType and use the shared network model instead.
Add support for both "sms" and "2fa_sms" SMS validation values.
2026-05-03 06:53:56 +03:00
melod1n df2c61d8d7 feat(auth): add web captcha handling
- replace manual captcha screen with WebView-based VK captcha flow
- handle captcha error 14 by showing the captcha overlay and retrying with success_token
- pass captcha redirect/result state through AppSettings
- remove old captcha ViewModel, navigation, validation, and DI
- add ACRA crash reporting
- add WIP message edit mode UI/state
- update Gradle wrapper, SDK config, and dependencies
2026-05-03 05:49:16 +03:00
dependabot[bot] 97c59a85b6 Chore(deps): Bump actions/upload-artifact from 6 to 7 (#252)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 21:01:31 +03:00
melod1n 155a3666ad bump app version code and name (#251) 2026-02-16 16:32:10 +03:00
melod1n ce375c902c Refactor: Improve reply component layout and styling
This commit refactors the `Reply` composable for better layout consistency and simplifies its implementation.

The `Reply` component no longer uses a fixed height, allowing it to dynamically resize based on its content. The layout has been updated from a `Box` to a `Row` to properly align the side indicator bar with the height of the text content. Padding and corner rounding logic has been simplified and centralized within the `Reply` composable itself, removing redundant parameters from the `MessageBubble`.

Key changes:
- `Reply` composable now uses `Row` for its root layout instead of `Box`.
- Removed the fixed `48.dp` height to allow dynamic content sizing.
- The side indicator bar's height now matches the text content's height.
- Simplified padding and shape logic in `MessageBubble` by removing conditional parameters passed to `Reply`.
- Adjusted padding inside `MessageBubble` to accommodate the new `Reply` layout.
2026-02-06 22:58:03 +03:00
melod1n 96b4fc8539 ui: improve Compose stability and message UI
- Add minute/second abbreviations and kotlin.time-based relative time formatter
- Introduce FastPreview and update previews to use AppTheme with dark/dynamic colors
- Refactor attachments preview grid & waveform to use ImmutableList and reduce recompositions
- Tweak message bubble reply styling and swipe-to-reply animation/haptics
- Add Compose Stability Analyzer plugin and enable it in debug builds
- Cache shared images by sha256 and improve share intent/chooser text
- Minor UX polish (e.g., “No views”) and immutability annotations
2026-02-06 22:14:01 +03:00
melod1n e3e9157dd5 Style: Update icons for message status and actions
This commit updates the icons used to indicate a message's status and within the message context menu. The outlined "star" and "edit" icons have been replaced with their filled variants for better visual distinction.

Key changes:
- Replaced `ic_star_round_24` with `ic_star_fill_round_24` for "important" messages in `DateStatus` and the "Mark as Important" action.
- Replaced `ic_edit_round_24` with `ic_edit_fill_round_24` for "edited" messages in `DateStatus`.
- Added the new `ic_edit_fill_round_24` drawable resource.
- In `MessageBubble`, the `derivedStateOf` for `shouldFill` is now wrapped in a `remember` block to prevent unnecessary recompositions.
2026-01-24 21:58:11 +03:00
melod1n 5aa28066d7 add icons to dialog in messages history
change icons fill color from @android:color/white to #ffffff
2026-01-24 21:49:28 +03:00
melod1n 1638d70ef2 update and refresh icons to Material Symbols;
update MaterialDialog style
2026-01-24 21:36:41 +03:00
melod1n 8c13d9e7e1 update some icons 2026-01-24 16:02:02 +03:00
melod1n 5dd829b6f6 change @android:color/white to #ffffff in icons 2026-01-24 15:28:13 +03:00
melod1n 2a238fa1bf Refactor: Upgrade Gradle and streamline build logic
This commit upgrades the project's build system and refactors the build logic for better maintainability and alignment with modern practices.

The Gradle version has been updated from 8.14.2 to 9.3.0, and the Android Gradle Plugin (AGP) has been upgraded to version 9.0.0. This required migrating the build logic to use the new `com.android.build.api.dsl` interfaces instead of the deprecated `com.android.build.gradle` ones.

Key changes:
- Upgraded Gradle to `9.3.0`.
- Upgraded Android Gradle Plugin to `9.0.0`.
- Updated various dependencies including Kotlin, Compose BOM, Chucker, and serialization.
- Removed the explicit `kotlin-android` plugin application, as it's now handled by AGP.
- Migrated build convention plugins to use the new AGP DSL APIs.
- Commented out the custom APK naming logic in `app/build.gradle.kts`.
- Added new `gradle.properties` flags for build configuration.
- Corrected the namespace in `core/model` from `datastore` to `model`.
2026-01-24 14:58:04 +03:00
melod1n 3f54961ac6 Build: Add support for Nexus repository
This commit updates the Gradle settings to allow specifying Nexus repositories for both plugins and dependencies. It reads the repository URLs from Gradle properties or environment variables (`NEXUS_PLUGINS_URL` and `NEXUS_MAVEN_URL`).

If these properties are set, the corresponding Nexus Maven repositories are added to the build configuration.
2026-01-24 11:35:16 +03:00
difome 045f2e8268 feat: add Ukrainian localization (#250) 2025-12-27 21:57:23 +03:00
melod1n 3eb33b2612 Refactor: Remove unused resources
This commit cleans up the `core/ui` module by removing unused drawable files and string resources.

Key changes:
- Deleted unused drawables: `ic_multimedia.xml`, `round_file_download_24.xml`, `round_install_mobile_24.xml`, and `round_play_arrow_24px.xml`.
- Removed a large number of unused string resources from `values/strings.xml` and `values-ru/strings.xml`, including strings related to calls, captchas, and duplicate actions.
2025-12-27 21:44:50 +03:00
melod1n f2d565fd3e Fix typo in Russian string resource
Corrects a spelling error in the Russian translation for `message_context_action_unmark_as_spam` in `core/ui/src/main/res/values-ru/strings.xml`.

- Changed "Помеьиьб как не спам" to "Пометить как не спам".
2025-12-27 21:36:17 +03:00
melod1n 7ab333280c Refactor: Clean up unused code and improve error handling
This commit performs a general cleanup of the codebase by removing unused dependencies, comments, and functions. It also improves error handling in the build logic.

Key changes:
- Removed a TODO and an inappropriate function `dickPizda` from `Extensions.kt`.
- Removed stale TODO comments from `core/data/build.gradle.kts` and `core/domain/build.gradle.kts`.
- Replaced a `TODO` call with a proper `IllegalArgumentException` in `KotlinAndroid.kt` for better error reporting when encountering unsupported project extensions.
2025-12-27 20:56:02 +03:00
melod1n 45ee0acea5 * refactor Conversation -> Convo
* extract Message and Convo mappers to core/domain module
* improve reply container text
2025-12-17 17:16:02 +03:00
melod1n 7b6571f208 Feat: Add animation to reply summary visibility
This commit wraps the reply summary `Text` composable within an `AnimatedVisibility` component. This ensures that the summary animates in and out of view smoothly when its content changes, preventing abrupt layout shifts.
2025-12-17 09:21:22 +03:00
melod1n 389d3f9e52 Style: Update message bubble colors
Updates the container colors for incoming and outgoing message bubbles to align with Material 3 design tokens.

- The outgoing message bubble container color is changed from `surfaceColorAtElevation(2.dp)` to `surfaceContainer`.
- The reply container color within an outgoing message is changed from `primaryContainer` to `surfaceContainerHighest`.

Additionally, the `@Preview` for `MessageBubble` is updated to display both an incoming and an outgoing message for better design validation.
2025-12-15 23:08:42 +03:00
melod1n 69a50f8fcd Refactor: Encapsulate MessageBubble colors
This commit refactors the `MessageBubble` composable by extracting the color logic into a private `messageBubbleColors` function. This function returns an immutable `MessageBubbleColors` data class, which holds the container, content, and reply container colors.

This change cleans up the main composable, improves readability, and centralizes color definitions for both incoming and outgoing messages. Additionally, the background color logic for attachments has been simplified to make it transparent for media types like stickers and videos.
2025-12-15 23:01:38 +03:00
melod1n 8839015249 Fix: Ensure sender's name truncates correctly in incoming messages
This commit resolves an issue where the sender's name in incoming message bubbles would not truncate properly, potentially breaking the layout. The fix ensures the name text correctly adapts to the width of the message bubble.

Additionally, this change introduces `ImmutableList` for message attachments to improve performance and refactors where the conversion to `ImmutableList` happens, moving it into the `MessageMapper`.

Key changes:
- The `MessageBubble` now reports its width, allowing the sender's name `Text` to be constrained correctly.
- Sender's name now uses `labelMedium` typography.
- Enabled showing the sender's name by default in `MessagesHistoryViewModelImpl`.
- Changed `UiItem.Message.attachments` from `List` to `ImmutableList` for better Compose performance.
- Moved the `toImmutableList()` conversion for attachments into the `MessageMapper`.
2025-12-15 22:58:50 +03:00
melod1n 478639e427 Refactor: Extract RootScreen from MainActivity and fix reply message user
This commit refactors the UI composition logic by extracting it from `MainActivity` into a new, dedicated `RootScreen` composable. This improves the separation of concerns and simplifies `MainActivity`.

Additionally, a bug has been fixed where a replied-to message would incorrectly display the author of the parent message instead of its own author.

Key changes:
- Moved theme setup, permission handling, Long-Poll/Online service management, and navigation graph hosting into the new `RootScreen.kt`.
- `MainActivity` is now significantly simplified, delegating its UI composition to `RootScreen`.
- Corrected the user and group assignment for `replyMessage` in `MessagesRepositoryImpl` to ensure the correct author is displayed.
- Introduced `OnlineFriendsViewModel` to the `FriendsRoute` to separate the logic for online friends.
- Replaced `List` with a custom `ImmutableList` for `photoViewerInfo` state to improve Compose stability.
2025-12-15 22:24:17 +03:00
dependabot[bot] dcbfd43896 Chore(deps): Bump actions/upload-artifact from 5 to 6 (#249)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 21:39:44 +03:00
melod1n 7b2c102470 feat(messages): Implement "message read by" counter
This commit introduces the ability to see how many people have read an outgoing message in a group chat. A "views" count is now displayed in the message options dialog for relevant messages.

- **API & Data Layer:**
  - Added `getMessageReadPeers` to `MessagesService` and `MessagesRepository` to fetch users who have read a specific message.
  - Introduced `MessagesGetReadPeersResponse` to handle the API response.
  - A new URL constant `GET_MESSAGE_READ_PEERS` was added.

- **Domain Layer:**
  - A new `GetMessageReadPeersUseCase` is created to provide the view count to the ViewModel.
  - The use case is registered in the `DomainModule`.

- **ViewModel:**
  - `MessagesHistoryViewModel` now includes `loadMessageReadPeers` to asynchronously fetch and return the view count for a message.

- **UI (Compose):**
  - The `MessageOptionsDialog` now displays a "views" count for outgoing chat messages.
  - It uses a `LaunchedEffect` to call `loadMessageReadPeers` when the dialog is shown.
  - The `visibility` icon has been updated and its XML file renamed to `round_visibility_24px.xml` to follow a consistent naming convention.

- **Refactoring & Minor Fixes:**
  - Simplified several `derivedStateOf` usages to direct calculations or property delegates in `MessageBubble.kt` and `MessagesList.kt` for minor performance improvements.
  - Renamed `IncomingMessageBubble.kt` and `OutgoingMessageBubble.kt` to `MessageBubbleIncoming.kt` and `MessageBubbleOutgoing.kt` respectively for consistency.
  - Removed an unnecessary log statement in `MessagesList.kt`.
2025-12-06 14:37:08 +03:00
melod1n 5310596cf6 Refactor: Improve swipe-to-dismiss and image sharing
This commit refactors the swipe-to-dismiss gesture and the image sharing logic in the photo viewer.

The swipe-to-dismiss animation is now smoother and more reliable, using `Animatable` instead of `animateFloatAsState`. The background dimming effect has also been improved to be more responsive to the drag gesture.

Additionally, the responsibility for creating the share `Intent` has been moved from the composable screen into the `PhotoViewViewModel`, improving the separation of concerns.

Key changes:
- Replaced `animateFloatAsState` with `Animatable` for smoother swipe-to-dismiss animations.
- Improved the alpha calculation for the background during the drag gesture.
- Moved the creation of the share `Intent` into the `PhotoViewViewModel`.
- Simplified the drag-handling logic by removing local state management from the composable.
2025-12-06 09:00:01 +03:00
melod1n 65ff74622a Update README.md 2025-12-06 03:39:25 +03:00
melod1n f48878f003 Refactor: Implement swipe-to-reply and redesign input bar
This commit introduces the ability to reply to a message by swiping it to the right. The message input bar and related components have been redesigned and refactored for a cleaner look and better user experience.

Key changes:
- Added a swipe-to-reply gesture on message bubbles.
- Redesigned the message `InputBar` with updated styling, animations, and rounded corners that adapt to the reply state.
- Renamed `MessagesHistoryInputBar` to a more generic `InputBar`.
- Introduced `FastTextField`, a customized `BasicTextField`, for better performance and control.
- Replaced `IconButton` with `FastIconButton` and `RippledClickContainer` in several places for consistent click handling.
- Refactored `PinnedMessageContainer` and `ReplyContainer` with improved UI.
- Updated the Compose BOM to `2025.12.00`.
2025-12-06 03:35:14 +03:00
melod1n c666bd46f3 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:25:22 +03:00
melod1n 421ca27758 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:24:52 +03:00
melod1n 8231062ca5 Refactor: Move RippledClickContainer to core UI module
Moves the `RippledClickContainer` composable from the `messageshistory` feature module to the `core/ui` module to allow for reuse across different features.

Additionally, this change introduces text truncation with an ellipsis for the title and text within the `ReplyContainer` to prevent long content from breaking the layout.
2025-12-03 06:22:53 +03:00
melod1n 723555f634 feat(messages): Implement reply functionality
This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
2025-12-03 06:12:44 +03:00
melod1n 3e05744a18 Update README.md 2025-12-03 06:07:45 +03:00
melod1n 821ee46cef feat(messages): Implement reply functionality
This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
2025-12-03 06:07:03 +03:00
melod1n dcddddea9b Refactor: Exclude outgoing messages from being marked as read
The "Mark as read" option will no longer be shown for outgoing messages in the message options dialog, as they are implicitly read.
2025-12-03 06:06:37 +03:00
melod1n 018151ad18 add confirmation dialog to chat creation
This commit introduces a confirmation dialog before creating a new chat. The dialog displays the final chat title, which is now dynamically generated based on the user's input or the names of the selected participants.

Key changes:
- Added a confirmation dialog that appears when the user clicks the "create chat" button.
- Implemented logic to generate a provisional chat title from participants' names if no title is explicitly set.
- Refactored `CreateChatViewModel` by removing the interface and simplifying the implementation.
- Added new string resources for the confirmation dialog.
2025-12-02 05:31:09 +03:00
melod1n 6f55251fb7 new file: app/keystore/keystore.jks 2025-12-02 04:10:35 +03:00
melod1n 83772fb3ec remove keystore 2025-12-02 04:10:00 +03:00
melod1n d5ee50a979 update keystore.jks 2025-12-02 04:01:05 +03:00
melod1n 079c4178ec Update keystore.jks 2025-12-02 03:48:37 +03:00
melod1n 21bcacded8 disable cleartext traffic 2025-12-02 03:30:24 +03:00
melod1n 3a272376c1 Refactor: replace material icons with local drawables and bump libs 2025-12-02 02:21:45 +03:00
dependabot[bot] ea6c094b4d Bump actions/upload-artifact from 4 to 5 (#247)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 00:02:14 +03:00
melod1n 91b87f6fa5 fix deprecations 2025-10-09 06:46:25 +03:00
melod1n a5952098a2 new attachment: gift 2025-10-09 06:32:57 +03:00
melod1n 4f1149c8d8 bump libs 2025-10-09 06:20:28 +03:00
melod1n 56bf6ac063 bump libs 2025-10-07 20:12:51 +03:00
melod1n 1b9401673d Update README.md 2025-09-10 10:25:08 +03:00
melod1n db92b261c0 vk.com -> vk.ru 2025-09-10 10:24:53 +03:00
dependabot[bot] 42ad04093d Bump agp from 8.12.2 to 8.13.0 (#231) 2025-09-08 20:31:10 +00:00
dependabot[bot] 9535847bf3 Bump koin from 4.1.0 to 4.1.1 (#232) 2025-09-08 20:31:07 +00:00
dependabot[bot] b3c35ec6f5 Bump com.google.android.material:material from 1.12.0 to 1.13.0 (#233) 2025-09-08 20:31:02 +00:00
dependabot[bot] f5853f61c3 Bump lifecycle from 2.9.2 to 2.9.3 (#228) 2025-08-31 10:23:56 +00:00
dependabot[bot] 620cf53c65 Bump androidx.compose:compose-bom-alpha from 2025.08.00 to 2025.08.01 (#229) 2025-08-31 10:23:51 +00:00
dependabot[bot] 411776b767 Bump agp from 8.12.1 to 8.12.2 (#230) 2025-08-31 10:23:46 +00:00
melod1n 8cb3ed8784 some improvements, new feature 2025-08-27 05:17:40 +03:00
melod1n 4677e484d9 move strings in core/ui module
disable generating android resources everywhere except core/ui module
replace UiR with R
2025-08-27 04:53:46 +03:00
melod1n 799ed820e3 feat: audio message preview 2025-08-26 09:36:55 +03:00
dependabot[bot] 3fd679e65d Bump actions/setup-java from 4 to 5 (#227) 2025-08-23 01:38:06 +00:00
dependabot[bot] 6b95deb7bf Bump agp from 8.12.0 to 8.12.1 (#226) 2025-08-19 21:36:23 +00:00
melod1n 47c1f623f0 Refactor: Introduce video message record mode
This commit refactors the `ActionMode` sealed class into an enum and adds a new `RECORD_VIDEO` state. This allows for distinct actions for recording audio and video messages.

Specifically, the following changes were made:
- Converted `ActionMode` from a sealed class to an enum.
- Added `RECORD_VIDEO` to `ActionMode`.
- Updated `MessagesHistoryInputBar` to:
    - Animate the action button icon change between record modes.
    - Remove the shake animation from emoji, attachment, and mic buttons.
- Updated `MessagesHistoryViewModel` to toggle between `RECORD_AUDIO` and `RECORD_VIDEO` when the action button is clicked in a record mode.
- Added support for displaying `VIDEO_MESSAGE` attachments in `Attachments.kt`, including an animated circular preview.
- Updated `MessageBubble` to render video messages without a background, similar to stickers.
- Added `image` property to `VkVideoMessageDomain` to hold the URL for the video message preview.
- Added a new drawable `rounded_photo_camera_24` for the video record button.
- Updated `VkVideoMessageData` to parse and provide the square preview image URL to the domain model.
2025-08-20 00:31:33 +03:00
melod1n 600aed40e7 Refactor: Make FullScreenDialog dismissible
This commit introduces a dismiss functionality to the `FullScreenDialog`.

The `FullScreenDialog` now accepts an `onDismiss` lambda parameter.
This `onDismiss` lambda is invoked when a dismiss request is made for the dialog.

In `PhotoViewScreen`, the `onDismiss` lambda passed to `FullScreenDialog` is now the `onDismiss` lambda received by the `PhotoViewScreen` itself.
2025-08-19 23:15:37 +03:00
melod1n 22e8a5c09e Prevent PhotoViewer share/copy while loading
This commit prevents the share and copy actions in the PhotoViewer
from being triggered if the content is still loading.
2025-08-19 22:58:13 +03:00
melod1n 252f6ec21e Refactor: Use Dialog for PhotoViewScreen
This commit refactors the PhotoViewScreen to be displayed as a Dialog instead of a separate navigation destination.

Key changes:
- Introduced `PhotoViewDialog` composable that wraps `PhotoViewRoute` in a `FullScreenDialog`.
- Modified `RootScreen` to use `PhotoViewDialog` for displaying images.
- Updated `PhotoViewViewModelImpl` to handle loading state and display a loader while downloading images.
- Made `Loader` and `ContainedLoader` colors configurable.
- Adjusted `PhotoViewScreen` UI:
    - Set background to translucent black.
    - Updated TopAppBar background color and icon tints.
    - Improved vertical drag gesture for dismissing the viewer.
- Made `VkUserData.LastSeen.platform` nullable.
- Removed unused navigation functions related to the old PhotoViewScreen.
2025-08-19 22:54:38 +03:00
melod1n 7e25bc3a8d Update target and compile SDK to 36
Bumps various dependencies:
- Haze from 1.6.9 to 1.6.10
- Kotlin from 2.2.0 to 2.2.10
- KSP from 2.2.0-2.0.2 to 2.2.10-2.0.2
- Compose BOM from 2025.07.01 to 2025.08.00
- Core KTX from 1.16.0 to 1.17.0
- Nanokt from 1.2.0 to 1.3.0
2025-08-17 07:19:39 +03:00
dependabot[bot] 7b0f6fe2a6 Bump actions/checkout from 4 to 5 (#220) 2025-08-17 04:07:36 +00:00
dependabot[bot] 6d5b09ef81 Bump haze from 1.6.8 to 1.6.9 (#218) 2025-08-08 22:14:19 +00:00
dependabot[bot] 268e0a8beb Bump agp from 8.11.1 to 8.12.0 (#219) 2025-08-08 22:14:15 +00:00
dependabot[bot] 9e67ad0834 Bump lifecycle from 2.9.1 to 2.9.2 (#213) 2025-07-31 19:49:49 +00:00
dependabot[bot] 34ea8ef944 Bump chucker from 4.1.0 to 4.2.0 (#211) 2025-07-31 19:49:34 +00:00
dependabot[bot] 3d0c310575 Bump haze from 1.6.7 to 1.6.8 (#212) 2025-07-31 19:49:28 +00:00
dependabot[bot] 602cf8f18b Bump androidx.compose:compose-bom-alpha from 2025.06.02 to 2025.07.01 (#217) 2025-07-31 19:49:24 +00:00
dependabot[bot] 1b3581fcfd Bump androidx.navigation:navigation-compose from 2.9.1 to 2.9.3 (#216) 2025-07-31 19:49:19 +00:00
dependabot[bot] 4b254d7d41 Bump agp from 8.11.0 to 8.11.1 (#210) 2025-07-14 09:27:25 +00:00
dependabot[bot] d98dca83f1 Bump haze from 1.6.6 to 1.6.7 (#209) 2025-07-09 17:54:25 +00:00
melod1n 9e6b079bf6 ability to import/export auth data and some refactoring 2025-07-09 17:29:51 +03:00
dependabot[bot] d2aaac68e2 Bump org.jetbrains.kotlinx:kotlinx-serialization-json (#202)
Bumps [org.jetbrains.kotlinx:kotlinx-serialization-json](https://github.com/Kotlin/kotlinx.serialization) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/Kotlin/kotlinx.serialization/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.serialization/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kotlin/kotlinx.serialization/compare/v1.8.1...v1.9.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-serialization-json
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 13:49:55 +03:00
dependabot[bot] ada9c13947 Bump androidx.navigation:navigation-compose from 2.9.0 to 2.9.1 (#206) 2025-07-09 10:48:11 +00:00
dependabot[bot] cd4c1d6d76 Bump haze from 1.6.4 to 1.6.6 (#203) 2025-07-09 08:43:00 +00:00
dependabot[bot] fab92d50d6 Bump androidx.compose:compose-bom-alpha from 2025.06.01 to 2025.06.02 (#205) 2025-07-09 08:42:52 +00:00
dependabot[bot] 9e4521c3a0 Bump com.squareup.okhttp3:logging-interceptor (#208) 2025-07-09 08:42:41 +00:00
melod1n 70b552412c Enhance PhotoViewer with share and open-in actions, improve reply UI
This commit introduces "Share" and "Open in..." actions to the `PhotoViewScreen`, allowing users to share images via other apps or open them in external viewers.

**Key changes:**

- **PhotoViewer:**
    - Added "Share" and "Open in..." options to the `PhotoViewScreen` dropdown menu.
    - `PhotoViewViewModel`:
        - Implemented `onShareClicked()` and `onOpenInClicked()` to handle these new actions.
        - Added `shareRequest` StateFlow to manage image sharing intents.
        - Introduced `downloadAndStoreImageToCache()` to download and cache images for sharing.
        - `onImageShared()` resets `shareRequest` after sharing.
    - Updated `TopBar` to include the new menu items.
    - Added string resources for "Open in…" and "Share".
- **Reply UI:**
    - `Reply.kt`: Title and summary text now use `TextOverflow.Ellipsis` to prevent long text from breaking the layout.
- **API Model:**
    - `MessagesResponse.kt`: Added `MessagesMarkAsImportantResponse` data class to handle the response for marking messages as important.
- **Data Layer:**
    - `MessagesRepositoryImpl`: Updated `markAsImportant` to correctly map the API response using `MessagesMarkAsImportantResponse`.
- **Minor:**
    - `README.md`: Updated feature checklist for external viewer.
    - `ApplicationModule.kt`: Added experimental Coil API opt-in.
2025-06-26 20:46:53 +03:00
dependabot[bot] a7307e7862 Bump agp from 8.10.1 to 8.11.0 (#197) 2025-06-26 16:54:50 +00:00
melod1n 60a30b9422 feat: Display stickers in messages (#200)
This commit introduces the ability to display stickers within message bubbles.

Key changes:
- `Attachments.kt`: Added handling for `AttachmentType.STICKER`. If an attachment type is unsupported, a placeholder text is now displayed.
- `Sticker.kt`: New composable created to render `VkStickerDomain` using `AsyncImage`.
- `MessageBubble.kt`:
    - Adjusted background alpha for sticker messages to make the bubble transparent.
    - Minor refactoring of `minDateContainerWidth` and `dateContainerWidth` initialization.
- `VkStickerDomain.kt`: Added `getUrl()` function to construct sticker image URLs, with options for specifying width and background.
2025-06-26 19:54:33 +03:00
melod1n 93d81f1e9e Refactor: Rename addTextContextMenuComponents to appendTextContextMenuComponents
This commit also updates the Compose BOM version to `2025.06.01`.
2025-06-25 09:36:00 +03:00
melod1n 5be101deec Refactor: Move versioning to build files, update dependencies
This commit moves the `minSdk`, `targetSdk`, `compileSdk`, `versionCode`, and `versionName` definitions from `gradle/libs.versions.toml` directly into the relevant build files (`app/build.gradle.kts` and convention plugins).

Additionally, the following dependencies were updated:
- Gradle wrapper from 8.12 to 8.14.2
- KSP from 2.1.21-2.0.2 to 2.2.0-2.0.2
- ModuleGraph from 2.8.0 to 2.9.0

The `fast-android-test` and `fast-jvm-library` plugin aliases were also commented out in `gradle/libs.versions.toml`.
2025-06-25 09:33:26 +03:00
melod1n 76dd1e2ce7 Reply attachment (#195) 2025-06-25 09:04:50 +03:00
dependabot[bot] 56683bea96 Bump kotlin from 2.1.21 to 2.2.0 (#194) 2025-06-24 11:46:37 +00:00
melod1n 3dae1fe101 Refactor: Introduce FullScreenContainedLoader and use rememberUpdatedState
This commit introduces `FullScreenContainedLoader` and replaces usages of `FullScreenLoader` where appropriate.

It also updates several composables to use `rememberUpdatedState` for lambda parameters to ensure the latest versions are used.

Additionally, the following changes are included:
- Add a setting to show/hide the attachment button in the chat input bar.
- Implement navigation to `PhotoViewScreen` when a photo attachment is clicked in a message.
- Add "Copy link" and "Copy image" actions to `PhotoViewScreen`.
- Remove unused settings and their corresponding logic from `SettingsViewModel` and `UserSettings`.
2025-06-24 14:44:51 +03:00
melod1n c1e76e1c60 improvements 2025-06-23 20:49:14 +03:00
melod1n c14ee45d53 improvements 2025-06-23 19:50:28 +03:00
melod1n 9d4e3f50ea split MessagesHistoryScreen.kt in separated composables.
Added "regular" to text field in messages history screen - for clearing formatting
2025-06-23 17:36:02 +03:00
melod1n 17b5c944ac m3 expressive theme and full screen loader
revert pre-loading non-main screens
2025-06-20 21:55:17 +03:00
melod1n 5aa1f21183 switch to compose-bom-alpha 2025-06-20 21:25:17 +03:00
dependabot[bot] a916dc649c Bump room from 2.7.1 to 2.7.2 (#191) 2025-06-20 16:09:23 +00:00
dependabot[bot] 7e0b9d49ba Bump androidx.compose:compose-bom from 2025.06.00 to 2025.06.01 (#192) 2025-06-20 16:09:18 +00:00
dependabot[bot] d525a1573c Bump koin from 4.0.4 to 4.1.0 (#189) 2025-06-16 22:36:35 +00:00
dependabot[bot] 091a88dd45 Bump ksp from 2.1.21-2.0.1 to 2.1.21-2.0.2 (#190) 2025-06-16 22:36:31 +00:00
melod1n a3a970115a Bump deps 2025-06-09 13:29:36 +03:00
dependabot[bot] e36c1ea0ca Bump haze from 1.6.2 to 1.6.3 (#185) 2025-06-03 00:10:25 +00:00
dependabot[bot] f2437f67cb Bump com.squareup.okhttp3:logging-interceptor (#184) 2025-05-30 08:10:27 +00:00
dependabot[bot] 049bcfd2da Bump agp from 8.10.0 to 8.10.1 (#183) 2025-05-30 08:10:21 +00:00
dependabot[bot] d5e24214ce Bump haze from 1.6.1 to 1.6.2 (#182) 2025-05-21 18:48:06 +00:00
dependabot[bot] c94e128e64 Bump androidx.compose:compose-bom from 2025.05.00 to 2025.05.01 (#181) 2025-05-20 21:24:10 +00:00
dependabot[bot] 0836816391 Bump haze from 1.6.0 to 1.6.1 (#180) 2025-05-19 18:27:08 +00:00
melod1n be58949372 Update build.yml 2025-05-16 05:09:53 +03:00
melod1n 618388a719 update workflow to upload apks with build time in name 2025-05-16 05:09:04 +03:00
melod1n 7369fd5c70 Bump retrofit and converter-moshi from 2.11.0 to 3.0.0 2025-05-16 01:21:07 +03:00
dependabot[bot] b668e86622 Bump ksp from 2.1.20-2.0.1 to 2.1.21-2.0.1 (#177) 2025-05-15 22:08:57 +00:00
melod1n aa4326f09e chore: update readme 2025-05-13 23:30:04 +03:00
dependabot[bot] 0a8d26a8f0 Bump kotlin from 2.1.20 to 2.1.21 (#175) 2025-05-13 20:10:14 +00:00
dependabot[bot] 08d20f7a0f Bump com.google.accompanist:accompanist-permissions (#174) 2025-05-13 19:53:51 +00:00
melod1n 325211ad5f refactor: improve auth screen and bump haze version
* Bump haze version to 1.6.0.
* Blur now works on android 11 and older
* Add "Sign up" and "Forgot password?" links to the auth screen.
* Add logic to toggle dynamic colors on logo click in the auth screen (Android 12+).
2025-05-13 22:52:53 +03:00
melod1n b63cc86e48 pre-warm up the main screens when the application is launched 2025-05-11 22:36:41 +03:00
melod1n 628b93e4ab Bump project version (#173) 2025-05-11 21:52:37 +03:00
melod1n 7e696ec8c8 chore: adjust github workflows 2025-05-11 21:33:37 +03:00
melod1n b184d98670 Bump project versionName (#166) 2025-05-11 21:19:49 +03:00
melod1n 43539139e8 Simple attachments in messages history (#164)
* new attachments in messages history - photo, video, audio, file, link
* improve attachments in messages history and adjusted font size for logo's text in auth screen
* audio duration, file preview and url preview are now visible in attachments in messages history screen
* make MessageBubble width adapt to attachments container width
* topbar back icon crossfade animation
* implement rich text for message input
* handle click and long click on attachments
* added click and long click handlers for attachments in message bubbles
* enabled opening photos, files, and links when clicked.
* implemented basic long-click logging for photos.
* handled back press to return to Conversations from other tabs.
* corrected the logic for filtering and selecting video images.
* updated string resources for attachments, including a new "Clip" string.
* make MessageBubble mention text underline on out messages
2025-05-10 03:10:07 +03:00
melod1n f45a106ed8 Update build.yml 2025-05-09 05:11:57 +03:00
melod1n 8b31e88caf shorten texts in PinnedMessageContainer 2025-04-11 06:43:00 +03:00
dependabot[bot] 4cb34327cf Bump androidx.compose:compose-bom from 2025.03.01 to 2025.04.00 (#156) 2025-04-11 01:55:17 +00:00
dependabot[bot] c7b414b9f0 Bump room from 2.6.1 to 2.7.0 (#154) 2025-04-11 01:55:14 +00:00
dependabot[bot] 9a296c8b84 Bump coroutines from 1.10.1 to 1.10.2 (#153) 2025-04-11 01:55:08 +00:00
melod1n ca569354fb Bump app version 2025-04-04 21:51:41 +03:00
melod1n 89748b72ed Update API version (#147)
* Bump VK Api version to 5.238
* Implemented new authorization flow (at the moment, without auto re-requesting token)
* Add support for sticker pack preview attachments
* Bump LongPoll to version 19
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Archive screen with full functionality
* Recomposition fixes
* Markdown support for messages bubbles
* Adjust app name font size based on screen width
* Navigation related improvements
* Add logout functionality
2025-04-04 20:43:59 +03:00
dependabot[bot] add67b6f8d Bump org.jetbrains.kotlinx:kotlinx-serialization-json (#149) 2025-04-01 17:46:12 +00:00
dependabot[bot] 25e7e39ed0 Bump koin from 4.0.3 to 4.0.4 (#148) 2025-04-01 17:45:15 +00:00
melod1n 935d313257 improve long polling service reliability 2025-03-30 19:54:12 +03:00
melod1n 5b5e8f8446 Implement scroll to top in friends and conversations screens 2025-03-29 22:31:48 +03:00
melod1n f1892670da more blur 2025-03-29 22:10:47 +03:00
melod1n d46c72f7e6 improve login screen UI and logic & fixes for blur 2025-03-29 22:03:37 +03:00
melod1n 157c0c71fe Update README.md 2025-03-29 03:02:00 +03:00
melod1n 988da07852 Fix deleting unsent messages and disable "for everyone" delete option 2025-03-29 03:00:50 +03:00
melod1n f02822a011 a shit ton features, improvements and fixes in messages history screen and others 2025-03-29 02:51:49 +03:00
melod1n da9644cde1 Add theme option to disable animations and fix account avatar loading in bottom bar 2025-03-28 19:59:38 +03:00
melod1n 9aa85d40c6 pinned message in messages history draft 2025-03-27 12:16:26 +03:00
melod1n f66123ba94 some fixes for pinned message 2025-03-27 04:54:30 +03:00
melod1n b80babed9c draft pinned message and fixes 2025-03-27 04:32:11 +03:00
melod1n 85c5a10891 update README.md 2025-03-27 03:51:07 +03:00
melod1n 51356aa4dd chat materials pagination and ui improvements 2025-03-27 03:50:39 +03:00
melod1n 807c23926e reworked chat materials screen and some fixes 2025-03-27 02:27:19 +03:00
melod1n 37a654790c Bump compose-bom from 2025.03.00 to 2025.03.01 2025-03-26 22:10:44 +03:00
dependabot[bot] a36060654d Bump koin from 4.0.2 to 4.0.3 (#145) 2025-03-26 19:08:57 +00:00
dependabot[bot] da5fa8d77a Bump ksp from 2.1.20-1.0.31 to 2.1.20-1.0.32 (#146) 2025-03-26 19:08:52 +00:00
melod1n 4d18c86f04 feat(messages): add message selection and actions 2025-03-26 22:06:55 +03:00
melod1n 296c3ce7a0 update README.md 2025-03-26 01:31:03 +03:00
melod1n 0ae05709db feat: Add ordering functionality for friends list 2025-03-26 01:28:50 +03:00
dependabot[bot] 3dbf2bd8a4 Bump com.google.guava:guava from 33.4.5-jre to 33.4.6-jre (#143) 2025-03-25 19:44:09 +00:00
dependabot[bot] 3b02e2ff61 Bump agp from 8.9.0 to 8.9.1 (#141) 2025-03-25 08:22:25 +00:00
dependabot[bot] b21675d6f2 Bump haze from 1.5.1 to 1.5.2 (#142) 2025-03-25 08:22:11 +00:00
melod1n 9e301af076 update gh actions' jdk 2025-03-23 20:54:07 +03:00
melod1n 3fdb574971 some updates 2025-03-23 20:51:15 +03:00
melod1n ad6e413bbb Update README.md 2025-03-23 20:00:13 +03:00
melod1n 8dc47c3fa5 separated screens for friends tab 2025-03-23 19:53:58 +03:00
melod1n 0eb3146428 Release 0.1.9 (#140)
* improvements in longpoll's stuff
2025-03-23 17:55:28 +03:00
melod1n b2879d8756 Release 0.1.8 (#139)
* pagination in chat fixed
* other fixes and improvements

* fixed visual bug in progress bar in chat history

* Refactor: Enhance conversations and friends features

-   In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists.
-   In `MainGraph`, added `onMessageClicked` for navigation to message history.
-   In `ApiEvent`, introduced `parseOrNull` for handling unknown event types.
-   In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling.
-   In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`.
-   In `FriendItem`, added an icon button to initiate sending a message to a friend.
-   In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists.
-   In `RootScreen`, added `onMessageClicked` for navigating to messages.
-   In `FriendsList`, added `onMessageClicked` for handling message clicks.
-   In `MainScreen`, removed unused `MutableSharedFlow`.
-   In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`.
-   In `FriendsNavigation`, added `onMessageClicked` for handling message clicks.
-   In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter.
-   In `ErrorView`, added text alignment.
-   In `NoItemsView`, added support for a button and custom text.
-   In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`.

* Chat creation feature (#138)

* - read indicator, edit status and time for message in messages history

* message sending status
2025-03-23 09:22:41 +03:00
526 changed files with 20712 additions and 9369 deletions
+36 -22
View File
@@ -1,10 +1,10 @@
name: Android CI Build
on:
push:
branches: [ "dev", "release/*", "hotfix/*" ]
pull_request:
branches: [ "dev", "release/*", "hotfix/*" ]
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -12,37 +12,51 @@ env:
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
build_apk_aab:
build_apks:
runs-on: ubuntu-24.04
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: set up JDK 17
uses: actions/setup-java@v4
- name: set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v4
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+11 -26
View File
@@ -1,8 +1,11 @@
name: Android CI Release
permissions:
contents: read
on:
pull_request:
branches: [ "master" ]
workflow_dispatch:
push:
branches: [ "release/*"]
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -15,50 +18,32 @@ jobs:
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: set up JDK 17
uses: actions/setup-java@v4
- name: set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Build and sign debug Bundle
run: ./gradlew bundleDebug
- name: Upload debug Bundle
uses: actions/upload-artifact@v4
with:
name: app-debug.aab
path: app/build/outputs/bundle/debug/app-debug.aab
- name: Build and sign release Bundle
run: ./gradlew bundleRelease
- name: Upload release Bundle
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab
+34 -22
View File
@@ -7,52 +7,64 @@ Unofficial messenger for russian social network VKontakte
- [x] 2FA support
- [x] Resend otp
- [x] Captcha support
- [ ] Support for new authorization with service and refresh tokens
- [x] Support for new authorization with service and refresh tokens
- [ ] Handle token expiration
- [x] Ability to export/import tokens
- [x] Conversations list
- [x] Pagination
- [x] Manual refresh
- [x] Pin & unpin conversations
- [x] Delete conversations
- [ ] Archive
- [ ] View archived conversations
- [ ] Archive & unarchive conversations
- [x] Archive
- [x] View archived conversations
- [x] Archive & unarchive conversations
- [x] Friends list
- [ ] Sort alphabetically, by priority or random
- [ ] Separate tab with only friends who are online
- [x] Sort alphabetically, by priority or random
- [x] Separate tab with only friends who are online
- [x] Settings screen
- [ ] TODO
- [x] Chat screen
- [ ] Pagination
- [x] Pagination
- [x] Manual refresh
- [x] Message bubbles
- [x] Text
- [ ] Date
- [ ] Message's attachments
- [ ] Photo
- [ ] Video
- [ ] Audio
- [ ] File
- [ ] Link
- [x] Date
- [x] Read status
- [x] Edit status
- [x] Sending status
- [x] Message's attachments
- [x] Photo
- [x] Video
- [x] Audio
- [x] File
- [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] TODO
- [x] Send messages
- [ ] Pinned message
- [ ] Pin & unpin messages
- [ ] Reply to message
- [ ] Delete message
- [ ] Select multiple messages
- [ ] Delete
- [x] Pinned message
- [x] Pin & unpin messages
- [x] Reply to message
- [x] Swipe to reply to message
- [x] Delete message
- [x] Select multiple messages
- [x] Delete
- [ ] Forward
- [ ] Forward in current chat
- [ ] Send attachments to chat
- [ ] TODO
- [x] Chat materials (attachments)
- [x] Separate tabs for each attachment type
- [ ] Pagination
- [x] Pagination
- [x] Manual refresh
- [x] View attachments
- [x] Open photo
- [x] Internal viewer
- [ ] External viewer
- [x] External viewer
- [ ] Open video in external player
- [ ] TODO
- [ ] Caching
+20 -3
View File
@@ -1,3 +1,4 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties
plugins {
@@ -12,8 +13,8 @@ android {
defaultConfig {
applicationId = "dev.meloda.fastvk"
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
versionCode = 11
versionName = "0.2.3"
}
signingConfigs {
@@ -58,6 +59,18 @@ android {
}
}
// applicationVariants.all {
// outputs.all {
// val date = System.currentTimeMillis() / 1000
// val buildType = buildType.name
// val appVersion = versionName
// val appVersionCode = versionCode
//
// val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
// (this as? BaseVariantOutputImpl)?.outputFileName = newApkName
// }
// }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -66,10 +79,13 @@ android {
}
dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
implementation(projects.feature.conversations)
implementation(projects.feature.convos)
implementation(projects.feature.languagepicker)
implementation(projects.feature.messageshistory)
implementation(projects.feature.photoviewer)
@@ -77,6 +93,7 @@ dependencies {
implementation(projects.feature.friends)
implementation(projects.feature.profile)
implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.common)
implementation(projects.core.ui)
Binary file not shown.
@@ -24,6 +24,7 @@ import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.settings.navigation.Settings
import kotlinx.coroutines.Dispatchers
@@ -36,14 +37,13 @@ interface MainViewModel {
val startDestination: StateFlow<Any?>
val isNeedToReplaceWithAuth: StateFlow<Boolean>
val currentUser: StateFlow<VkUser?>
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
val isNeedToCheckNotificationsPermission: StateFlow<Boolean>
val isNeedToRequestNotifications: StateFlow<Boolean>
val profileImageUrl: StateFlow<String?>
fun onError(error: BaseError)
fun onNavigatedToAuth()
@@ -59,6 +59,8 @@ interface MainViewModel {
fun onNotificationsDeniedDialogDismissed()
fun onNotificationsRationaleDialogDismissed()
fun onNotificationsRationaleDialogCancelClicked()
fun onUserAuthenticated()
}
class MainViewModelImpl(
@@ -70,24 +72,24 @@ class MainViewModelImpl(
override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val currentUser = MutableStateFlow<VkUser?>(null)
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
override val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
override val isNeedToRequestNotifications = MutableStateFlow(false)
override val profileImageUrl = MutableStateFlow<String?>(null)
private var openNotificationsSettings = false
private var openAppSettings = false
override fun onError(error: BaseError) {
when (error) {
BaseError.SessionExpired -> {
BaseError.SessionExpired,
BaseError.AccountBlocked -> {
isNeedToReplaceWithAuth.update { true }
}
is BaseError.SimpleError -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui
else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui
}
}
@@ -170,17 +172,20 @@ class MainViewModelImpl(
disableBackgroundLongPoll()
}
override fun onUserAuthenticated() {
loadProfile()
}
private fun loadProfile() {
loadUserByIdUseCase(userId = null)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
profileImageUrl.emit(null)
currentUser.emit(null)
},
success = { response ->
val user = response ?: return@listenValue
profileImageUrl.emit(user.photo100)
currentUser.emit(user)
}
)
}
@@ -4,8 +4,14 @@ import android.app.Application
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@@ -18,10 +24,14 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin()
initAcra()
}
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() {
startKoin {
androidLogger()
@@ -30,5 +40,21 @@ class AppGlobal : Application(), ImageLoaderFactory {
}
}
override fun newImageLoader(): ImageLoader = get()
private fun initAcra() {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
mailSender {
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
}
dialog {
text = "App crashed"
enabled = true
}
}
}
}
@@ -5,17 +5,17 @@ import android.content.res.Resources
import android.os.PowerManager
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.auth.authModule
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl
import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.convos.di.convosModule
import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -32,13 +32,12 @@ import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind
import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module {
includes(domainModule)
includes(
loginModule,
validationModule,
captchaModule,
conversationsModule,
authModule,
convosModule,
settingsModule,
messagesHistoryModule,
photoViewModule,
@@ -46,10 +45,10 @@ val applicationModule = module {
longPollModule,
friendsModule,
profileModule,
chatMaterialsModule
chatMaterialsModule,
createChatModule
)
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
singleOf(PreferenceManager::getDefaultSharedPreferences)
single<Resources> { androidContext().resources }
@@ -65,6 +64,7 @@ val applicationModule = module {
ImageLoader.Builder(get())
.crossfade(true)
.build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
}
singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -2,16 +2,15 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R as UiR
@Serializable
object MainGraph
@@ -22,27 +21,28 @@ object Main
fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: MainViewModel
onMessageClicked: (userid: Long) -> Unit,
onNavigateToCreateChat: () -> Unit
) {
val navigationItems = ImmutableList.of(
BottomNavigationItem(
titleResId = UiR.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24,
titleResId = R.string.title_friends,
selectedIconResId = R.drawable.ic_group_fill_round_24,
unselectedIconResId = R.drawable.ic_group_round_24,
route = Friends,
),
BottomNavigationItem(
titleResId = UiR.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24,
route = Conversations
titleResId = R.string.title_convos,
selectedIconResId = R.drawable.ic_mail_fill_round_24,
unselectedIconResId = R.drawable.ic_mail_round_24,
route = ConvoGraph
),
BottomNavigationItem(
titleResId = UiR.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24,
titleResId = R.string.title_profile,
selectedIconResId = R.drawable.ic_account_circle_fill_round_24,
unselectedIconResId = R.drawable.ic_account_circle_round_24,
route = Profile
)
)
@@ -52,9 +52,10 @@ fun NavGraphBuilder.mainScreen(
navigationItems = navigationItems,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onPhotoClicked = onPhotoClicked,
viewModel = viewModel
onMessageClicked = onMessageClicked,
onNavigateToCreateChat = onNavigateToCreateChat
)
}
}
@@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
@@ -15,42 +14,20 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import dev.meloda.fast.ui.R
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
class MainActivity : AppCompatActivity() {
@@ -87,169 +64,33 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
KoinContext {
val context = LocalContext.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
}
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
RootScreen(
toggleLongPollService = { enable, inBackground ->
toggleLongPollService(
enable = true,
inBackground = true
enable = enable,
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LaunchedEffect(longPollStateToApply) {
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
},
toggleOnlineService = ::toggleOnlineService
)
}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
mutableStateOf(
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont
)
)
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
}
}
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
val noCategoryName = getString(R.string.notification_channel_no_category_name)
val noCategoryDescriptionText =
getString(UiR.string.notification_channel_no_category_description)
getString(R.string.notification_channel_no_category_description)
val noCategoryChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
@@ -259,9 +100,9 @@ class MainActivity : AppCompatActivity() {
description = noCategoryDescriptionText
}
val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
val longPollName = getString(R.string.notification_channel_long_polling_service_name)
val longPollDescriptionText =
getString(UiR.string.notification_channel_long_polling_service_description)
getString(R.string.notification_channel_long_polling_service_description)
val longPollChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
@@ -272,7 +113,7 @@ class MainActivity : AppCompatActivity() {
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels(
listOf(
@@ -292,12 +133,19 @@ class MainActivity : AppCompatActivity() {
}
}
private val longPollingServiceIntent by lazy {
Intent(this, LongPollingService::class.java)
}
private val onlineServiceIntent by lazy {
Intent(this, OnlineService::class.java)
}
private fun toggleLongPollService(
enable: Boolean,
inBackground: Boolean = AppSettings.Experimental.longPollInBackground
) {
if (enable) {
val longPollIntent = Intent(this, LongPollingService::class.java)
val longPollIntent = longPollingServiceIntent
if (inBackground) {
ContextCompat.startForegroundService(this, longPollIntent)
@@ -305,15 +153,15 @@ class MainActivity : AppCompatActivity() {
startService(longPollIntent)
}
} else {
stopService(Intent(this, LongPollingService::class.java))
stopService(longPollingServiceIntent)
}
}
private fun toggleOnlineService(enable: Boolean) {
if (enable) {
startService(Intent(this, OnlineService::class.java))
startService(onlineServiceIntent)
} else {
stopService(Intent(this, OnlineService::class.java))
stopService(onlineServiceIntent)
}
}
@@ -1,12 +1,14 @@
package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
@@ -25,19 +27,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import coil.compose.SubcomposeAsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.conversations.navigation.conversationsScreen
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem
@@ -45,10 +48,11 @@ import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.profileScreen
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
@@ -56,44 +60,52 @@ fun MainScreen(
navigationItems: ImmutableList<BottomNavigationItem>,
onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onNavigateToMessagesHistory: (convoId: Long) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
viewModel: MainViewModel
onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val hazeState = remember { HazeState() }
val activity = LocalActivity.current as? AppCompatActivity ?: return
val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) }
val navController = rememberNavController()
val profileImageUrl by viewModel.profileImageUrl.collectAsStateWithLifecycle()
var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1)
}
val sharedFlow = remember {
MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
BackHandler(enabled = selectedItemIndex != 1) {
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1
navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
}
val profileImageUrl = LocalUser.current?.photo100
var tabReselected by remember {
mutableStateOf(navigationItems.associate { it.route to false })
}
Scaffold(
bottomBar = {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
)
} else Modifier
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.enableBlur) 0f else 1f
)
),
containerColor = if (theme.enableBlur) Color.Transparent
else NavigationBarDefaults.containerColor
) {
navigationItems.forEachIndexed { index, item ->
NavigationBarItem(
@@ -109,7 +121,9 @@ fun MainScreen(
}
}
} else {
sharedFlow.tryEmit(index)
tabReselected = tabReselected.toMutableMap().also {
it[navigationItems[index].route] = true
}
}
},
icon = {
@@ -133,9 +147,7 @@ fun MainScreen(
.size(24.dp)
.clip(CircleShape)
.alpha(if (isLoading) 0f else 1f),
onSuccess = {
isLoading = false
}
onSuccess = { isLoading = false }
)
} else {
Icon(
@@ -156,11 +168,12 @@ fun MainScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (currentTheme.enableBlur) 0.dp else padding.calculateBottomPadding())
) {
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalBottomPadding provides if (currentTheme.enableBlur) padding.calculateBottomPadding() else 0.dp
LocalBottomPadding provides padding.calculateBottomPadding(),
LocalReselectedTab provides tabReselected,
LocalNavController provides navController
) {
NavHost(
navController = navController,
@@ -174,22 +187,32 @@ fun MainScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
friendsScreen(
activity = activity,
onError = onError,
navController = navController,
onPhotoClicked = onPhotoClicked
)
conversationsScreen(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
scrollToTopFlow = sharedFlow,
navController = navController,
onMessageClicked = onMessageClicked,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[Friends] = false
}
},
)
convosGraph(
activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false
}
}
)
profileScreen(
activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked,
navController = navController
onPhotoClicked = onPhotoClicked
)
}
}
@@ -1,47 +1,243 @@
package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.convos.navigation.createChatScreen
import dev.meloda.fast.convos.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.navigation.navigateToPhotoView
import dev.meloda.fast.photoviewer.navigation.photoViewScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RootScreen(
navController: NavHostController = rememberNavController(),
viewModel: MainViewModel
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit
) {
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false, null)
}
toggleLongPollService(true, true)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false, null)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
longPollStateToApply.isLaunched(),
longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
val navController: NavHostController = rememberNavController()
val activity = LocalActivity.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
@@ -109,6 +305,18 @@ fun RootScreen(
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
@@ -116,35 +324,84 @@ fun RootScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = navController::navigateToMain,
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
viewModel = viewModel
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat,
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onChatMaterialsDropdownItemClicked = navController::navigateToChatMaterials
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
onPhotoClicked = { url ->
photoViewerInfo = listOf(url) to null
}
)
createChatScreen(
onChatCreated = { convoId ->
navController.popBackStack()
navController.navigateToMessagesHistory(convoId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker
onLanguageItemClicked = navController::navigateToLanguagePicker,
onRestartRequired = {
activity?.let {
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
activity.finish()
}
}
)
languagePickerScreen(onBack = navController::navigateUp)
}
photoViewScreen(onBack = navController::navigateUp)
PhotoViewDialog(
photoViewerInfo = photoViewerInfo?.let { info ->
info.first.toImmutableList() to info.second
},
onDismiss = { photoViewerInfo = null }
)
CaptchaScreen(
captchaRedirectUri = captchaRedirectUri,
onBack = {
AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled)
},
onResult = { result ->
AppSettings.setCaptchaResult(
CaptchaTokenResult.Success(result)
)
},
)
}
}
}
}
}
}
@@ -16,11 +16,11 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R
@@ -30,11 +30,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() {
@@ -42,15 +44,9 @@ class LongPollingService : Service() {
private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e(TAG, "error: $throwable")
if (throwable !is NoAccessTokenException) {
throwable.printStackTrace()
}
longPollController.updateCurrentState(LongPollState.Exception)
longPollController.setStateToApply(LongPollState.Exception)
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
handleError(throwable)
}
private val coroutineContext: CoroutineContext
@@ -63,6 +59,8 @@ class LongPollingService : Service() {
private var currentJob: Job? = null
private val inBackground get() = AppSettings.Experimental.longPollInBackground
override fun onCreate() {
super.onCreate()
Log.d(STATE_TAG, "onCreate()")
@@ -76,21 +74,12 @@ class LongPollingService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY
val inBackground = AppSettings.Experimental.longPollInBackground
Log.d(
STATE_TAG,
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this"
)
if (currentJob != null) {
currentJob?.cancel()
currentJob = null
}
coroutineScope.launch {
currentJob = startPolling().also { it.join() }
}
startJob()
val openCategorySettingsIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
@@ -108,11 +97,6 @@ class LongPollingService : Service() {
PendingIntent.FLAG_IMMUTABLE
)
longPollController.updateCurrentState(
if (inBackground) LongPollState.Background
else LongPollState.InApp
)
if (inBackground) {
val notification =
NotificationsUtils.createNotification(
@@ -134,17 +118,33 @@ class LongPollingService : Service() {
return START_STICKY
}
private fun startJob() {
if (currentJob != null) {
currentJob?.cancel()
currentJob = null
}
coroutineScope.launch {
currentJob = startPolling().also { it.join() }
}
}
private fun startPolling(): Job {
if (job.isCompleted || job.isCancelled) {
Log.d(STATE_TAG, "job is completed or cancelled")
Log.d(STATE_TAG, "Job is completed or cancelled")
throw Exception("Job is over")
}
Log.d(STATE_TAG, "job started")
Log.d(STATE_TAG, "Starting job...")
return coroutineScope.launch(coroutineContext) {
longPollController.updateCurrentState(
if (inBackground) LongPollState.Background
else LongPollState.InApp
)
return coroutineScope.launch {
if (UserConfig.accessToken.isEmpty()) {
throw NoAccessTokenException
throw NoAccessTokenException()
}
var serverInfo = getServerInfo()
@@ -204,7 +204,7 @@ class LongPollingService : Service() {
}
}
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
longPollUseCase.getLongPollServer(
needPts = true,
version = VkConstants.LP_VERSION
@@ -224,7 +224,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse(
server: VkLongPollData
): LongPollUpdates? = suspendCoroutine {
): LongPollUpdates? = suspendCancellableCoroutine {
longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}",
key = server.key,
@@ -246,6 +246,21 @@ class LongPollingService : Service() {
}
}
private fun handleError(throwable: Throwable) {
Log.e(TAG, "error: $throwable")
if (throwable !is NoAccessTokenException) {
throwable.printStackTrace()
}
coroutineScope.launch {
delay(5.seconds)
startJob()
}
longPollController.updateCurrentState(LongPollState.Exception)
}
override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy")
longPollController.updateCurrentState(LongPollState.Stopped)
@@ -259,8 +274,7 @@ class LongPollingService : Service() {
}
override fun onTrimMemory(level: Int) {
Log.d(STATE_TAG, "onTrimMemory")
longPollController.updateCurrentState(LongPollState.Stopped)
Log.d(STATE_TAG, "onTrimMemory. Level: $level")
super.onTrimMemory(level)
}
@@ -276,4 +290,4 @@ class LongPollingService : Service() {
}
private data class LongPollException(override val message: String) : Throwable()
private data object NoAccessTokenException : Throwable()
private class NoAccessTokenException : Throwable()
@@ -6,7 +6,7 @@ import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.ui.R as UiR
import dev.meloda.fast.ui.R
object NotificationsUtils {
@@ -28,7 +28,7 @@ object NotificationsUtils {
actions: List<NotificationCompat.Action> = emptyList(),
): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(UiR.drawable.ic_fast_logo)
.setSmallIcon(R.drawable.ic_fast_logo)
.setContentTitle(title)
.setPriority(priority.value)
.setContentIntent(contentIntent)
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Allow cleartext network traffic -->
<base-config
cleartextTrafficPermitted="true"
cleartextTrafficPermitted="false"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors>
<!-- Trust pre-installed CAs -->
+3 -3
View File
@@ -7,13 +7,13 @@ plugins {
group = "dev.meloda.fast.buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
jvmTarget = JvmTarget.JVM_21
}
}
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.libs
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -10,12 +10,15 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
}
}
}
@@ -1,18 +1,26 @@
import com.android.build.gradle.LibraryExtension
import com.android.build.api.dsl.LibraryExtension
import dev.meloda.fast.configureAndroidCompose
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.configure
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)
extensions.configure<LibraryExtension> {
configureAndroidCompose(this)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
}
}
}
@@ -1,8 +1,7 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -14,14 +13,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.parcelize")
apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
extensions.configure<LibraryAndroidComponentsExtension> {
@@ -1,6 +1,6 @@
import com.android.build.gradle.TestExtension
import com.android.build.api.dsl.TestExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.libs
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -10,12 +10,15 @@ class AndroidTestConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.test")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
}
}
}
@@ -5,12 +5,10 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *, *>,
commonExtension: CommonExtension,
) {
commonExtension.apply {
buildFeatures {
compose = true
}
buildFeatures.compose = true
dependencies {
val bom = libs.findLibrary("compose-bom").get()
@@ -1,6 +1,9 @@
package dev.meloda.fast
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.CompileOptions
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
@@ -13,28 +16,29 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
commonExtension: CommonExtension,
) {
when (commonExtension) {
is ApplicationExtension -> commonExtension.compileOptions(buildCompileOptions())
is LibraryExtension -> commonExtension.compileOptions(buildCompileOptions())
}
commonExtension.apply {
compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
defaultConfig {
minSdk = libs.findVersion("minSdk").get().toString().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
compileSdk = getVersionInt("compileSdk")
}
configureKotlin<KotlinAndroidProjectExtension>()
}
private fun buildCompileOptions(): CompileOptions.() -> Unit = {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
configureKotlin<KotlinJvmProjectExtension>()
@@ -47,15 +51,17 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
else -> throw IllegalArgumentException("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_17
jvmTarget = JvmTarget.JVM_21
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview"
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property",
"-Xcontext-parameters"
)
}
}
@@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
fun Project.getVersionInt(alias: String): Int {
return libs.findVersion(alias).get().requiredVersion.toInt()
}
+2 -1
View File
@@ -1,7 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.jvm) apply false
@@ -9,4 +8,6 @@ plugins {
alias(libs.plugins.room) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) apply true
alias(libs.plugins.stability.analyzer) apply false
}
@@ -4,9 +4,9 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.173"
const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.com/method"
const val API_VERSION = "5.238"
const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
@@ -5,12 +5,12 @@ object VkConstants {
const val GROUP_FIELDS = "description,members_count,counters,status,verified"
const val USER_FIELDS =
"photo_50,photo_100,photo_200,photo_400_orig,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate"
"photo_50,photo_100,photo_200,photo_400_orig,status,screen_name,online_info,last_seen,verified,sex,bdate"
const val ALL_FIELDS =
"$USER_FIELDS,$GROUP_FIELDS"
const val LP_VERSION = 10
const val LP_VERSION = 19
const val VK_APP_ID = "2274003"
const val VK_SECRET = "hHbZxrka2uZ6jB1inYsH"
@@ -18,6 +18,11 @@ object VkConstants {
const val FAST_GROUP_ID = -119516304
const val FAST_APP_ID = "6964679"
const val MESSENGER_APP_ID = 51453752
const val MESSENGER_APP_SECRET = "4UyuCUsdK8pVCNoeQuGi"
const val MESSENGER_APP_SCOPE = 1454174
object Auth {
const val SCOPE = "notify," +
"friends," +
@@ -1,5 +1,7 @@
package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -11,6 +13,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlin.reflect.KClass
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -23,6 +26,20 @@ fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element)
}
fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
var removed = false
val each = iterator()
while (each.hasNext()) {
if (condition(each.next())) {
each.remove()
removed = true
}
}
return removed
}
fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
@@ -75,6 +92,11 @@ fun <T> MutableStateFlow<T>.setValue(function: (T) -> T) {
update { newValue }
}
fun <T> MutableStateFlow<T>.updateValue(block: T.() -> T) {
val newValue = block(value)
update { newValue }
}
fun Any.asInt(): Int {
return when (this) {
is Number -> this.toInt()
@@ -83,6 +105,14 @@ fun Any.asInt(): Int {
}
}
fun Any.asLong(): Long {
return when (this) {
is Number -> this.toLong()
else -> throw IllegalArgumentException("Object is not numeric")
}
}
fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
return when (this) {
is List<*> -> this.mapNotNull { it?.run(mapper) }
@@ -90,3 +120,33 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
else -> emptyList()
}
}
fun <T> Bundle.getParcelableCompat(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz.java)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
return this.start < other.endInclusive && other.start < this.endInclusive
}
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
}
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
return (this.start - other)..(this.endInclusive - other)
}
@@ -1,19 +1,22 @@
package dev.meloda.fast.common.util
import android.content.res.Resources
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay
import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute
import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year
import dev.meloda.fast.common.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
object TimeUtils {
@@ -27,7 +30,11 @@ object TimeUtils {
}.timeInMillis
}
fun getLocalizedDate(resources: Resources, date: Long): String {
fun getLocalizedDate(
date: Long,
yesterday: () -> String,
today: () -> String
): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
@@ -36,48 +43,41 @@ object TimeUtils {
now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) {
return resources.getString(R.string.yesterday)
return yesterday()
} else {
"dd MMMM"
}
}
else -> return resources.getString(R.string.today)
else -> return today()
}
return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
}
fun getLocalizedTime(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
fun getLocalizedTime(
date: Long,
yearShort: () -> String,
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String
): String {
val now = Clock.System.now()
val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when {
now.year != then.year -> {
"${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${resources.getString(R.string.week_short).lowercase()}"
} else {
"$change${resources.getString(R.string.day_short).lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
resources.getString(R.string.time_now).lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
diff > 30.days -> "${diff.inWholeDays / 30}${monthShort().lowercase()}"
diff > 7.days -> "${diff.inWholeDays / 7}${weekShort().lowercase()}"
diff > 1.days -> "${diff.inWholeDays}${dayShort().lowercase()}"
diff > 1.hours -> "${diff.inWholeHours}h"
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
else -> now().lowercase()
}
}
}
@@ -0,0 +1,17 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
fun String.sha256() = this.hashString("SHA-256")
fun String.hashString(algorithm: String): String {
return MessageDigest
.getInstance(algorithm)
.digest(this.toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
-1
View File
@@ -14,7 +14,6 @@ dependencies {
api(projects.core.network)
api(projects.core.database)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.retrofit)
implementation(libs.eithernet)
implementation(libs.koin.android)
@@ -25,8 +25,6 @@ sealed class State<out T> {
data object InternalError : Error()
data class OAuthError(val error: OAuthErrorDomain) : Error()
data class TestError(val message: String) : Error()
}
fun isLoading(): Boolean = this is Loading
@@ -38,16 +36,16 @@ sealed class State<out T> {
}
inline fun <T> State<T>.processState(
error: (error: State.Error) -> (Unit),
success: (data: T) -> (Unit),
error: (error: State.Error) -> Unit,
success: (data: T) -> Unit,
idle: (() -> (Unit)) = {},
loading: (() -> (Unit)) = {},
any: () -> Unit = {}
) {
when (this) {
is State.Error -> {
error(this)
any()
error(this)
}
State.Idle -> idle()
@@ -55,17 +53,47 @@ inline fun <T> State<T>.processState(
State.Loading -> loading()
is State.Success -> {
success(data)
any()
success(data)
}
}
}
fun OAuthErrorDomain?.toStateApiError(): State.Error {
if (this == null) return State.Error.ConnectionError
return State.Error.OAuthError(this)
}
fun RestApiErrorDomain?.toStateApiError(): State.Error = when (this) {
null -> State.Error.ConnectionError
else -> State.Error.ApiError(VkErrorCode.parse(code), message)
}
fun <T : Any> ApiResult<T, OAuthErrorDomain>.asState() = when (this) {
is ApiResult.Success -> State.Success(this.value)
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
fun <T : Any, N> ApiResult<T, OAuthErrorDomain>.asState(successMapper: (T) -> N) =
when (this) {
is ApiResult.Success -> State.Success(successMapper(this.value))
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
fun <T : Any, E : Any> ApiResult<T, E>.success(): T =
when (this) {
is ApiResult.Success -> value
else -> throw IllegalArgumentException()
}
fun <T : Any> ApiResult<T, RestApiErrorDomain>.mapToState() = when (this) {
is ApiResult.Success -> State.Success(this.value)
@@ -6,17 +6,18 @@ object UserConfig {
private const val ARG_CURRENT_USER_ID = "current_user_id"
var currentUserId: Int = -1
get() = AppSettings.getInt(ARG_CURRENT_USER_ID, -1)
var currentUserId: Long = -1
get() = AppSettings.getLong(ARG_CURRENT_USER_ID, -1)
set(value) {
field = value
AppSettings.edit { putInt(ARG_CURRENT_USER_ID, value) }
AppSettings.edit { putLong(ARG_CURRENT_USER_ID, value) }
}
var userId: Int = -1
var userId: Long = -1
var accessToken: String = ""
var fastToken: String? = ""
var trustedHash: String? = null
var exchangeToken: String? = null
fun clear() {
currentUserId = -1
@@ -1,7 +1,7 @@
package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import kotlin.math.abs
@@ -10,15 +10,15 @@ class VkGroupsMap(
private val groups: List<VkGroupDomain>
) {
private val map: HashMap<Int, VkGroupDomain> by lazy {
private val map: HashMap<Long, VkGroupDomain> by lazy {
HashMap(groups.associateBy(VkGroupDomain::id))
}
fun groups(): List<VkGroupDomain> = map.values.toList()
fun conversationGroup(conversation: VkConversation): VkGroupDomain? =
if (!conversation.peerType.isGroup()) null
else map[abs(conversation.id)]
fun convoGroup(convo: VkConvo): VkGroupDomain? =
if (!convo.peerType.isGroup()) null
else map[abs(convo.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
@@ -36,7 +36,7 @@ class VkGroupsMap(
if (message.fromId >= 0) null
else map[abs(message.fromId)]
fun group(groupId: Int): VkGroupDomain? = map[abs(groupId)]
fun group(groupId: Long): VkGroupDomain? = map[abs(groupId)]
companion object {
@@ -1,7 +1,8 @@
package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.domain.VkContactDomain
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -9,11 +10,11 @@ import kotlin.math.abs
object VkMemoryCache {
private val users: HashMap<Int, VkUser> = hashMapOf()
private val groups: HashMap<Int, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Int, VkMessage> = hashMapOf()
private val conversations: HashMap<Int, VkConversation> = hashMapOf()
private val contacts: HashMap<Int, VkContactDomain> = hashMapOf()
private val users: HashMap<Long, VkUser> = hashMapOf()
private val groups: HashMap<Long, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Long, VkMessage> = hashMapOf()
private val convos: HashMap<Long, VkConvo> = hashMapOf()
private val contacts: HashMap<Long, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) {
users.forEach { user -> VkMemoryCache.users[user.id] = user }
@@ -27,9 +28,9 @@ object VkMemoryCache {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
}
fun appendConversations(conversations: List<VkConversation>) {
conversations.forEach { conversation ->
VkMemoryCache.conversations[conversation.id] = conversation
fun appendConvos(convos: List<VkConvo>) {
convos.forEach { convo ->
VkMemoryCache.convos[convo.id] = convo
}
}
@@ -37,83 +38,83 @@ object VkMemoryCache {
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
}
operator fun set(userId: Int, user: VkUser) {
operator fun set(userid: Long, user: VkUser) {
users[userId] = user
}
operator fun set(groupId: Int, group: VkGroupDomain) {
operator fun set(groupId: Long, group: VkGroupDomain) {
groups[groupId] = group
}
operator fun set(messageId: Int, message: VkMessage) {
operator fun set(messageId: Long, message: VkMessage) {
messages[messageId] = message
}
operator fun set(conversationId: Int, conversation: VkConversation) {
conversations[conversationId] = conversation
operator fun set(convoId: Long, convo: VkConvo) {
convos[convoId] = convo
}
operator fun set(contactId: Int, contact: VkContactDomain) {
operator fun set(contactId: Long, contact: VkContactDomain) {
contacts[contactId] = contact
}
fun getUser(id: Int): VkUser? {
fun getUser(id: Long): VkUser? {
return getUsers(id).firstOrNull()
}
fun getUsers(vararg ids: Int): List<VkUser> {
fun getUsers(vararg ids: Long): List<VkUser> {
return getUsers(ids.toList())
}
fun getUsers(ids: List<Int>): List<VkUser> {
fun getUsers(ids: List<Long>): List<VkUser> {
return ids.mapNotNull { id -> users[id] }
}
fun getGroup(id: Int): VkGroupDomain? {
fun getGroup(id: Long): VkGroupDomain? {
return getGroups(id).firstOrNull()
}
fun getGroups(vararg ids: Int): List<VkGroupDomain> {
fun getGroups(vararg ids: Long): List<VkGroupDomain> {
return getGroups(ids.toList())
}
fun getGroups(ids: List<Int>): List<VkGroupDomain> {
fun getGroups(ids: List<Long>): List<VkGroupDomain> {
return ids.mapNotNull { id -> groups[id] }
}
fun getMessage(id: Int): VkMessage? {
fun getMessage(id: Long): VkMessage? {
return getMessages(id).firstOrNull()
}
fun getMessages(vararg ids: Int): List<VkMessage> {
fun getMessages(vararg ids: Long): List<VkMessage> {
return getMessages(ids.toList())
}
fun getMessages(ids: List<Int>): List<VkMessage> {
fun getMessages(ids: List<Long>): List<VkMessage> {
return ids.mapNotNull { id -> messages[id] }
}
fun getConversation(id: Int): VkConversation? {
return getConversations(id).firstOrNull()
fun getConvo(id: Long): VkConvo? {
return getConvos(id).firstOrNull()
}
fun getConversations(vararg ids: Int): List<VkConversation> {
return getConversations(ids.toList())
fun getConvos(vararg ids: Long): List<VkConvo> {
return getConvos(ids.toList())
}
fun getConversations(ids: List<Int>): List<VkConversation> {
return ids.mapNotNull { id -> conversations[id] }
fun getConvos(ids: List<Long>): List<VkConvo> {
return ids.mapNotNull { id -> convos[id] }
}
fun getContact(id: Int): VkContactDomain? {
fun getContact(id: Long): VkContactDomain? {
return getContacts(id).firstOrNull()
}
fun getContacts(vararg ids: Int): List<VkContactDomain> {
fun getContacts(vararg ids: Long): List<VkContactDomain> {
return getContacts(ids.toList())
}
fun getContacts(ids: List<Int>): List<VkContactDomain> {
fun getContacts(ids: List<Long>): List<VkContactDomain> {
return ids.mapNotNull { id -> contacts[id] }
}
}
@@ -1,7 +1,7 @@
package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -9,15 +9,15 @@ class VkUsersMap(
private val users: List<VkUser>
) {
private val map: HashMap<Int, VkUser> by lazy {
private val map: HashMap<Long, VkUser> by lazy {
HashMap(users.associateBy(VkUser::id))
}
fun users(): List<VkUser> = map.values.toList()
fun conversationUser(conversation: VkConversation): VkUser? =
if (!conversation.peerType.isUser()) null
else map[conversation.id]
fun convoUser(convo: VkConvo): VkUser? =
if (!convo.peerType.isUser()) null
else map[convo.id]
fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
@@ -35,7 +35,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId]
else null
fun user(userId: Int): VkUser? = map[userId]
fun user(userId: Long): VkUser? = map[userId]
companion object {
@@ -0,0 +1,34 @@
package dev.meloda.fast.data
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
object VkUtils {
fun parseError(error: State.Error): BaseError? {
return when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
if (error.errorMessage.startsWith(
"User authorization failed: user is blocked."
)
) {
BaseError.AccountBlocked
} else {
BaseError.SessionExpired
}
}
else -> BaseError.SimpleError(message = error.errorMessage)
}
}
State.Error.ConnectionError -> BaseError.ConnectionError
State.Error.InternalError -> BaseError.InternalError
State.Error.UnknownError -> BaseError.UnknownError
else -> null
}
}
}
@@ -1,12 +1,32 @@
package dev.meloda.fast.data.api.auth
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse
import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse
import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface AuthRepository {
suspend fun logout(): ApiResult<Int, RestApiErrorDomain>
suspend fun validatePhone(
validationSid: String
): ApiResult<ValidatePhoneResponse, RestApiErrorDomain>
suspend fun getAnonymToken(
clientId: String,
clientSecret: String
): ApiResult<GetAnonymTokenResponse, RestApiErrorDomain>
suspend fun exchangeSilentToken(
anonymToken: String,
silentToken: String,
silentUuid: String
): ApiResult<ExchangeSilentTokenResponse, RestApiErrorDomain>
suspend fun getExchangeToken(
accessToken: String
): ApiResult<GetExchangeTokenResponse, RestApiErrorDomain>
}
@@ -1,10 +1,17 @@
package dev.meloda.fast.data.api.auth
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.model.api.requests.ExchangeSilentTokenRequest
import dev.meloda.fast.model.api.requests.GetAnonymTokenRequest
import dev.meloda.fast.model.api.requests.GetExchangeTokenRequest
import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse
import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse
import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.service.auth.AuthService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -12,9 +19,50 @@ class AuthRepositoryImpl(
private val service: AuthService
) : AuthRepository {
override suspend fun logout(): ApiResult<Int, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
service.logout(
clientId = VkConstants.MESSENGER_APP_ID.toString(),
clientSecret = VkConstants.MESSENGER_APP_SECRET
).mapApiDefault()
}
override suspend fun validatePhone(
validationSid: String
): ApiResult<ValidatePhoneResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
service.validatePhone(validationSid).mapApiDefault()
}
override suspend fun getAnonymToken(
clientId: String,
clientSecret: String
): ApiResult<GetAnonymTokenResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetAnonymTokenRequest(
clientId = clientId,
clientSecret = clientSecret
)
service.getAnonymToken(requestModel.map).mapApiDefault()
}
override suspend fun exchangeSilentToken(
anonymToken: String,
silentToken: String,
silentUuid: String
): ApiResult<ExchangeSilentTokenResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ExchangeSilentTokenRequest(
anonymToken = anonymToken,
silentToken = silentToken,
silentUuid = silentUuid
)
service.exchangeSilentToken(requestModel.map).mapApiDefault()
}
override suspend fun getExchangeToken(
accessToken: String
): ApiResult<GetExchangeTokenResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetExchangeTokenRequest(accessToken = accessToken)
service.getExchangeToken(requestModel.map).mapApiDefault()
}
}
@@ -1,22 +0,0 @@
package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.RestApiErrorDomain
interface ConversationsRepository {
suspend fun getConversations(
count: Int?,
offset: Int?
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun getConversationsById(
peerIds: List<Int>
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain>
suspend fun pin(peerId: Int): ApiResult<Int, RestApiErrorDomain>
suspend fun unpin(peerId: Int): ApiResult<Int, RestApiErrorDomain>
}
@@ -1,148 +0,0 @@
package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.ConversationsDeleteRequest
import dev.meloda.fast.model.api.requests.ConversationsGetRequest
import dev.meloda.fast.model.api.requests.ConversationsPinRequest
import dev.meloda.fast.model.api.requests.ConversationsUnpinRequest
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.conversations.ConversationsService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ConversationsRepositoryImpl(
private val conversationsService: ConversationsService,
private val conversationDao: ConversationDao
) : ConversationsRepository {
override suspend fun getConversations(
count: Int?,
offset: Int?
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsGetRequest(
count = count,
offset = offset,
fields = VkConstants.ALL_FIELDS,
filter = "all",
extended = true,
startMessageId = null
)
conversationsService.getConversations(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
item.conversation.asDomain(lastMessage).let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun getConversationsById(
peerIds: List<Int>
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mapOf(
"peer_ids" to peerIds.joinToString(separator = ","),
"extended" to "1",
"fields" to VkConstants.ALL_FIELDS
)
conversationsService.getConversationsById(requestParams).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
response.items.map { item ->
item.asDomain().let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun storeConversations(conversations: List<VkConversation>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
}
override suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = ConversationsDeleteRequest(peerId = peerId)
conversationsService.delete(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun pin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsPinRequest(peerId = peerId)
conversationsService.pin(requestModel.map).mapApiDefault()
}
override suspend fun unpin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsUnpinRequest(peerId = peerId)
conversationsService.unpin(requestModel.map).mapApiDefault()
}
}
@@ -0,0 +1,30 @@
package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.network.RestApiErrorDomain
interface ConvosRepository {
suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getConvos(
count: Int?,
offset: Int?,
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun getConvosById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain>
suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
suspend fun unpin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
suspend fun reorderPinned(peerIds: List<Long>): ApiResult<Int, RestApiErrorDomain>
suspend fun archive(peerId: Long): ApiResult<Int, RestApiErrorDomain>
suspend fun unarchive(peerId: Long): ApiResult<Int, RestApiErrorDomain>
}
@@ -0,0 +1,201 @@
package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.convos.ConvosService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConvosRepositoryImpl(
private val convosService: ConvosService,
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val convoDao: ConvoDao
) : ConvosRepository {
override suspend fun storeConvos(convos: List<VkConvo>) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
}
override suspend fun getConvos(
count: Int?,
offset: Int?,
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConvosGetRequest(
count = count,
offset = offset,
fields = VkConstants.ALL_FIELDS,
filter = filter,
extended = true,
startMessageId = null
)
convosService.getConvos(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val convos = response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
).also { VkMemoryCache[message.id] = it }
}
item.convo.asDomain(lastMessage).let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
val messages = convos.mapNotNull(VkConvo::lastMessage)
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
convos
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun getConvosById(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mutableMapOf(
"peer_ids" to peerIds.joinToString(separator = ",")
).apply {
extended?.let { this["extended"] = if (it) "1" else "0" }
fields?.let { this["fields"] = it }
}
convosService.getConvosById(requestParams).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
val convos = response.items.map { item ->
item.asDomain().let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
convos
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
convosService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun pin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unpin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun reorderPinned(
peerIds: List<Long>
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService
.reorderPinned(mapOf("peer_ids" to peerIds.joinToString(",")))
.mapApiDefault()
}
override suspend fun archive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unarchive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
}
@@ -16,7 +16,7 @@ class FilesRepository(
// AUDIO_MESSAGE("audio_message")
// }
//
// suspend fun getMessagesUploadServer(peerId: Int, type: FileType) =
// suspend fun getMessagesUploadServer(peerid: Long, type: FileType) =
// filesService.getUploadServer(
// mapOf(
// "peer_id" to peerId.toString(),
@@ -8,11 +8,13 @@ import com.slack.eithernet.ApiResult
interface FriendsRepository {
suspend fun getAllFriends(
order: String,
count: Int?,
offset: Int?
): ApiResult<FriendsInfo, RestApiErrorDomain>
suspend fun getFriends(
order: String,
count: Int?,
offset: Int?
): ApiResult<List<VkUser>, RestApiErrorDomain>
@@ -20,7 +22,7 @@ interface FriendsRepository {
suspend fun getOnlineFriends(
count: Int?,
offset: Int?
): ApiResult<List<Int>, RestApiErrorDomain>
): ApiResult<List<Long>, RestApiErrorDomain>
suspend fun storeUsers(users: List<VkUser>)
}
@@ -2,7 +2,7 @@ package dev.meloda.fast.data.api.friends
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.database.dao.UsersDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.FriendsInfo
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.domain.VkUser
@@ -21,14 +21,15 @@ import kotlinx.coroutines.withContext
class FriendsRepositoryImpl(
private val service: FriendsService,
private val dao: UsersDao
private val dao: UserDao
) : FriendsRepository {
override suspend fun getAllFriends(
order: String,
count: Int?,
offset: Int?
): ApiResult<FriendsInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val friends = async { getFriends(count, offset) }.await()
val friends = async { getFriends(order, count, offset) }.await()
.successOrElse { failure ->
return@withContext failure
}
@@ -42,11 +43,12 @@ class FriendsRepositoryImpl(
}
override suspend fun getFriends(
order: String,
count: Int?,
offset: Int?
): ApiResult<List<VkUser>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetFriendsRequest(
order = "hints",
order = order,
count = count,
offset = offset,
fields = VkConstants.USER_FIELDS
@@ -67,7 +69,7 @@ class FriendsRepositoryImpl(
override suspend fun getOnlineFriends(
count: Int?,
offset: Int?
): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
): ApiResult<List<Long>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = GetOnlineFriendsRequest(
order = "hints",
count = count,
@@ -1,9 +1,9 @@
package dev.meloda.fast.data.api.messages
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo(
val messages: List<VkMessage>,
val conversations: List<VkConversation>
val convos: List<VkConvo>
)
@@ -1,77 +1,119 @@
package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getHistory(
conversationId: Int,
convoId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
suspend fun getById(
messagesIds: List<Int>,
peerCmIds: List<Long>?,
peerId: Long?,
messagesIds: List<Long>?,
cmIds: List<Long>?,
extended: Boolean?,
fields: String?
): ApiResult<List<VkMessage>, RestApiErrorDomain>
suspend fun send(
peerId: Int,
randomId: Int,
peerId: Long,
randomId: Long,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain>
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
peerId: Long,
startMessageId: Long?
): ApiResult<Int, RestApiErrorDomain>
suspend fun getHistoryAttachments(
peerId: Int,
peerId: Long,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
cmId: Long
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>)
suspend fun createChat(
userIds: List<Long>?,
title: String?
): ApiResult<Long, RestApiErrorDomain>
// suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain>
//
// suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain>
//
// suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain>
//
// suspend fun delete(
// params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain>
//
// suspend fun edit(
// params: MessagesEditRequest
// ): ApiResult<Int, RestApiErrorDomain>
//
// suspend fun getChat(
// params: MessagesGetChatRequest
// ): ApiResult<VkChatData, RestApiErrorDomain>
//
// suspend fun getConversationMembers(
// params: MessagesGetConversationMembersRequest
// ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain>
//
// suspend fun removeChatUser(
// params: MessagesRemoveChatUserRequest
// ): ApiResult<Int, RestApiErrorDomain>
suspend fun pin(
peerId: Long,
messageId: Long? = null,
cmId: Long? = null
): ApiResult<VkMessage, RestApiErrorDomain>
suspend fun unpin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain>
suspend fun markAsImportant(
peerId: Long,
messageIds: List<Long>? = null,
cmIds: List<Long>? = null,
important: Boolean
): ApiResult<List<Long>, RestApiErrorDomain>
suspend fun delete(
peerId: Long,
messageIds: List<Long>?,
cmIds: List<Long>?,
spam: Boolean,
deleteForAll: Boolean
): ApiResult<List<Any>, RestApiErrorDomain>
suspend fun edit(
peerId: Long,
messageId: Long? = null,
cmId: Long? = null,
message: String? = null,
lat: Float? = null,
long: Float? = null,
attachments: List<VkAttachment>? = null,
notParseLinks: Boolean = false,
keepSnippets: Boolean = true,
keepForwardedMessages: Boolean = true
): ApiResult<Int, RestApiErrorDomain>
suspend fun getChat(
chatId: Long,
fields: String? = null
): ApiResult<VkChatData, RestApiErrorDomain>
suspend fun getConvoMembers(
peerId: Long,
offset: Int? = null,
count: Int? = null,
extended: Boolean? = null,
fields: String? = null
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain>
suspend fun removeChatUser(
chatId: Long,
memberId: Long
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
}
@@ -1,46 +1,69 @@
package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetConvoMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest
import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MessagesRepositoryImpl(
private val messagesService: MessagesService,
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val convoDao: ConvoDao
) : MessagesRepository {
override suspend fun getHistory(
conversationId: Int,
convoId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest(
count = count,
offset = offset,
peerId = conversationId,
peerId = convoId,
extended = true,
startMessageId = null,
rev = null,
@@ -68,25 +91,40 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage.let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
).also { VkMemoryCache[message.id] = it }
}
}
val conversations = response.conversations.orEmpty().map { item ->
val convos = response.convos.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
.let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
MessagesHistoryInfo(
messages = messages,
conversations = conversations
convos = convos
)
},
errorMapper = { error ->
@@ -96,12 +134,18 @@ class MessagesRepositoryImpl(
}
override suspend fun getById(
messagesIds: List<Int>,
peerCmIds: List<Long>?,
peerId: Long?,
messagesIds: List<Long>?,
cmIds: List<Long>?,
extended: Boolean?,
fields: String?
): ApiResult<List<VkMessage>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetByIdRequest(
peerCmIds = peerCmIds,
peerId = peerId,
messagesIds = messagesIds,
cmIds = cmIds,
extended = extended,
fields = fields
)
@@ -111,45 +155,70 @@ class MessagesRepositoryImpl(
val response = apiResponse.requireResponse()
val messages = response.items
val usersMap =
VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
val groupsMap =
VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
messages.map { message ->
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
val domainMessages = messages.map { message ->
message.asDomain().copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
)
}
launch(Dispatchers.IO) {
messageDao.insertAll(domainMessages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
domainMessages
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun send(
peerId: Int,
randomId: Int,
peerId: Long,
randomId: Long,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
forward = forward,
attachments = attachments,
formatData = formatData
)
messagesService.send(requestModel.map).mapApiDefault()
}
override suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
peerId: Long,
startMessageId: Long?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesMarkAsReadRequest(
peerId = peerId,
@@ -160,11 +229,11 @@ class MessagesRepositoryImpl(
}
override suspend fun getHistoryAttachments(
peerId: Int,
peerId: Long,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
cmId: Long
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryAttachmentsRequest(
@@ -174,7 +243,7 @@ class MessagesRepositoryImpl(
offset = offset,
preserveOrder = true,
attachmentTypes = attachmentTypes,
conversationMessageId = conversationMessageId,
cmId = cmId,
fields = VkConstants.ALL_FIELDS
)
@@ -190,6 +259,11 @@ class MessagesRepositoryImpl(
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
launch(Dispatchers.IO) {
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
response.items.map(VkAttachmentHistoryMessageData::toDomain)
},
errorMapper = { error ->
@@ -198,81 +272,170 @@ class MessagesRepositoryImpl(
)
}
override suspend fun createChat(
userIds: List<Long>?,
title: String?
): ApiResult<Long, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesCreateChatRequest(
userIds = userIds,
title = title
)
messagesService.createChat(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().chatId
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun pin(
peerId: Long,
messageId: Long?,
cmId: Long?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
cmId = cmId
)
messagesService.pin(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().asDomain()
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun unpin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesUnpinMessageRequest(peerId = peerId)
messagesService.unpin(requestModel.map).mapApiDefault()
}
override suspend fun markAsImportant(
peerId: Long,
messageIds: List<Long>?,
cmIds: List<Long>?,
important: Boolean
): ApiResult<List<Long>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesMarkAsImportantRequest(
messagesIds = messageIds.orEmpty(),
important = important
)
messagesService.markAsImportant(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun delete(
peerId: Long,
messageIds: List<Long>?,
cmIds: List<Long>?,
spam: Boolean,
deleteForAll: Boolean
): ApiResult<List<Any>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesDeleteRequest(
peerId = peerId,
messagesIds = messageIds,
cmIds = cmIds,
isSpam = spam,
deleteForAll = deleteForAll
)
messagesService.delete(requestModel.map).mapApiDefault()
}
override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity))
}
// override suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.markAsImportant(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.pin(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.unpin(params.map).mapResult(
// successMapper = {},
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun delete(
// params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.delete(params.map).mapResult(
// successMapper = {},
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun edit(
// params: MessagesEditRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.edit(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getChat(
// params: MessagesGetChatRequest
// ): ApiResult<VkChatData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getChat(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getConversationMembers(
// params: MessagesGetConversationMembersRequest
// ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> =
// withContext(Dispatchers.IO) {
// messagesService.getConversationMembers(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun removeChatUser(
// params: MessagesRemoveChatUserRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.removeChatUser(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
}
override suspend fun edit(
peerId: Long,
messageId: Long?,
cmId: Long?,
message: String?,
lat: Float?,
long: Float?,
attachments: List<VkAttachment>?,
notParseLinks: Boolean,
keepSnippets: Boolean,
keepForwardedMessages: Boolean
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesEditRequest(
peerId = peerId,
messageId = messageId,
cmId = cmId,
message = message,
lat = lat,
long = long,
attachments = attachments,
notParseLinks = notParseLinks,
keepSnippets = keepSnippets,
keepForwardedMessages = keepForwardedMessages
)
messagesService.edit(requestModel.map).mapApiDefault()
}
override suspend fun getChat(
chatId: Long,
fields: String?
): ApiResult<VkChatData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetChatRequest(
chatId = chatId,
fields = fields
)
messagesService.getChat(requestModel.map).mapApiDefault()
}
override suspend fun getConvoMembers(
peerId: Long,
offset: Int?,
count: Int?,
extended: Boolean?,
fields: String?
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = MessagesGetConvoMembersRequest(
peerId = peerId,
offset = offset,
count = count,
extended = extended,
fields = fields
)
messagesService.getConvoMembers(requestModel.map).mapApiDefault()
}
override suspend fun removeChatUser(
chatId: Long,
memberId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesRemoveChatUserRequest(
chatId = chatId,
memberId = memberId
)
messagesService.removeChatUser(requestModel.map).mapApiDefault()
}
override suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
messagesService.getMessageReadPeers(
mapOf(
"peer_id" to peerId.toString(),
"cmid" to cmId.toString(),
"extended" to "1",
"fields" to VkConstants.USER_FIELDS
)
).mapApiDefault()
}
}
@@ -1,6 +1,9 @@
package dev.meloda.fast.data.api.oauth
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.responses.AuthDirectResponse
import dev.meloda.fast.model.api.responses.GetSilentTokenResponse
import dev.meloda.fast.network.OAuthErrorDomain
interface OAuthRepository {
@@ -11,5 +14,15 @@ interface OAuthRepository {
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse
): ApiResult<AuthDirectResponse, OAuthErrorDomain>
suspend fun getSilentToken(
login: String,
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
}
@@ -1,10 +1,16 @@
package dev.meloda.fast.data.api.oauth
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.model.api.requests.AuthDirectRequest
import dev.meloda.fast.model.api.responses.AuthDirectResponse
import dev.meloda.fast.model.api.responses.GetSilentTokenResponse
import dev.meloda.fast.network.OAuthErrorDomain
import dev.meloda.fast.network.ValidationType
import dev.meloda.fast.network.VkOAuthError
import dev.meloda.fast.network.VkOAuthErrorType
import dev.meloda.fast.network.mapResult
import dev.meloda.fast.network.service.oauth.OAuthService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -18,37 +24,194 @@ class OAuthRepositoryImpl(
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse = withContext(Dispatchers.IO) {
captchaKey: String?,
): ApiResult<AuthDirectResponse, OAuthErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest(
grantType = VkConstants.Auth.GrantType.PASSWORD,
clientId = VkConstants.VK_APP_ID,
clientSecret = VkConstants.VK_SECRET,
clientId = VkConstants.MESSENGER_APP_ID.toString(),
clientSecret = VkConstants.MESSENGER_APP_SECRET,
username = login,
password = password,
scope = VkConstants.Auth.SCOPE,
scope = VkConstants.MESSENGER_APP_SCOPE.toString(),
validationForceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
)
when (val result = oAuthService.auth(requestModel.map)) {
is ApiResult.Success -> result.value
oAuthService.auth(requestModel.map).mapResult(
successMapper = {
it
},
errorMapper = { response ->
val error = response?.error?.let(VkOAuthError::parse)
val errorType = response?.errorType?.let(VkOAuthErrorType::parse)
is ApiResult.Failure.HttpFailure -> {
requireNotNull(result.error)
when (error) {
null -> OAuthErrorDomain.UnknownError
VkOAuthError.FLOOD_CONTROL -> OAuthErrorDomain.TooManyTriesError
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
} else {
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
}
}
is ApiResult.Failure.ApiFailure -> TODO()
is ApiResult.Failure.NetworkFailure -> {
// TODO: 13/07/2024, Danil Nikolaev: implement showing network error
TODO()
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
)
}
is ApiResult.Failure.UnknownFailure -> TODO()
else -> throw IllegalStateException("Unknown result")
VkOAuthError.INVALID_CLIENT -> {
OAuthErrorDomain.InvalidCredentialsError
}
VkOAuthError.INVALID_REQUEST -> {
when (errorType) {
null -> OAuthErrorDomain.UnknownError
VkOAuthErrorType.WRONG_OTP -> {
OAuthErrorDomain.WrongValidationCode
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
OAuthErrorDomain.WrongValidationCodeFormat
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
OAuthErrorDomain.TooManyTriesError
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
OAuthErrorDomain.InvalidCredentialsError
}
}
}
VkOAuthError.UNKNOWN -> OAuthErrorDomain.UnknownError
}
}
)
}
override suspend fun getSilentToken(
login: String,
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest(
grantType = VkConstants.Auth.GrantType.PASSWORD,
clientId = VkConstants.MESSENGER_APP_ID.toString(),
clientSecret = VkConstants.MESSENGER_APP_SECRET,
username = login,
password = password,
scope = VkConstants.MESSENGER_APP_SCOPE.toString(),
validationForceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
successToken = successToken
)
oAuthService.getSilentToken(requestModel.map).mapResult(
successMapper = { it },
errorMapper = { response ->
val error = response?.error?.let(VkOAuthError::parse)
val errorType = response?.errorType?.let(VkOAuthErrorType::parse)
when (error) {
null -> OAuthErrorDomain.UnknownError
VkOAuthError.FLOOD_CONTROL -> OAuthErrorDomain.TooManyTriesError
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
} else {
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
}
}
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
)
}
VkOAuthError.INVALID_CLIENT -> {
OAuthErrorDomain.InvalidCredentialsError
}
VkOAuthError.INVALID_REQUEST -> {
when (errorType) {
null -> OAuthErrorDomain.UnknownError
VkOAuthErrorType.WRONG_OTP -> {
OAuthErrorDomain.WrongValidationCode
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
OAuthErrorDomain.WrongValidationCodeFormat
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
OAuthErrorDomain.TooManyTriesError
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
OAuthErrorDomain.InvalidCredentialsError
}
}
}
VkOAuthError.UNKNOWN -> OAuthErrorDomain.UnknownError
}
}
)
}
}
@@ -8,7 +8,7 @@ class PhotosRepository(
private val photosService: PhotosService
) {
suspend fun getMessagesUploadServer(peerId: Int) =
suspend fun getMessagesUploadServer(peerId: Long) =
photosService.getUploadServer(mapOf("peer_id" to peerId.toString()))
suspend fun uploadPhoto(url: String, photo: MultipartBody.Part) =
@@ -1,18 +1,18 @@
package dev.meloda.fast.data.api.users
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface UsersRepository {
suspend fun get(
userIds: List<Int>?,
userIds: List<Long>?,
fields: String?,
nomCase: String?
): ApiResult<List<VkUser>, RestApiErrorDomain>
suspend fun getLocalUsers(userIds: List<Int>): List<VkUser>
suspend fun getLocalUsers(userIds: List<Long>): List<VkUser>
suspend fun storeUsers(users: List<VkUser>)
}
@@ -1,7 +1,8 @@
package dev.meloda.fast.data.api.users
import com.slack.eithernet.ApiResult
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.database.dao.UsersDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
@@ -11,18 +12,17 @@ import dev.meloda.fast.model.database.asExternalModel
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.users.UsersService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class UsersRepositoryImpl(
private val service: UsersService,
private val dao: UsersDao
private val dao: UserDao
) : UsersRepository {
override suspend fun get(
userIds: List<Int>?,
userIds: List<Long>?,
fields: String?,
nomCase: String?
): ApiResult<List<VkUser>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -38,7 +38,9 @@ class UsersRepositoryImpl(
val users = response.map(VkUserData::mapToDomain)
launch { storeUsers(users) }
launch(Dispatchers.IO) {
storeUsers(users)
}
VkMemoryCache.appendUsers(users)
@@ -51,7 +53,7 @@ class UsersRepositoryImpl(
}
override suspend fun getLocalUsers(
userIds: List<Int>
userIds: List<Long>
): List<VkUser> = withContext(Dispatchers.IO) {
dao.getAllByIds(userIds).map(VkUserEntity::asExternalModel)
}
@@ -6,7 +6,7 @@ interface AccountsRepository {
suspend fun getAccounts(): List<AccountEntity>
suspend fun getAccountById(userId: Int): AccountEntity?
suspend fun getAccountById(userId: Long): AccountEntity?
suspend fun storeAccounts(accounts: List<AccountEntity>)
}
@@ -9,7 +9,7 @@ class AccountsRepositoryImpl(
override suspend fun getAccounts(): List<AccountEntity> = accountDao.getAll()
override suspend fun getAccountById(userId: Int): AccountEntity? =
override suspend fun getAccountById(userId: Long): AccountEntity? =
accountDao.getById(userId)
override suspend fun storeAccounts(
@@ -6,8 +6,8 @@ import dev.meloda.fast.data.api.account.AccountRepositoryImpl
import dev.meloda.fast.data.api.audios.AudiosRepository
import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.api.auth.AuthRepositoryImpl
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepositoryImpl
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.convos.ConvosRepositoryImpl
import dev.meloda.fast.data.api.files.FilesRepository
import dev.meloda.fast.data.api.friends.FriendsRepository
import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl
@@ -45,7 +45,7 @@ val dataModule = module {
singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class
singleOf(::ConvosRepositoryImpl) bind ConvosRepository::class
singleOf(::FilesRepository)
@@ -65,7 +65,6 @@ val dataModule = module {
singleOf(::FriendsRepositoryImpl) bind FriendsRepository::class
// TODO: 11/08/2024, Danil Nikolaev: find a better solution
single<Interceptor>(named("token_interceptor")) {
AccessTokenInterceptor()
}
@@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "3ebd234270e36902d3d461af38664869",
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, PRIMARY KEY(`userId`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))",
"fields": [
{
"fieldPath": "userId",
@@ -31,6 +31,12 @@
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
@@ -46,7 +52,7 @@
"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, '3ebd234270e36902d3d461af38664869')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -0,0 +1,58 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` 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
},
{
"fieldPath": "trustedHash",
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"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, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -0,0 +1,413 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "6c315b7f800694f635318d86032746ec",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` 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": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `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": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` 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, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"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"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"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, '6c315b7f800694f635318d86032746ec')"
]
}
}
@@ -0,0 +1,413 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a746865995959331f8a1b512c049dacb",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` 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": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `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": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` 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, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"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"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"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, 'a746865995959331f8a1b512c049dacb')"
]
}
}
@@ -7,7 +7,7 @@ import dev.meloda.fast.model.database.AccountEntity
@Database(
entities = [AccountEntity::class],
version = 2
version = 3
)
abstract class AccountsDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
@@ -3,12 +3,12 @@ package dev.meloda.fast.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UsersDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.database.typeconverters.Converters
import dev.meloda.fast.model.database.VkConversationEntity
import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.database.VkGroupEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.database.VkUserEntity
@@ -18,15 +18,15 @@ import dev.meloda.fast.model.database.VkUserEntity
VkUserEntity::class,
VkGroupEntity::class,
VkMessageEntity::class,
VkConversationEntity::class
VkConvoEntity::class
],
version = 7
version = 11
)
@TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UsersDao
abstract fun userDao(): UserDao
abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao
abstract fun convoDao(): ConvoDao
}
@@ -11,8 +11,8 @@ abstract class AccountDao : EntityDao<AccountEntity> {
abstract suspend fun getAll(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE userId = :userId")
abstract suspend fun getById(userId: Int): AccountEntity?
abstract suspend fun getById(userId: Long): AccountEntity?
@Query("DELETE FROM accounts WHERE userId = :userId")
abstract suspend fun deleteById(userId: Int)
abstract suspend fun deleteById(userId: Long)
}
@@ -1,30 +0,0 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConversationWithMessage
import dev.meloda.fast.model.database.VkConversationEntity
@Dao
abstract class ConversationDao : EntityDao<VkConversationEntity> {
@Query("SELECT * FROM conversations")
abstract suspend fun getAll(): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getById(id: Int): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Int): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,30 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConvoWithMessage
import dev.meloda.fast.model.database.VkConvoEntity
@Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos")
abstract suspend fun getAll(): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity?
@Transaction
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -10,14 +10,14 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:conversationId)")
abstract suspend fun getAll(conversationId: Int): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IS (:messageId)")
abstract suspend fun getById(messageId: Int): VkMessageEntity?
abstract suspend fun getById(messageId: Long): VkMessageEntity?
@Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
@@ -5,14 +5,14 @@ import androidx.room.Query
import dev.meloda.fast.model.database.VkUserEntity
@Dao
abstract class UsersDao : EntityDao<VkUserEntity> {
abstract class UserDao : EntityDao<VkUserEntity> {
@Query("SELECT * FROM users")
abstract suspend fun getAll(): List<VkUserEntity>
@Query("SELECT * FROM users WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkUserEntity>
abstract suspend fun getAllByIds(ids: List<Long>): List<VkUserEntity>
@Query("DELETE FROM users WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
abstract suspend fun deleteByIds(ids: List<Long>): Int
}
@@ -2,24 +2,28 @@ package dev.meloda.fast.database.di
import androidx.room.Room
import dev.meloda.fast.database.AccountsDatabase
import dev.meloda.fast.database.CacheDatabase
import dev.meloda.fast.database.di.migration.migrationFrom2To3
import org.koin.core.scope.Scope
import org.koin.dsl.module
val databaseModule = module {
single {
Room.databaseBuilder(get(), AccountsDatabase::class.java, "accounts").build()
Room.databaseBuilder(get(), AccountsDatabase::class.java, "accounts")
.addMigrations(migrationFrom2To3)
.build()
}
single { get<AccountsDatabase>().accountDao() }
single {
Room.databaseBuilder(get(), dev.meloda.fast.database.CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration()
Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration(true)
.build()
}
single { cacheDB().userDao() }
single { cacheDB().groupDao() }
single { cacheDB().messageDao() }
single { cacheDB().conversationDao() }
single { cacheDB().convoDao() }
}
private fun Scope.cacheDB(): dev.meloda.fast.database.CacheDatabase = get()
private fun Scope.cacheDB(): CacheDatabase = get()
@@ -0,0 +1,14 @@
package dev.meloda.fast.database.di.migration
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val migrationFrom2To3 = object : Migration(
startVersion = 2,
endVersion = 3
) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE accounts ADD COLUMN exchangeToken TEXT DEFAULT null")
}
}
@@ -13,6 +13,15 @@ class Converters {
.split(", ")
.mapNotNull(String::toIntOrNull)
@TypeConverter
fun longListToString(list: List<Long>): String = list.joinToString()
@TypeConverter
fun stringToLongList(string: String): List<Long> =
string
.split(", ")
.mapNotNull(String::toLongOrNull)
@TypeConverter
fun stringListToString(list: List<String>): String = list.joinToString()
@@ -4,13 +4,32 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.properties.Delegates
import kotlin.reflect.KClass
sealed class CaptchaTokenResult {
data object Initial : CaptchaTokenResult()
data object Null : CaptchaTokenResult()
data object Cancelled : CaptchaTokenResult()
data class Success(val token: String) : CaptchaTokenResult()
}
object AppSettings {
private var preferences: SharedPreferences by Delegates.notNull()
private val captchaResult = MutableStateFlow<CaptchaTokenResult>(CaptchaTokenResult.Initial)
fun getCaptchaResultFlow(): StateFlow<CaptchaTokenResult> = captchaResult.asStateFlow()
fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result }
private val captchaRedirectUri = MutableStateFlow<String?>(null)
fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow()
fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri }
fun init(preferences: SharedPreferences) {
this.preferences = preferences
}
@@ -96,6 +115,20 @@ object AppSettings {
)
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var showManualRefreshOptions: Boolean
get() = get(
SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS,
SettingsKeys.DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS
)
set(value) = put(SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS, value)
var enableHaptic: Boolean
get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -11,6 +11,10 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_MANUAL_REFRESH_OPTIONS = "show_manual_refresh_options"
const val DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS = false
const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -45,7 +49,10 @@ object SettingsKeys {
const val KEY_ENABLE_HAPTIC = "enable_haptic"
const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 0
const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -53,5 +60,5 @@ object SettingsKeys {
const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category"
const val ID_DMITRY = 37610580
const val ID_DMITRY = 37610580L
}
@@ -14,16 +14,12 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean>
fun onUseContactNamesChanged(use: Boolean)
@@ -34,15 +30,10 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean)
}
@@ -57,17 +48,13 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash)
override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val longPollInBackground =
MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
override fun onUseContactNamesChanged(use: Boolean) {
@@ -94,18 +81,10 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language
}
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send
}
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground
}
@@ -114,14 +93,6 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use
}
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use
}
+6 -1
View File
@@ -8,11 +8,16 @@ android {
}
dependencies {
api(projects.core.common)
api(projects.core.data)
api(projects.core.model)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
}
@@ -1,12 +1,32 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse
import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse
import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
import kotlinx.coroutines.flow.Flow
interface AuthUseCase {
interface AuthUseCase : BaseUseCase {
fun logout(): Flow<State<Int>>
fun validatePhone(
validationSid: String
): Flow<State<ValidatePhoneResponse>>
suspend fun getAnonymToken(
clientId: String,
clientSecret: String
): Flow<State<GetAnonymTokenResponse>>
suspend fun exchangeSilentToken(
anonymToken: String,
silentToken: String,
silentUuid: String
): Flow<State<ExchangeSilentTokenResponse>>
suspend fun getExchangeToken(
accessToken: String
): Flow<State<GetExchangeTokenResponse>>
}
@@ -3,16 +3,44 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.responses.ExchangeSilentTokenResponse
import dev.meloda.fast.model.api.responses.GetAnonymTokenResponse
import dev.meloda.fast.model.api.responses.GetExchangeTokenResponse
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class AuthUseCaseImpl(private val repository: AuthRepository) : AuthUseCase {
override fun validatePhone(validationSid: String): Flow<State<ValidatePhoneResponse>> = flow {
emit(State.Loading)
override fun logout(): Flow<State<Int>> = flowNewState { repository.logout().mapToState() }
val newState = repository.validatePhone(validationSid).mapToState()
emit(newState)
override fun validatePhone(validationSid: String): Flow<State<ValidatePhoneResponse>> =
flowNewState { repository.validatePhone(validationSid = validationSid).mapToState() }
override suspend fun getAnonymToken(
clientId: String,
clientSecret: String
): Flow<State<GetAnonymTokenResponse>> = flowNewState {
repository.getAnonymToken(
clientId = clientId,
clientSecret = clientSecret
).mapToState()
}
override suspend fun exchangeSilentToken(
anonymToken: String,
silentToken: String,
silentUuid: String
): Flow<State<ExchangeSilentTokenResponse>> = flowNewState {
repository.exchangeSilentToken(
anonymToken = anonymToken,
silentToken = silentToken,
silentUuid = silentUuid
).mapToState()
}
override suspend fun getExchangeToken(
accessToken: String
): Flow<State<GetExchangeTokenResponse>> = flowNewState {
repository.getExchangeToken(accessToken = accessToken).mapToState()
}
}
@@ -0,0 +1,16 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
interface BaseUseCase {
suspend fun <T> FlowCollector<State<T>>.emitState(stateBlock: suspend () -> State<T>) {
emit(State.Loading)
emit(stateBlock())
}
fun <T> flowNewState(stateBlock: suspend () -> State<T>) =
flow { emitState(stateBlock) }
}
@@ -1,19 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow
interface ConversationsUseCase {
fun getConversations(
count: Int?,
offset: Int?,
): Flow<State<List<VkConversation>>>
fun delete(peerId: Int): Flow<State<Int>>
fun changePinState(peerId: Int, pin: Boolean): Flow<State<Int>>
suspend fun storeConversations(conversations: List<VkConversation>)
}
@@ -1,118 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
// override fun getConversations(
// count: Int?,
// offset: Int?,
// fields: String,
// filter: String,
// extended: Boolean?,
// startMessageId: Int?
// ): Flow<dev.meloda.fast.network.State<ConversationsResponseDomain>> = flow {
// emit(dev.meloda.fast.network.State.Loading)
//
// val newState = conversationsRepository.getConversations(
// params = ConversationsGetRequest(
// count = count,
// offset = offset,
// fields = fields,
// filter = filter,
// extended = extended,
// startMessageId = startMessageId
// )
// ).fold(
// onSuccess = { response -> dev.meloda.fast.network.State.Success(response.toDomain()) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
//
// override fun pin(peerId: Int): Flow<dev.meloda.fast.network.State<Unit>> = flow {
// emit(dev.meloda.fast.network.State.Loading)
//
// val newState = conversationsRepository.pin(
// ConversationsPinRequest(peerId = peerId)
// ).fold(
// onSuccess = { dev.meloda.fast.network.State.Success(Unit) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override fun unpin(peerId: Int): Flow<dev.meloda.fast.network.State<Unit>> = flow {
// emit(dev.meloda.fast.network.State.Loading)
//
// val newState = conversationsRepository.unpin(
// ConversationsUnpinRequest(peerId = peerId)
// ).fold(
// onSuccess = { dev.meloda.fast.network.State.Success(Unit) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override suspend fun storeConversations(conversations: List<VkConversationDomain>) {
// conversationsDao.insertAll(conversations.map(VkConversationDomain::mapToDb))
// }
//
// override suspend fun storeGroups(groups: List<VkGroupDomain>) {
// groupsDao.insertAll(groups.map(VkGroupDomain::mapToDB))
// }
override fun getConversations(
count: Int?,
offset: Int?
): Flow<State<List<VkConversation>>> = flow {
emit(State.Loading)
val newState = repository.getConversations(count, offset).mapToState()
emit(newState)
}
override suspend fun storeConversations(
conversations: List<VkConversation>
) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations)
}
override fun delete(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.delete(peerId = peerId).mapToState()
emit(newState)
}
override fun changePinState(peerId: Int, pin: Boolean): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = if (pin) {
repository.pin(peerId)
} else {
repository.unpin(peerId)
}.mapToState()
emit(newState)
}
}
@@ -0,0 +1,29 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
interface ConvoUseCase : BaseUseCase {
suspend fun storeConvos(convos: List<VkConvo>)
fun getConvos(
count: Int? = null,
offset: Int? = null,
filter: ConvosFilter
): Flow<State<List<VkConvo>>>
fun getById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConvo>>>
fun delete(peerId: Long): Flow<State<Long>>
fun changePinState(peerId: Long, pin: Boolean): Flow<State<Int>>
fun changeArchivedState(peerId: Long, archive: Boolean): Flow<State<Int>>
}
@@ -0,0 +1,71 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class ConvoUseCaseImpl(
private val repository: ConvosRepository,
) : ConvoUseCase {
override suspend fun storeConvos(
convos: List<VkConvo>
) = withContext(Dispatchers.IO) {
repository.storeConvos(convos)
}
override fun getConvos(
count: Int?,
offset: Int?,
filter: ConvosFilter
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvos(
count = count,
offset = offset,
filter = filter
).mapToState()
}
override fun getById(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields
).mapToState()
}
override fun delete(peerId: Long): Flow<State<Long>> = flowNewState {
repository.delete(peerId = peerId).mapToState()
}
override fun changePinState(
peerId: Long,
pin: Boolean
): Flow<State<Int>> = flowNewState {
if (pin) {
repository.pin(peerId)
} else {
repository.unpin(peerId)
}.mapToState()
}
override fun changeArchivedState(
peerId: Long,
archive: Boolean
): Flow<State<Int>> = flowNewState {
if (archive) {
repository.archive(peerId)
} else {
repository.unarchive(peerId)
}.mapToState()
}
}
@@ -8,11 +8,13 @@ import kotlinx.coroutines.flow.Flow
interface FriendsUseCase {
fun getAllFriends(
order: String = "hints",
count: Int?,
offset: Int?
): Flow<State<FriendsInfo>>
fun getFriends(
order: String = "hints",
count: Int?,
offset: Int?
): Flow<State<List<VkUser>>>
@@ -20,7 +22,7 @@ interface FriendsUseCase {
fun getOnlineFriends(
count: Int?,
offset: Int?
): Flow<State<List<Int>>>
): Flow<State<List<Long>>>
suspend fun storeUsers(users: List<VkUser>)
}
@@ -11,25 +11,32 @@ import kotlinx.coroutines.flow.flow
class FriendsUseCaseImpl(private val repository: FriendsRepository) :
FriendsUseCase {
override fun getAllFriends(count: Int?, offset: Int?): Flow<State<FriendsInfo>> = flow {
override fun getAllFriends(order: String, count: Int?, offset: Int?): Flow<State<FriendsInfo>> = flow {
emit(State.Loading)
val newState = repository.getAllFriends(count, offset).mapToState()
val newState = repository.getAllFriends(order, count, offset).mapToState()
emit(newState)
}
override fun getFriends(
count: Int?, offset: Int?
order: String,
count: Int?,
offset: Int?
): Flow<State<List<VkUser>>> = flow {
emit(State.Loading)
val newState = repository.getFriends(count, offset).mapToState()
val newState = repository.getFriends(
order = order,
count = count,
offset = offset
).mapToState()
emit(newState)
}
override fun getOnlineFriends(
count: Int?, offset: Int?
): Flow<State<List<Int>>> = flow {
): Flow<State<List<Long>>> = flow {
emit(State.Loading)
val newState = repository.getOnlineFriends(count, offset).mapToState()
@@ -6,10 +6,7 @@ import dev.meloda.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase(
private val accountsRepository: AccountsRepository
) {
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId)
}
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flow
class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
operator fun invoke(userId: Int): Flow<State<VkUser?>> = flow {
operator fun invoke(userId: Long): Flow<State<VkUser?>> = flow {
emit(State.Loading)
val newState = kotlin.runCatching {
@@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
emit(newState)
}
suspend fun proceed(userId: Long): VkUser? {
return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull()
}
}
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flow
class GetLocalUsersByIdsUseCase(private val repository: UsersRepository) {
operator fun invoke(userIds: List<Int>): Flow<State<List<VkUser>>> = flow {
operator fun invoke(userIds: List<Long>): Flow<State<List<VkUser>>> = flow {
emit(State.Loading)
val newState = kotlin.runCatching {
@@ -0,0 +1,21 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.messages.MessagesRepository
import dev.meloda.fast.data.mapToState
import kotlinx.coroutines.flow.Flow
class GetMessageReadPeersUseCase(
private val repository: MessagesRepository
) : BaseUseCase {
operator fun invoke(
peerId: Long,
cmId: Long
): Flow<State<Int>> = flowNewState {
repository.getMessageReadPeers(
peerId = peerId,
cmId = cmId
).mapToState(successMapper = { it.totalCount })
}
}
@@ -1,23 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class LoadConversationsByIdUseCase(
private val conversationsRepository: ConversationsRepository
) {
operator fun invoke(peerIds: List<Int>): Flow<State<List<VkConversation>>> = flow {
emit(State.Loading)
val newState = conversationsRepository
.getConversationsById(peerIds = peerIds)
.mapToState()
emit(newState)
}
}
@@ -0,0 +1,25 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
class LoadConvosByIdUseCase(
private val convosRepository: ConvosRepository
) : BaseUseCase {
operator fun invoke(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConvo>>> = flowNewState {
convosRepository
.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields,
).mapToState()
}
}
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.flow
class LoadUserByIdUseCase(private val repository: UsersRepository) {
operator fun invoke(
userId: Int?,
userId: Long?,
fields: String = VkConstants.USER_FIELDS,
nomCase: String? = null
): Flow<State<VkUser?>> = flow {
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.flow
class LoadUsersByIdsUseCase(private val repository: UsersRepository) {
operator fun invoke(
userIds: List<Int>?,
userIds: List<Long>?,
fields: String = VkConstants.USER_FIELDS,
nomCase: String? = null
): Flow<State<List<VkUser>>> = flow {
@@ -3,30 +3,39 @@ package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase
) {
private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.d("LongPollUpdatesParser", "error: $throwable")
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("LongPollUpdatesParser", "error: $throwable")
throwable.printStackTrace()
}
@@ -35,29 +44,26 @@ class LongPollUpdatesParser(
private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: MutableMap<ApiEvent, MutableCollection<VkEventCallback<*>>> =
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
val eventType: ApiEvent = try {
ApiEvent.parse(eventId)
} catch (e: Exception) {
e.printStackTrace()
Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
return
}
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
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.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.PIN_UNPIN_CONVERSATION -> parseConversationPinStateChanged(eventType, event)
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
@@ -65,12 +71,460 @@ class LongPollUpdatesParser(
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> onNewEvent(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun onNewEvent(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event")
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = true
)
} else {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = false
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
message?.let {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
)
}
}
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_EDITED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>)
.onEvent(LongPollParsedEvent.MessageEdited(message))
}
}
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>)
.onEvent(
LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>)
.onEvent(
LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>)
.onEvent(
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
}
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>)
.onEvent(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
}
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>)
.onEvent(
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
}
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
@@ -85,20 +539,28 @@ class LongPollUpdatesParser(
else -> return
}
val peerId = event[1].asInt()
val userIds = event[2].toList(Any::asInt).filter { it != UserConfig.userId }
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return
coroutineScope.launch {
listenersMap[eventType]?.let { listeners ->
listenersMap[longPollEvent]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.Interaction>)
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
.onEvent(
LongPollEvent.Interaction(
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
@@ -109,253 +571,237 @@ class LongPollUpdatesParser(
}
}
}
}
private fun parseConversationPinStateChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val peerId = event[1].asInt()
val majorId = event[2].asInt()
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
coroutineScope.launch {
listenersMap[ApiEvent.PIN_UNPIN_CONVERSATION]?.let { listeners ->
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>)
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
.onEvent(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = majorId
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
}
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val newMessageEvent: LongPollEvent.VkMessageNewEvent? =
loadNormalMessage(
eventType,
messageId
)
newMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageNewEvent>)
.onEvent(event)
}
}
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
coroutineScope.launch {
val editedMessageEvent: LongPollEvent.VkMessageEditEvent? =
loadNormalMessage(
eventType,
messageId
)
editedMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_EDIT]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageEditEvent>)
.onEvent(event)
}
}
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val messageId = event[2].asInt()
val unreadCount = event[3].asInt()
coroutineScope.launch {
listenersMap[ApiEvent.MESSAGE_READ_INCOMING]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>)
.onEvent(
LongPollEvent.VkMessageReadIncomingEvent(
loadMessage(
peerId = peerId,
messageId = messageId,
unreadCount = unreadCount
)
)
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>)
.onEvent(LongPollParsedEvent.MessageUpdated(message))
}
}
}
}
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val messageId = event[2].asInt()
val unreadCount = event[3].asInt()
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
coroutineScope.launch {
listenersMap[ApiEvent.MESSAGE_READ_OUTGOING]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>)
.onEvent(
LongPollEvent.VkMessageReadOutgoingEvent(
peerId = peerId,
messageId = messageId,
unreadCount = unreadCount
)
)
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>)
.onEvent(LongPollParsedEvent.MessageCacheClear(message))
}
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
private suspend inline fun <reified T : LongPollEvent> loadNormalMessage(
eventType: ApiEvent,
messageId: Int
): T? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
messageIds = listOf(messageId),
peerCmIds = null,
peerId = peerId,
messageIds = messageId?.let(::listOf),
cmIds = cmId?.let(::listOf),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadNormalMessage: error: $error")
Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { messages ->
val message = messages.singleOrNull() ?: run {
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
VkMemoryCache[message.id] = message
messagesUseCase.storeMessage(message)
val resumeValue: LongPollEvent? = when (eventType) {
ApiEvent.MESSAGE_NEW -> LongPollEvent.VkMessageNewEvent(message)
ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(message)
else -> {
continuation.resume(null)
null
}
}
resumeValue?.let { value -> continuation.resume(value as T) }
continuation.resume(message)
}
)
}
}
}
private fun <T : Any> registerListener(
eventType: ApiEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf()).also { it.add(listener) }
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
}
private fun <T : Any> registerListeners(
eventTypes: List<ApiEvent>,
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onConversationPinStateChanged(listener: VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) {
registerListener(ApiEvent.PIN_UNPIN_CONVERSATION, listener)
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
}
fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) {
onConversationPinStateChanged(assembleEventCallback(block))
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
}
fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) {
registerListener(ApiEvent.MESSAGE_READ_INCOMING, listener)
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
}
fun onMessageIncomingRead(block: (LongPollEvent.VkMessageReadIncomingEvent) -> Unit) {
onMessageIncomingRead(assembleEventCallback(block))
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
}
fun onMessageOutgoingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>) {
registerListener(ApiEvent.MESSAGE_READ_OUTGOING, listener)
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
}
fun onMessageOutgoingRead(block: (LongPollEvent.VkMessageReadOutgoingEvent) -> Unit) {
onMessageOutgoingRead(assembleEventCallback(block))
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
}
fun onNewMessage(listener: VkEventCallback<LongPollEvent.VkMessageNewEvent>) {
registerListener(ApiEvent.MESSAGE_NEW, listener)
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onNewMessage(block: (LongPollEvent.VkMessageNewEvent) -> Unit) {
onNewMessage(assembleEventCallback(block))
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
}
fun onMessageEdited(listener: VkEventCallback<LongPollEvent.VkMessageEditEvent>) {
registerListener(ApiEvent.MESSAGE_EDIT, listener)
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
}
fun onMessageEdited(block: (LongPollEvent.VkMessageEditEvent) -> Unit) {
onMessageEdited(assembleEventCallback(block))
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
}
fun onInteractions(listener: VkEventCallback<LongPollEvent.Interaction>) {
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
}
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
}
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
}
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
}
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
}
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = listener
listener = assembleEventCallback(block)
)
}
fun onInteractions(block: (LongPollEvent.Interaction) -> Unit) {
onInteractions(assembleEventCallback(block))
}
fun clearListeners() {
listenersMap.clear()
}
}
internal inline fun <R : Any> assembleEventCallback(
internal inline fun <R : LongPollParsedEvent> assembleEventCallback(
crossinline block: (R) -> Unit,
): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) }
}
fun interface VkEventCallback<in T : Any> {
fun interface VkEventCallback<in T : LongPollParsedEvent> {
fun onEvent(event: T)
}
@@ -5,43 +5,78 @@ import dev.meloda.fast.data.api.messages.MessagesHistoryInfo
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import kotlinx.coroutines.flow.Flow
interface MessagesUseCase {
interface MessagesUseCase : BaseUseCase {
suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>)
fun getMessagesHistory(
conversationId: Int,
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>>
fun getById(
messageIds: List<Int>,
peerCmIds: List<Long>?,
peerId: Long?,
messageIds: List<Long>?,
cmIds: List<Long>?,
extended: Boolean?,
fields: String?
): Flow<State<List<VkMessage>>>
fun sendMessage(
peerId: Int,
randomId: Int,
peerId: Long,
randomId: Long,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): Flow<State<Int>>
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>>
fun markAsRead(
peerId: Int,
startMessageId: Int
peerId: Long,
startMessageId: Long
): Flow<State<Int>>
fun getHistoryAttachments(
peerId: Int,
count: Int?,
offset: Int?,
peerId: Long,
count: Int? = null,
offset: Int? = null,
attachmentTypes: List<String>,
conversationMessageId: Int
cmId: Long
): Flow<State<List<VkAttachmentHistoryMessage>>>
suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>)
fun createChat(
userIds: List<Long>? = null,
title: String
): Flow<State<Long>>
fun pin(
peerId: Long,
messageId: Long? = null,
cmId: Long? = null
): Flow<State<VkMessage>>
fun unpin(
peerId: Long
): Flow<State<Int>>
fun markAsImportant(
peerId: Long,
messageIds: List<Long>? = null,
cmIds: List<Long>? = null,
important: Boolean
): Flow<State<List<Long>>>
fun delete(
peerId: Long,
messageIds: List<Long>? = null,
cmIds: List<Long>? = null,
spam: Boolean = false,
deleteForAll: Boolean = false
): Flow<State<List<Any>>>
}
@@ -7,99 +7,13 @@ import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class MessagesUseCaseImpl(
private val repository: MessagesRepository
private val repository: MessagesRepository,
) : MessagesUseCase {
override fun getMessagesHistory(
conversationId: Int,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>> = flow {
emit(State.Loading)
val newState = repository.getHistory(
conversationId = conversationId,
offset = offset,
count = count
).mapToState()
emit(newState)
}
override fun getById(
messageIds: List<Int>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkMessage>>> = flow {
emit(State.Loading)
val newState = repository.getById(
messagesIds = messageIds,
extended = extended,
fields = fields
).mapToState()
emit(newState)
}
override fun sendMessage(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.send(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
).mapToState()
emit(newState)
}
override fun markAsRead(
peerId: Int,
startMessageId: Int
): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).mapToState()
emit(newState)
}
override fun getHistoryAttachments(
peerId: Int,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>> = flow {
emit(State.Loading)
val newState = repository.getHistoryAttachments(
peerId = peerId,
count = count,
offset = offset,
attachmentTypes = attachmentTypes,
conversationMessageId = conversationMessageId
).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) {
repository.storeMessages(listOf(message))
}
@@ -107,4 +21,131 @@ class MessagesUseCaseImpl(
override suspend fun storeMessages(messages: List<VkMessage>) {
repository.storeMessages(messages)
}
override fun getMessagesHistory(
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory(
convoId = convoId,
offset = offset,
count = count
).mapToState()
}
override fun getById(
peerCmIds: List<Long>?,
peerId: Long?,
messageIds: List<Long>?,
cmIds: List<Long>?,
extended: Boolean?,
fields: String?
): Flow<State<List<VkMessage>>> = flowNewState {
repository.getById(
peerCmIds = peerCmIds,
peerId = peerId,
messagesIds = messageIds,
cmIds = cmIds,
extended = extended,
fields = fields
).mapToState()
}
override fun sendMessage(
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send(
peerId = peerId,
randomId = randomId,
message = message,
forward = forward,
attachments = attachments,
formatData = formatData
).mapToState()
}
override fun markAsRead(
peerId: Long,
startMessageId: Long
): Flow<State<Int>> = flowNewState {
repository.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).mapToState()
}
override fun getHistoryAttachments(
peerId: Long,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
cmId: Long
): Flow<State<List<VkAttachmentHistoryMessage>>> = flowNewState {
repository.getHistoryAttachments(
peerId = peerId,
count = count,
offset = offset,
attachmentTypes = attachmentTypes,
cmId = cmId
).mapToState()
}
override fun createChat(
userIds: List<Long>?,
title: String
): Flow<State<Long>> = flowNewState {
repository.createChat(userIds, title).mapToState()
}
override fun pin(
peerId: Long,
messageId: Long?,
cmId: Long?
): Flow<State<VkMessage>> = flowNewState {
repository.pin(
peerId = peerId,
messageId = messageId,
cmId = cmId
).mapToState()
}
override fun unpin(peerId: Long): Flow<State<Int>> = flowNewState {
repository.unpin(peerId = peerId).mapToState()
}
override fun markAsImportant(
peerId: Long,
messageIds: List<Long>?,
cmIds: List<Long>?,
important: Boolean
): Flow<State<List<Long>>> = flowNewState {
repository.markAsImportant(
peerId = peerId,
messageIds = messageIds,
cmIds = cmIds,
important = important
).mapToState()
}
override fun delete(
peerId: Long,
messageIds: List<Long>?,
cmIds: List<Long>?,
spam: Boolean,
deleteForAll: Boolean
): Flow<State<List<Any>>> = flowNewState {
repository.delete(
peerId = peerId,
messageIds = messageIds,
cmIds = cmIds,
spam = spam,
deleteForAll = deleteForAll
).mapToState()
}
}
@@ -2,6 +2,7 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.AuthInfo
import dev.meloda.fast.model.api.responses.GetSilentTokenResponse
import kotlinx.coroutines.flow.Flow
interface OAuthUseCase {
@@ -14,4 +15,14 @@ interface OAuthUseCase {
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>>
fun getSilentToken(
login: String,
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String? = null,
captchaKey: String? = null,
successToken: String? = null
): Flow<State<GetSilentTokenResponse>>
}
@@ -2,11 +2,9 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.oauth.OAuthRepository
import dev.meloda.fast.data.asState
import dev.meloda.fast.model.AuthInfo
import dev.meloda.fast.network.OAuthErrorDomain
import dev.meloda.fast.network.ValidationType
import dev.meloda.fast.network.VkOAuthError
import dev.meloda.fast.network.VkOAuthErrorType
import dev.meloda.fast.model.api.responses.GetSilentTokenResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -24,109 +22,47 @@ class OAuthUseCaseImpl(
): Flow<State<AuthInfo>> = flow {
emit(State.Loading)
val response = oAuthRepository.auth(
val newState = oAuthRepository.auth(
login = login,
password = password,
forceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey
).asState(
successMapper = {
AuthInfo(
userId = it.userId!!,
accessToken = it.accessToken!!,
validationHash = it.validationHash!!
)
}
)
emit(newState)
}
override fun getSilentToken(
login: String,
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading)
val newState = oAuthRepository.getSilentToken(
login = login,
password = password,
forceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
forceSms = forceSms
)
kotlin.runCatching {
val error = response.error?.let(VkOAuthError::parse)
val errorType = response.errorType?.let(VkOAuthErrorType::parse)
val newState = when (error) {
null -> {
State.Success(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
validationHash = response.validationHash
)
)
}
VkOAuthError.FLOOD_CONTROL -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
)
}
}
VkOAuthError.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
)
)
}
VkOAuthError.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthError.INVALID_REQUEST -> {
when (errorType) {
null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError)
VkOAuthErrorType.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
}
}
VkOAuthError.UNKNOWN -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
successToken = successToken
).asState()
emit(newState)
}.fold(
onSuccess = {
},
onFailure = {
emit(State.Error.TestError(it.stackTraceToString()))
}
)
}
}
@@ -6,7 +6,8 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
@@ -26,5 +27,7 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase)
singleOf(::LoadConvosByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
}

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