53 Commits

Author SHA1 Message Date
Codex ff8e2fdd49 refactor: simplify app bootstrap and root errors 2026-05-18 20:46:46 +03:00
Codex 514b8859c7 refactor: trim message history orchestration 2026-05-18 20:46:18 +03:00
Codex c18a7963bf fix: harden captcha and long poll parsing 2026-05-18 20:45:41 +03:00
Codex 255a194c25 docs: add tech debt audit 2026-05-18 20:45:12 +03:00
Codex 6b7f8f2397 refactor: split messages history actions 2026-05-14 21:05:20 +03:00
Codex 0ffd92b875 refactor: extract message composer actions 2026-05-14 20:53:34 +03:00
Codex 68fff3ebee refactor: extract message interactions 2026-05-14 20:48:50 +03:00
Codex f6c6ed59f3 refactor: unify db refresh flows 2026-05-14 20:45:24 +03:00
Codex f24eae8209 refactor: split message transport actions 2026-05-14 18:24:46 +03:00
Codex 96f45aef6a refactor: extract pinned message handling 2026-05-14 18:21:03 +03:00
Codex 5dc000341b refactor: extract message loading 2026-05-14 18:16:43 +03:00
Codex 6961ac7240 refactor: split message actions and parsers 2026-05-14 18:14:10 +03:00
Codex c380c1a73d refactor: split message event handlers 2026-05-14 18:06:54 +03:00
Codex 2bf81c60d6 refactor: extract message ui mapper 2026-05-14 18:03:30 +03:00
Codex 2e472733d9 refactor: extract paging helpers 2026-05-14 18:01:38 +03:00
Codex d91b726b9d refactor: reduce long poll dispatch noise 2026-05-14 17:56:34 +03:00
Codex 3bb4de24a7 refactor: harden main shell state 2026-05-14 17:51:48 +03:00
Codex 22d13fcbe5 refactor: centralize shared error handling 2026-05-14 17:48:56 +03:00
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
388 changed files with 9224 additions and 7072 deletions
+5 -2
View File
@@ -3,6 +3,9 @@ name: Android CI Build
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
env: env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }} RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
@@ -37,7 +40,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name - name: Upload APK with original name
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v7
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
@@ -53,7 +56,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name - name: Upload APK with original name
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v7
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
+4 -2
View File
@@ -1,4 +1,6 @@
name: Android CI Release name: Android CI Release
permissions:
contents: read
on: on:
workflow_dispatch: workflow_dispatch:
@@ -32,7 +34,7 @@ jobs:
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload release APK - name: Upload release APK
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v7
with: with:
name: app-release.apk name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk path: app/build/outputs/apk/release/app-release.apk
@@ -41,7 +43,7 @@ jobs:
run: ./gradlew bundleRelease run: ./gradlew bundleRelease
- name: Upload release Bundle - name: Upload release Bundle
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v7
with: with:
name: app-release.aab name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/release/app-release.aab
+2 -1
View File
@@ -48,7 +48,8 @@ Unofficial messenger for russian social network VKontakte
- [x] Send messages - [x] Send messages
- [x] Pinned message - [x] Pinned message
- [x] Pin & unpin messages - [x] Pin & unpin messages
- [ ] Reply to message - [x] Reply to message
- [x] Swipe to reply to message
- [x] Delete message - [x] Delete message
- [x] Select multiple messages - [x] Select multiple messages
- [x] Delete - [x] Delete
+131
View File
@@ -0,0 +1,131 @@
# Fast Messenger Tech Debt Audit
## Critical
### `core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt`
- captcha flow is built around `wait/notify`, a raw executor, and shared mutable state.
- risk: deadlocks, leaked jobs, hard-to-reproduce auth hangs.
- fix: rewrite as suspend-based flow with timeout and explicit cancellation.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- file mixes parsing, dispatching, IO loading, and concurrency orchestration.
- risk: regressions when VK event format changes.
- fix: split by event family and add parser tests.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- view model is doing too much: loaders, navigation, selection, long poll hooks, read peers, dialog flow.
- risk: brittle state transitions and untestable branching.
- fix: extract coordinators/handlers per concern.
### `app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt`
- root bootstrap handles auth, locale, long poll, permissions, profile, and start destination in one class.
- risk: startup bugs and hidden coupling between flows.
- fix: move startup/permission orchestration into dedicated controllers.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root composable owns too many top-level flows and dialogs.
- risk: UI orchestration drift and hard-to-read navigation logic.
- fix: split dialogs, bootstrap, and navigation concern into smaller composables.
## High
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- uses many `Log.d` calls and large `when` branches.
- fix: reduce logging noise and add structured tracing only where needed.
### `core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt`
- double-parsing response bodies and only logging malformed payloads.
- risk: opaque failures and harder debugging.
- fix: normalize error conversion and surface typed failures.
### `feature/profile/src/main/kotlin/dev/meloda/fast/profile/ProfileViewModel.kt`
- `loadAccountInfo()` has an empty error branch.
- risk: profile can fail silently.
- fix: set `baseError` and show fallback UI.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt`
- attachment preview logic still sits in UI layer and mixes fallback behavior.
- risk: silent drops of unsupported attachments.
- fix: move preview mapping to domain/ui-model layer.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/Link.kt`
- title handling is nullable and duplicated with preview logic.
- fix: create a small UI model for link rendering.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/File.kt`
- preview extraction is inline and branches on raw model internals.
- fix: extract to mapper/UI model.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt`
- message-item interaction is dense and repeated.
- fix: normalize scroll/reply handlers and reduce nested callbacks.
## Medium
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt`
- lookup helpers were using unsafe assumptions on external data.
- status: already improved, but should stay covered by tests.
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt`
- same risk profile as users map.
- status: already improved, still needs tests.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt`
- auth success mapping used forced unwraps on server data.
- status: already improved, but auth contract should be validated.
### `app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt`
- service lifecycle now respects config changes, but app exit semantics should be documented.
- fix: keep explicit separation of app-close vs config-change behavior.
### `build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt`
- build-tools are pinned to the local environment.
- risk: portable builds may drift between machines.
- fix: prefer SDK-managed consistency or document required SDK version.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootErrorDialog.kt`
- currently hardcodes English strings for some errors.
- fix: localize all texts through resources.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root-level error dialog is better, but some orchestration still remains in the root composable.
- fix: split into smaller root flows over time.
## Low
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/components/AnimatedDots.kt`
- marked TODO rewrite.
- fix when touching related loading UI.
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt`
- color picker TODO suggests unfinished theme customization.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/data/LongPollUpdates.kt`
- `List<List<Any>>` is a weakly typed API boundary.
- fix: introduce explicit event DTOs.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt`
- attachment persistence is still a TODO area.
### `core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt`
- attachment storage/restoration is unresolved.
### `core/common/src/main/kotlin/dev/meloda/fast/common/model/LongPollState.kt`
- Android 15 support TODO.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- large commented-out legacy block should be removed once upload flow is reimplemented.
### `feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt`
- debug/auth token acquisition TODO indicates unfinished auth path.
## What To Do First
1. Rewrite captcha interceptor.
2. Split `LongPollEventParser`.
3. Extract `MessagesHistoryViewModelImpl` orchestration.
4. Localize `RootErrorDialog` strings.
5. Add tests for auth, long poll parsing, and attachment mapping.
## Note
- Current code is already better on crash-prone nullable handling and service lifecycle.
- Remaining work is mostly structural and testability-focused.
+17 -14
View File
@@ -13,8 +13,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "dev.meloda.fastvk" applicationId = "dev.meloda.fastvk"
versionCode = 10 versionCode = 11
versionName = "0.2.2" versionName = "0.2.3"
} }
signingConfigs { signingConfigs {
@@ -59,17 +59,17 @@ android {
} }
} }
applicationVariants.all { // applicationVariants.all {
outputs.all { // outputs.all {
val date = System.currentTimeMillis() / 1000 // val date = System.currentTimeMillis() / 1000
val buildType = buildType.name // val buildType = buildType.name
val appVersion = versionName // val appVersion = versionName
val appVersionCode = versionCode // val appVersionCode = versionCode
//
val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk" // val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
(this as? BaseVariantOutputImpl)?.outputFileName = newApkName // (this as? BaseVariantOutputImpl)?.outputFileName = newApkName
} // }
} // }
packaging { packaging {
resources { resources {
@@ -79,10 +79,13 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) implementation(projects.feature.chatmaterials)
implementation(projects.feature.conversations) implementation(projects.feature.convos)
implementation(projects.feature.languagepicker) implementation(projects.feature.languagepicker)
implementation(projects.feature.messageshistory) implementation(projects.feature.messageshistory)
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
@@ -2,7 +2,6 @@ package dev.meloda.fast
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
@@ -38,6 +37,7 @@ interface MainViewModel {
val startDestination: StateFlow<Any?> val startDestination: StateFlow<Any?>
val isNeedToReplaceWithAuth: StateFlow<Boolean> val isNeedToReplaceWithAuth: StateFlow<Boolean>
val currentUser: StateFlow<VkUser?> val currentUser: StateFlow<VkUser?>
val baseError: StateFlow<BaseError?>
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean> val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean> val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
@@ -45,6 +45,7 @@ interface MainViewModel {
val isNeedToRequestNotifications: StateFlow<Boolean> val isNeedToRequestNotifications: StateFlow<Boolean>
fun onError(error: BaseError) fun onError(error: BaseError)
fun onErrorConsumed()
fun onNavigatedToAuth() fun onNavigatedToAuth()
@@ -73,6 +74,7 @@ class MainViewModelImpl(
override val startDestination = MutableStateFlow<Any?>(null) override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false) override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val currentUser = MutableStateFlow<VkUser?>(null) override val currentUser = MutableStateFlow<VkUser?>(null)
override val baseError = MutableStateFlow<BaseError?>(null)
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false) override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false) override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
@@ -82,6 +84,10 @@ class MainViewModelImpl(
private var openNotificationsSettings = false private var openNotificationsSettings = false
private var openAppSettings = false private var openAppSettings = false
init {
listenLongPollState()
}
override fun onError(error: BaseError) { override fun onError(error: BaseError) {
when (error) { when (error) {
BaseError.SessionExpired, BaseError.SessionExpired,
@@ -89,10 +95,16 @@ class MainViewModelImpl(
isNeedToReplaceWithAuth.update { true } isNeedToReplaceWithAuth.update { true }
} }
else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui else -> {
baseError.update { error }
}
} }
} }
override fun onErrorConsumed() {
baseError.update { null }
}
override fun onNavigatedToAuth() { override fun onNavigatedToAuth() {
isNeedToReplaceWithAuth.update { false } isNeedToReplaceWithAuth.update { false }
} }
@@ -203,10 +215,6 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase() val currentAccount = getCurrentAccountUseCase()
Log.d("MainViewModel", "currentAccount: $currentAccount")
listenLongPollState()
if (currentAccount != null) { if (currentAccount != null) {
UserConfig.apply { UserConfig.apply {
this.userId = currentAccount.userId this.userId = currentAccount.userId
@@ -4,8 +4,14 @@ import android.app.Application
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory 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.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings 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.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@@ -18,10 +24,14 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences) AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra()
} }
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() { private fun initKoin() {
startKoin { startKoin {
androidLogger() 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
}
}
}
} }
@@ -7,17 +7,15 @@ import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule import dev.meloda.fast.auth.authModule
import dev.meloda.fast.auth.login.di.loginModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl import dev.meloda.fast.common.LongPollControllerImpl
import dev.meloda.fast.common.provider.Provider import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl 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.conversations.di.createChatModule import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -38,10 +36,8 @@ import org.koin.dsl.module
val applicationModule = module { val applicationModule = module {
includes(domainModule) includes(domainModule)
includes( includes(
loginModule, authModule,
validationModule, convosModule,
captchaModule,
conversationsModule,
settingsModule, settingsModule,
messagesHistoryModule, messagesHistoryModule,
photoViewModule, photoViewModule,
@@ -2,15 +2,15 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.navigation.ConversationsGraph import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.presentation.MainScreen import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R
@Serializable @Serializable
object MainGraph object MainGraph
@@ -21,7 +21,7 @@ object Main
fun NavGraphBuilder.mainScreen( fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onNavigateToMessagesHistory: (conversationId: Long) -> Unit, onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit, onMessageClicked: (userid: Long) -> Unit,
onNavigateToCreateChat: () -> Unit onNavigateToCreateChat: () -> Unit
@@ -29,20 +29,20 @@ fun NavGraphBuilder.mainScreen(
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_friends, titleResId = R.string.title_friends,
selectedIconResId = R.drawable.baseline_people_alt_24, selectedIconResId = R.drawable.ic_group_fill_round_24,
unselectedIconResId = R.drawable.outline_people_alt_24, unselectedIconResId = R.drawable.ic_group_round_24,
route = Friends, route = Friends,
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_conversations, titleResId = R.string.title_convos,
selectedIconResId = R.drawable.baseline_chat_24, selectedIconResId = R.drawable.ic_mail_fill_round_24,
unselectedIconResId = R.drawable.outline_chat_24, unselectedIconResId = R.drawable.ic_mail_round_24,
route = ConversationsGraph route = ConvoGraph
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_profile, titleResId = R.string.title_profile,
selectedIconResId = R.drawable.baseline_account_circle_24, selectedIconResId = R.drawable.ic_account_circle_fill_round_24,
unselectedIconResId = R.drawable.outline_account_circle_24, unselectedIconResId = R.drawable.ic_account_circle_round_24,
route = Profile route = Profile
) )
) )
@@ -4,54 +4,29 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect 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.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalResources
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect 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.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants 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.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.service.OnlineService import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R 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.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -88,167 +63,22 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions() requestNotificationPermissions()
setContent { setContent {
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>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent) viewModel.onAppResumed(intent)
onPauseOrDispose {} onPauseOrDispose {}
} }
val permissionState = RootScreen(
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) toggleLongPollService = { enable, inBackground ->
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)
}
toggleLongPollService(
enable = true,
inBackground = 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)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService( toggleLongPollService(
enable = longPollStateToApply.isLaunched(), enable = enable,
inBackground = longPollStateToApply == LongPollState.Background inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground
) )
} },
toggleOnlineService = ::toggleOnlineService
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
) {
RootScreen(viewModel = viewModel)
}
}
} }
} }
@@ -279,7 +109,7 @@ class MainActivity : AppCompatActivity() {
} }
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels( notificationManager.createNotificationChannels(
listOf( listOf(
@@ -38,13 +38,14 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.navigation.ConversationsGraph import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.conversations.navigation.conversationsGraph import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.navigation.MainGraph import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.profile.navigation.profileScreen import dev.meloda.fast.profile.navigation.profileScreen
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -60,7 +61,7 @@ fun MainScreen(
navigationItems: ImmutableList<BottomNavigationItem>, navigationItems: ImmutableList<BottomNavigationItem>,
onError: (BaseError) -> Unit = {}, onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onNavigateToMessagesHistory: (conversationId: Long) -> Unit = {}, onNavigateToMessagesHistory: (convoId: Long) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {} onNavigateToCreateChat: () -> Unit = {}
@@ -69,15 +70,18 @@ fun MainScreen(
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) } val hazeState = remember { HazeState(true) }
val navController = rememberNavController() val navController = rememberNavController()
val defaultTabIndex = remember(navigationItems) {
var selectedItemIndex by rememberSaveable { navigationItems.indexOfFirst { it.route == ConvoGraph }.takeIf { it >= 0 } ?: 0
mutableIntStateOf(1)
} }
BackHandler(enabled = selectedItemIndex != 1) { var selectedItemIndex by rememberSaveable {
mutableIntStateOf(defaultTabIndex)
}
BackHandler(enabled = selectedItemIndex != defaultTabIndex) {
val currentRoute = navigationItems[selectedItemIndex].route val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1 selectedItemIndex = defaultTabIndex
navController.navigate(navigationItems[selectedItemIndex].route) { navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) { popUpTo(route = currentRoute) {
inclusive = true inclusive = true
@@ -127,7 +131,7 @@ fun MainScreen(
} }
}, },
icon = { icon = {
if (index == navigationItems.size - 1) { if (item.route == Profile) {
var isLoading by remember { var isLoading by remember {
mutableStateOf(true) mutableStateOf(true)
} }
@@ -197,14 +201,14 @@ fun MainScreen(
} }
}, },
) )
conversationsGraph( convosGraph(
activity = activity, activity = activity,
onError = onError, onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory, onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat, onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = { onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also { tabReselected = tabReselected.toMutableMap().also {
it[ConversationsGraph] = false it[ConvoGraph] = false
} }
} }
) )
@@ -0,0 +1,38 @@
package dev.meloda.fast.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
@Composable
fun RootErrorDialog(
baseError: BaseError?,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
if (baseError == null) return
val errorText = when (baseError) {
BaseError.ConnectionError -> "Connection error"
BaseError.InternalError -> "Internal error"
BaseError.UnknownError -> "Unknown error"
is BaseError.SimpleError -> baseError.message
BaseError.SessionExpired -> "Session expired"
BaseError.AccountBlocked -> "Account blocked"
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = errorText) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(id = R.string.try_again))
}
}
)
}
@@ -1,5 +1,6 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
@@ -9,192 +10,396 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController 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.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.conversations.navigation.createChatScreen import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.conversations.navigation.navigateToCreateChat 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.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory 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.Main
import dev.meloda.fast.navigation.mainScreen import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.navigation.navigateToSettings import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R 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.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController 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 @Composable
fun RootScreen( fun RootScreen(
navController: NavHostController = rememberNavController(), toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
viewModel: MainViewModel toggleOnlineService: (enable: Boolean) -> Unit
) { ) {
val activity = LocalActivity.current val resources = LocalResources.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) { val userSettings: UserSettings = koinInject()
if (isNeedToOpenAuth) { val longPollController: LongPollController = koinInject()
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true) val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
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)
}
} }
} }
if (isNeedToShowDeniedDialog) { LaunchedEffect(isNeedToRequestPermission) {
AlertDialog( if (isNeedToRequestPermission) {
onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed, viewModel.onPermissionsRequested()
title = { Text(text = stringResource(id = R.string.warning)) }, permissionState.launchPermissionRequest()
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) }, }
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
} }
if (isNeedToShowRationaleDialog) { LifecycleResumeEffect(longPollStateToApply) {
AlertDialog( if (longPollStateToApply != LongPollState.Background) {
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed, if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
title = { Text(text = stringResource(id = R.string.warning)) }, && longPollCurrentState != longPollStateToApply
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) }, ) {
confirmButton = { toggleLongPollService(false, null)
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
} }
Box(modifier = Modifier.fillMaxSize()) { toggleLongPollService(
NavHost( longPollStateToApply.isLaunched(),
navController = navController, longPollStateToApply == LongPollState.Background
startDestination = requireNotNull(startDestination), )
enterTransition = { fadeIn(animationSpec = tween(200)) }, }
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
)
mainScreen( onPauseOrDispose {}
onError = viewModel::onError, }
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null },
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen( val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
onError = viewModel::onError, LifecycleResumeEffect(sendOnline) {
onBack = navController::navigateUp, toggleOnlineService(sendOnline)
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen( onPauseOrDispose {
onBack = navController::navigateUp, toggleOnlineService(false)
onLogOutButtonClicked = { navController.navigateToAuth(true) }, }
onLanguageItemClicked = navController::navigateToLanguagePicker, }
onRestartRequired = {
activity?.let { val deviceWidthDp = remember(true) {
val intent = Intent(activity, MainActivity::class.java) resources.displayMetrics.widthPixels.pxToDp()
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) }
activity.startActivity(intent) val deviceHeightDp = remember(true) {
activity.finish() resources.displayMetrics.heightPixels.pxToDp()
} }
}
) val deviceWidthSize by remember(deviceWidthDp) {
languagePickerScreen(onBack = navController::navigateUp) 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()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) {
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true)
} }
}
PhotoViewDialog( if (isNeedToShowDeniedDialog) {
photoViewerInfo = photoViewerInfo, AlertDialog(
onDismiss = { photoViewerInfo = null } onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) },
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) )
} }
if (isNeedToShowRationaleDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) },
confirmButton = {
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
RootErrorDialog(
baseError = baseError,
onDismiss = viewModel::onErrorConsumed,
onConfirm = viewModel::onErrorConsumed
)
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),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat,
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
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,
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)
}
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,17 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
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.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@@ -32,10 +38,11 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.flow.last
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() { class LongPollingService : Service() {
@@ -55,6 +62,8 @@ class LongPollingService : Service() {
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val longPollUseCase: LongPollUseCase by inject() private val longPollUseCase: LongPollUseCase by inject()
private val messagesUseCase: MessagesUseCase by inject()
private val storeUsersUseCase: StoreUsersUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject() private val updatesParser: LongPollUpdatesParser by inject()
private var currentJob: Job? = null private var currentJob: Job? = null
@@ -150,6 +159,8 @@ class LongPollingService : Service() {
var serverInfo = getServerInfo() var serverInfo = getServerInfo()
?: throw LongPollException(message = "bad VK response (server info)") ?: throw LongPollException(message = "bad VK response (server info)")
syncLongPollHistory(serverInfo)
var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo) var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo)
?: throw LongPollException(message = "initiation error: bad VK response (last updates)") ?: throw LongPollException(message = "initiation error: bad VK response (last updates)")
@@ -160,6 +171,7 @@ class LongPollingService : Service() {
failCount++ failCount++
serverInfo = getServerInfo() serverInfo = getServerInfo()
?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)") ?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)")
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
continue continue
} }
@@ -179,6 +191,7 @@ class LongPollingService : Service() {
?: throw LongPollException( ?: throw LongPollException(
message = "failed retrieving server info after error: bad VK response (server info #3)" message = "failed retrieving server info after error: bad VK response (server info #3)"
) )
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
} }
@@ -196,6 +209,9 @@ class LongPollingService : Service() {
updates.forEach(updatesParser::parseNextUpdate) updates.forEach(updatesParser::parseNextUpdate)
} }
AppSettings.LongPoll.ts = lastUpdatesResponse.ts ?: serverInfo.ts
AppSettings.LongPoll.pts = lastUpdatesResponse.pts ?: AppSettings.LongPoll.pts
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs)) lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
} }
} }
@@ -204,7 +220,7 @@ class LongPollingService : Service() {
} }
} }
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine { private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
longPollUseCase.getLongPollServer( longPollUseCase.getLongPollServer(
needPts = true, needPts = true,
version = VkConstants.LP_VERSION version = VkConstants.LP_VERSION
@@ -224,7 +240,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse( private suspend fun getUpdatesResponse(
server: VkLongPollData server: VkLongPollData
): LongPollUpdates? = suspendCoroutine { ): LongPollUpdates? = suspendCancellableCoroutine {
longPollUseCase.getLongPollUpdates( longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}", serverUrl = "https://${server.server}",
key = server.key, key = server.key,
@@ -246,6 +262,73 @@ class LongPollingService : Service() {
} }
} }
private suspend fun syncLongPollHistory(serverInfo: VkLongPollData) {
val cursorTs = AppSettings.LongPoll.ts ?: serverInfo.ts
val cursorPts = AppSettings.LongPoll.pts ?: serverInfo.pts
val maxMsgId = messagesUseCase.getLocalMaxMessageId()
var currentTs = cursorTs
var currentPts = cursorPts
var more: Int?
do {
val historyResponse = getLongPollHistory(
ts = currentTs,
pts = currentPts,
maxMsgId = maxMsgId
) ?: return
historyResponse.history.orEmpty().forEach(updatesParser::parseNextUpdate)
historyResponse.messages?.items.orEmpty().takeIf { it.isNotEmpty() }?.let { rawMessages ->
val messages = rawMessages.map { it.asDomain() }
messagesUseCase.storeMessages(messages)
}
historyResponse.profiles.orEmpty().takeIf { it.isNotEmpty() }?.let { profiles ->
val users = profiles.map(VkUserData::mapToDomain)
VkMemoryCache.appendUsers(users)
storeUsersUseCase(users).last()
}
historyResponse.groups.orEmpty().takeIf { it.isNotEmpty() }?.let { groups ->
VkMemoryCache.appendGroups(groups.map(VkGroupData::mapToDomain))
}
currentTs = historyResponse.ts ?: historyResponse.fromPts ?: currentTs
currentPts = historyResponse.newPts ?: historyResponse.pts ?: currentPts
more = historyResponse.more
AppSettings.LongPoll.ts = currentTs
AppSettings.LongPoll.pts = currentPts
} while (more == 1)
}
private suspend fun getLongPollHistory(
ts: Int,
pts: Int,
maxMsgId: Long?
): dev.meloda.fast.model.api.data.LongPollHistoryResponse? = suspendCancellableCoroutine {
longPollUseCase.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = VkConstants.LP_VERSION,
maxMsgId = maxMsgId,
eventsLimit = 1000,
msgsLimit = 200
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
it.resume(response)
},
error = { error ->
Log.e(TAG, "getLongPollHistory: error: $error")
it.resume(null)
}
)
}
}
private fun handleError(throwable: Throwable) { private fun handleError(throwable: Throwable) {
Log.e(TAG, "error: $throwable") Log.e(TAG, "error: $throwable")
@@ -1,6 +1,7 @@
package dev.meloda.fast.service.longpolling.di package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUpdatesReducer
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl import dev.meloda.fast.domain.LongPollUseCaseImpl
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
@@ -10,4 +11,5 @@ import org.koin.dsl.module
val longPollModule = module { val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser) singleOf(::LongPollUpdatesParser)
singleOf(::LongPollUpdatesReducer)
} }
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) { with(target) {
apply(plugin = "com.android.application") apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>() val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension) configureAndroidCompose(extension)
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.libs import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -10,12 +10,15 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.application") apply("com.android.application")
apply("org.jetbrains.kotlin.android")
} }
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 36 defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
} }
} }
} }
@@ -1,19 +1,26 @@
import com.android.build.gradle.LibraryExtension import com.android.build.api.dsl.LibraryExtension
import dev.meloda.fast.configureAndroidCompose import dev.meloda.fast.configureAndroidCompose
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.configure
class AndroidLibraryComposeConventionPlugin : Plugin<Project> { class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
apply(plugin = "com.android.library") apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>() extensions.configure<LibraryExtension> {
extension.androidResources.enable = false configureAndroidCompose(this)
configureAndroidCompose(extension) androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
} }
} }
} }
@@ -1,5 +1,5 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import org.gradle.api.Plugin import org.gradle.api.Plugin
@@ -13,7 +13,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.library") apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.parcelize") apply("org.jetbrains.kotlin.plugin.parcelize")
apply("org.jetbrains.kotlin.plugin.serialization") apply("org.jetbrains.kotlin.plugin.serialization")
} }
@@ -21,7 +20,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig.targetSdk = 36
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
extensions.configure<LibraryAndroidComponentsExtension> { 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.configureKotlinAndroid
import dev.meloda.fast.libs import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -10,12 +10,15 @@ class AndroidTestConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.test") apply("com.android.test")
apply("org.jetbrains.kotlin.android")
} }
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 36 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 import org.gradle.kotlin.dsl.dependencies
internal fun Project.configureAndroidCompose( internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *, *>, commonExtension: CommonExtension,
) { ) {
commonExtension.apply { commonExtension.apply {
buildFeatures { buildFeatures.compose = true
compose = true
}
dependencies { dependencies {
val bom = libs.findLibrary("compose-bom").get() val bom = libs.findLibrary("compose-bom").get()
@@ -1,6 +1,9 @@
package dev.meloda.fast package dev.meloda.fast
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension 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.JavaVersion
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.plugins.JavaPluginExtension
@@ -13,24 +16,26 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
internal fun Project.configureKotlinAndroid( internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>, commonExtension: CommonExtension,
) { ) {
when (commonExtension) {
is ApplicationExtension -> commonExtension.compileOptions(buildCompileOptions())
is LibraryExtension -> commonExtension.compileOptions(buildCompileOptions())
}
commonExtension.apply { commonExtension.apply {
compileSdk = 36 compileSdk = getVersionInt("compileSdk")
buildToolsVersion = "36.1.0"
defaultConfig {
minSdk = 23
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
} }
configureKotlin<KotlinAndroidProjectExtension>() configureKotlin<KotlinAndroidProjectExtension>()
} }
private fun buildCompileOptions(): CompileOptions.() -> Unit = {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
internal fun Project.configureKotlinJvm() { internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> { extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
@@ -47,7 +52,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
when (this) { when (this) {
is KotlinAndroidProjectExtension -> compilerOptions is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}") else -> throw IllegalArgumentException("Unsupported project extension $this ${T::class}")
}.apply { }.apply {
jvmTarget = JvmTarget.JVM_21 jvmTarget = JvmTarget.JVM_21
allWarningsAsErrors = warningsAsErrors.toBoolean() allWarningsAsErrors = warningsAsErrors.toBoolean()
@@ -55,7 +60,9 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow // Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-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 val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("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 { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) 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.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
@@ -9,4 +8,6 @@ plugins {
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) apply true
alias(libs.plugins.stability.analyzer) apply false
} }
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -45,6 +46,14 @@ fun <T> Flow<T>.listenValue(
action: suspend (T) -> Unit action: suspend (T) -> Unit
): Job = onEach(action::invoke).launchIn(coroutineScope) ): Job = onEach(action::invoke).launchIn(coroutineScope)
fun CoroutineScope.launchDbRefresh(
load: suspend () -> Unit,
after: suspend () -> Unit
): Job = launch {
load()
after()
}
fun createTimerFlow( fun createTimerFlow(
time: Int, time: Int,
onStartAction: (suspend () -> Unit)? = null, onStartAction: (suspend () -> Unit)? = null,
@@ -138,3 +147,15 @@ fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
getParcelable(key) 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)
}
@@ -0,0 +1,25 @@
package dev.meloda.fast.common.paging
fun canPaginate(pageSize: Int, loadedCount: Int): Boolean = loadedCount == pageSize
fun isPaginationExhausted(
pageSize: Int,
loadedCount: Int,
hasExistingItems: Boolean
): Boolean = loadedCount != pageSize && hasExistingItems
fun <T> mergePage(
existing: List<T>,
page: List<T>,
offset: Int
): List<T> = if (offset == 0) page else existing + page
data class LoadingFlags(
val isLoading: Boolean,
val isPaginating: Boolean
)
fun loadingFlags(offset: Int, isLoading: Boolean): LoadingFlags = LoadingFlags(
isLoading = offset == 0 && isLoading,
isPaginating = offset > 0 && isLoading
)
@@ -1,7 +1,6 @@
package dev.meloda.fast.common.util package dev.meloda.fast.common.util
import com.conena.nanokt.jvm.util.dayOfMonth 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.hourOfDay
import com.conena.nanokt.jvm.util.millisecond import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute import com.conena.nanokt.jvm.util.minute
@@ -12,6 +11,12 @@ import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale 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 { object TimeUtils {
@@ -56,37 +61,23 @@ object TimeUtils {
monthShort: () -> String, monthShort: () -> String,
weekShort: () -> String, weekShort: () -> String,
dayShort: () -> String, dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String now: () -> String
): String { ): String {
val now = Calendar.getInstance() val now = Clock.System.now()
val then = Calendar.getInstance().also { it.timeInMillis = date } val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when { return when {
now.year != then.year -> { diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
"${now.year - then.year}${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()}"
now.month != then.month -> { diff > 1.hours -> "${diff.inWholeHours}h"
"${now.month - then.month}${monthShort().lowercase()}" diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
} diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
else -> now().lowercase()
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${weekShort().lowercase()}"
} else {
"$change${dayShort().lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
now().lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
} }
} }
} }
@@ -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
View File
@@ -14,7 +14,6 @@ dependencies {
api(projects.core.network) api(projects.core.network)
api(projects.core.database) api(projects.core.database)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.koin.android) implementation(libs.koin.android)
@@ -1,24 +1,18 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import androidx.core.net.toUri
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import java.net.URLEncoder
class AccessTokenInterceptor : Interceptor { class AccessTokenInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().url.newBuilder() val request = chain.request()
val urlBuilder = request.url.newBuilder()
val uri = builder.build().toUri().toString().toUri() if (request.url.queryParameter("access_token") == null) {
urlBuilder.addQueryParameter("access_token", UserConfig.accessToken)
if (uri.getQueryParameter("access_token") == null) {
builder.addQueryParameter(
"access_token",
URLEncoder.encode(UserConfig.accessToken, "utf-8")
)
} }
return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build()) return chain.proceed(request.newBuilder().url(urlBuilder.build()).build())
} }
} }
@@ -24,6 +24,9 @@ object UserConfig {
accessToken = "" accessToken = ""
fastToken = "" fastToken = ""
userId = -1 userId = -1
trustedHash = null
exchangeToken = null
AppSettings.LongPoll.clear()
} }
fun isLoggedIn(): Boolean { fun isLoggedIn(): Boolean {
@@ -1,7 +1,7 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData 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.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import kotlin.math.abs import kotlin.math.abs
@@ -16,17 +16,15 @@ class VkGroupsMap(
fun groups(): List<VkGroupDomain> = map.values.toList() fun groups(): List<VkGroupDomain> = map.values.toList()
fun conversationGroup(conversation: VkConversation): VkGroupDomain? = fun convoGroup(convo: VkConvo): VkGroupDomain? =
if (!conversation.peerType.isGroup()) null if (!convo.peerType.isGroup()) null
else map[abs(conversation.id)] else map[abs(convo.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? = fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null message.actionMemberId?.takeIf { it < 0 }?.let { map[abs(it)] }
else map[abs(message.actionMemberId!!)]
fun messageActionGroup(message: VkMessageData): VkGroupDomain? = fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null message.action?.memberId?.takeIf { it < 0 }?.let { map[abs(it)] }
else map[abs(message.action!!.memberId!!)]
fun messageGroup(message: VkMessage): VkGroupDomain? = fun messageGroup(message: VkMessage): VkGroupDomain? =
if (!message.isGroup()) null if (!message.isGroup()) null
@@ -1,8 +1,7 @@
package dev.meloda.fast.data 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.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.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
@@ -13,7 +12,7 @@ object VkMemoryCache {
private val users: HashMap<Long, VkUser> = hashMapOf() private val users: HashMap<Long, VkUser> = hashMapOf()
private val groups: HashMap<Long, VkGroupDomain> = hashMapOf() private val groups: HashMap<Long, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Long, VkMessage> = hashMapOf() private val messages: HashMap<Long, VkMessage> = hashMapOf()
private val conversations: HashMap<Long, VkConversation> = hashMapOf() private val convos: HashMap<Long, VkConvo> = hashMapOf()
private val contacts: HashMap<Long, VkContactDomain> = hashMapOf() private val contacts: HashMap<Long, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) { fun appendUsers(users: List<VkUser>) {
@@ -28,9 +27,9 @@ object VkMemoryCache {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message } messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
} }
fun appendConversations(conversations: List<VkConversation>) { fun appendConvos(convos: List<VkConvo>) {
conversations.forEach { conversation -> convos.forEach { convo ->
VkMemoryCache.conversations[conversation.id] = conversation VkMemoryCache.convos[convo.id] = convo
} }
} }
@@ -38,7 +37,15 @@ object VkMemoryCache {
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact } contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
} }
operator fun set(userid: Long, user: VkUser) { fun clear() {
users.clear()
groups.clear()
messages.clear()
convos.clear()
contacts.clear()
}
operator fun set(userId: Long, user: VkUser) {
users[userId] = user users[userId] = user
} }
@@ -50,8 +57,8 @@ object VkMemoryCache {
messages[messageId] = message messages[messageId] = message
} }
operator fun set(conversationId: Long, conversation: VkConversation) { operator fun set(convoId: Long, convo: VkConvo) {
conversations[conversationId] = conversation convos[convoId] = convo
} }
operator fun set(contactId: Long, contact: VkContactDomain) { operator fun set(contactId: Long, contact: VkContactDomain) {
@@ -94,16 +101,16 @@ object VkMemoryCache {
return ids.mapNotNull { id -> messages[id] } return ids.mapNotNull { id -> messages[id] }
} }
fun getConversation(id: Long): VkConversation? { fun getConvo(id: Long): VkConvo? {
return getConversations(id).firstOrNull() return getConvos(id).firstOrNull()
} }
fun getConversations(vararg ids: Long): List<VkConversation> { fun getConvos(vararg ids: Long): List<VkConvo> {
return getConversations(ids.toList()) return getConvos(ids.toList())
} }
fun getConversations(ids: List<Long>): List<VkConversation> { fun getConvos(ids: List<Long>): List<VkConvo> {
return ids.mapNotNull { id -> conversations[id] } return ids.mapNotNull { id -> convos[id] }
} }
fun getContact(id: Long): VkContactDomain? { fun getContact(id: Long): VkContactDomain? {
@@ -1,8 +1,7 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.data.VkMessageData 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.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
@@ -16,17 +15,15 @@ class VkUsersMap(
fun users(): List<VkUser> = map.values.toList() fun users(): List<VkUser> = map.values.toList()
fun conversationUser(conversation: VkConversation): VkUser? = fun convoUser(convo: VkConvo): VkUser? =
if (!conversation.peerType.isUser()) null if (!convo.peerType.isUser()) null
else map[conversation.id] else map[convo.id]
fun messageActionUser(message: VkMessage): VkUser? = fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null message.actionMemberId?.takeIf { it > 0 }?.let(map::get)
else map[message.actionMemberId]
fun messageActionUser(message: VkMessageData): VkUser? = fun messageActionUser(message: VkMessageData): VkUser? =
if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null message.action?.memberId?.takeIf { it > 0 }?.let(map::get)
else map[message.action!!.memberId]
fun messageUser(message: VkMessage): VkUser? = fun messageUser(message: VkMessage): VkUser? =
if (!message.isUser()) null if (!message.isUser()) null
@@ -1,25 +1,28 @@
package dev.meloda.fast.data.api.conversations package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
interface ConversationsRepository { interface ConvosRepository {
suspend fun storeConversations(conversations: List<VkConversation>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
suspend fun getConversations( suspend fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConversationsFilter filter: ConvosFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain> ): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun getConversationsById( suspend fun getConvosById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): ApiResult<List<VkConversation>, RestApiErrorDomain> ): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain>
suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain> suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
@@ -1,51 +1,75 @@
package dev.meloda.fast.data.api.conversations package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap import dev.meloda.fast.data.VkUsersMap
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.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.data.asDomain
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.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.ConversationsGetRequest import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.conversations.ConversationsService import dev.meloda.fast.network.service.convos.ConvosService
import dev.meloda.fast.model.database.asExternalModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ConversationsRepositoryImpl( class ConvosRepositoryImpl(
private val conversationsService: ConversationsService, private val convosService: ConvosService,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val userDao: UserDao, private val userDao: UserDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val conversationDao: ConversationDao private val convoDao: ConvoDao
) : ConversationsRepository { ) : ConvosRepository {
override suspend fun storeConversations(conversations: List<VkConversation>) { override suspend fun storeConvos(convos: List<VkConvo>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
} }
override suspend fun getConversations( override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
convoDao.getAllWithMessage().map { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}.sorted()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
convoDao.getByIdWithMessage(peerId)?.let { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}
}
override suspend fun deleteLocalConvo(peerId: Long) {
withContext(Dispatchers.IO) {
convoDao.deleteById(peerId)
}
}
override suspend fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConversationsFilter filter: ConvosFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsGetRequest( val requestModel = ConvosGetRequest(
count = count, count = count,
offset = offset, offset = offset,
fields = VkConstants.ALL_FIELDS, fields = VkConstants.ALL_FIELDS,
@@ -54,7 +78,7 @@ class ConversationsRepositoryImpl(
startMessageId = null startMessageId = null
) )
conversationsService.getConversations(requestModel.map).mapApiResult( convosService.getConvos(requestModel.map).mapApiResult(
successMapper = { apiResponse -> successMapper = { apiResponse ->
val response = apiResponse.requireResponse() val response = apiResponse.requireResponse()
@@ -69,7 +93,7 @@ class ConversationsRepositoryImpl(
VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList) VkMemoryCache.appendContacts(contactsList)
val conversations = response.items.map { item -> val convos = response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message -> val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy( message.copy(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
@@ -84,24 +108,24 @@ class ConversationsRepositoryImpl(
) )
).also { VkMemoryCache[message.id] = it } ).also { VkMemoryCache[message.id] = it }
} }
item.conversation.asDomain(lastMessage).let { conversation -> item.convo.asDomain(lastMessage).let { convo ->
conversation.copy( convo.copy(
user = usersMap.conversationUser(conversation), user = usersMap.convoUser(convo),
group = groupsMap.conversationGroup(conversation) group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[conversation.id] = it } ).also { VkMemoryCache[convo.id] = it }
} }
} }
val messages = conversations.mapNotNull(VkConversation::lastMessage) val messages = convos.mapNotNull(VkConvo::lastMessage)
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
} }
conversations convos
}, },
errorMapper = { error -> errorMapper = { error ->
error?.toDomain() error?.toDomain()
@@ -109,11 +133,11 @@ class ConversationsRepositoryImpl(
) )
} }
override suspend fun getConversationsById( override suspend fun getConvosById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mutableMapOf( val requestParams = mutableMapOf(
"peer_ids" to peerIds.joinToString(separator = ",") "peer_ids" to peerIds.joinToString(separator = ",")
).apply { ).apply {
@@ -121,7 +145,7 @@ class ConversationsRepositoryImpl(
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
} }
conversationsService.getConversationsById(requestParams).mapApiResult( convosService.getConvosById(requestParams).mapApiResult(
successMapper = { apiResponse -> successMapper = { apiResponse ->
val response = apiResponse.requireResponse() val response = apiResponse.requireResponse()
@@ -132,17 +156,17 @@ class ConversationsRepositoryImpl(
val usersMap = VkUsersMap.forUsers(profilesList) val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList) val groupsMap = VkGroupsMap.forGroups(groupsList)
val conversations = response.items.map { item -> val convos = response.items.map { item ->
item.asDomain().let { conversation -> item.asDomain().let { convo ->
conversation.copy( convo.copy(
user = usersMap.conversationUser(conversation), user = usersMap.convoUser(convo),
group = groupsMap.conversationGroup(conversation) group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[conversation.id] = it } ).also { VkMemoryCache[convo.id] = it }
} }
} }
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
} }
@@ -151,7 +175,7 @@ class ConversationsRepositoryImpl(
VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList) VkMemoryCache.appendContacts(contactsList)
conversations convos
}, },
errorMapper = { error -> errorMapper = { error ->
error?.toDomain() error?.toDomain()
@@ -161,7 +185,7 @@ class ConversationsRepositoryImpl(
override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> = override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
conversationsService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult( convosService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId }, successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() } errorMapper = { error -> error?.toDomain() }
) )
@@ -170,19 +194,19 @@ class ConversationsRepositoryImpl(
override suspend fun pin( override suspend fun pin(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun unpin( override suspend fun unpin(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun reorderPinned( override suspend fun reorderPinned(
peerIds: List<Long> peerIds: List<Long>
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService convosService
.reorderPinned(mapOf("peer_ids" to peerIds.joinToString(","))) .reorderPinned(mapOf("peer_ids" to peerIds.joinToString(",")))
.mapApiDefault() .mapApiDefault()
} }
@@ -190,12 +214,36 @@ class ConversationsRepositoryImpl(
override suspend fun archive( override suspend fun archive(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun unarchive( override suspend fun unarchive(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList()
val pinnedConvos = newConvos
.filter(VkConvo::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConvos.removeAll(pinnedConvos)
newConvos.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConvos.addAll(0, pinnedConvos)
return newConvos
} }
} }
@@ -1,6 +1,7 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
@@ -21,4 +22,14 @@ interface LongPollRepository {
mode: Int, mode: Int,
version: Int version: Int
): ApiResult<LongPollUpdates, RestApiErrorDomain> ): ApiResult<LongPollUpdates, RestApiErrorDomain>
suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain>
} }
@@ -1,7 +1,9 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.requests.LongPollGetHistoryRequest
import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest
import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -52,4 +54,29 @@ class LongPollRepositoryImpl(
longPollService.getResponse(serverUrl, requestModel.map).mapDefault() longPollService.getResponse(serverUrl, requestModel.map).mapDefault()
} }
override suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = LongPollGetHistoryRequest(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
)
messagesService.getLongPollHistory(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse() },
errorMapper = { error -> error?.toDomain() }
)
}
} }
@@ -1,9 +1,9 @@
package dev.meloda.fast.data.api.messages 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 import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo( data class MessagesHistoryInfo(
val messages: List<VkMessage>, val messages: List<VkMessage>,
val conversations: List<VkConversation> val convos: List<VkConvo>
) )
@@ -5,7 +5,8 @@ import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse 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.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -13,8 +14,14 @@ interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
suspend fun getHistory( suspend fun getHistory(
conversationId: Long, convoId: Long,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
@@ -32,7 +39,7 @@ interface MessagesRepository {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> ): ApiResult<MessagesSendResponse, RestApiErrorDomain>
@@ -98,16 +105,21 @@ interface MessagesRepository {
fields: String? = null fields: String? = null
): ApiResult<VkChatData, RestApiErrorDomain> ): ApiResult<VkChatData, RestApiErrorDomain>
suspend fun getConversationMembers( suspend fun getConvoMembers(
peerId: Long, peerId: Long,
offset: Int? = null, offset: Int? = null,
count: Int? = null, count: Int? = null,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> ): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain>
suspend fun removeChatUser( suspend fun removeChatUser(
chatId: Long, chatId: Long,
memberId: Long memberId: Long
): ApiResult<Int, RestApiErrorDomain> ): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
} }
@@ -5,7 +5,7 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap import dev.meloda.fast.data.VkUsersMap
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.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
@@ -17,17 +17,18 @@ import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
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.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesDeleteRequest import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest import dev.meloda.fast.model.api.requests.MessagesEditRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetChatRequest import dev.meloda.fast.model.api.requests.MessagesGetChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetConversationMembersRequest import dev.meloda.fast.model.api.requests.MessagesGetConvoMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
@@ -36,8 +37,10 @@ import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse 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.model.api.responses.MessagesSendResponse
import dev.meloda.fast.model.database.asExternalModel
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
@@ -51,18 +54,18 @@ class MessagesRepositoryImpl(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val userDao: UserDao, private val userDao: UserDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val conversationDao: ConversationDao private val convoDao: ConvoDao
) : MessagesRepository { ) : MessagesRepository {
override suspend fun getHistory( override suspend fun getHistory(
conversationId: Long, convoId: Long,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest( val requestModel = MessagesGetHistoryRequest(
count = count, count = count,
offset = offset, offset = offset,
peerId = conversationId, peerId = convoId,
extended = true, extended = true,
startMessageId = null, startMessageId = null,
rev = null, rev = null,
@@ -91,29 +94,31 @@ class MessagesRepositoryImpl(
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message), actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy( replyMessage = message.replyMessage.let { replyMessage ->
user = usersMap.messageUser(message), replyMessage?.copy(
group = groupsMap.messageGroup(message), user = usersMap.messageUser(replyMessage),
actionUser = usersMap.messageActionUser(message), group = groupsMap.messageGroup(replyMessage),
actionGroup = groupsMap.messageActionGroup(message), actionUser = usersMap.messageActionUser(replyMessage),
) actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
).also { VkMemoryCache[message.id] = it } ).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 } val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message) item.asDomain(message)
.let { conversation -> .let { convo ->
conversation.copy( convo.copy(
user = usersMap.conversationUser(conversation), user = usersMap.convoUser(convo),
group = groupsMap.conversationGroup(conversation) group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[conversation.id] = it } ).also { VkMemoryCache[convo.id] = it }
} }
} }
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
@@ -121,7 +126,7 @@ class MessagesRepositoryImpl(
MessagesHistoryInfo( MessagesHistoryInfo(
messages = messages, messages = messages,
conversations = conversations convos = convos
) )
}, },
errorMapper = { error -> errorMapper = { error ->
@@ -166,12 +171,14 @@ class MessagesRepositoryImpl(
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message), actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain()?.copy( replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
user = usersMap.messageUser(message), replyMessage?.copy(
group = groupsMap.messageGroup(message), user = usersMap.messageUser(replyMessage),
actionUser = usersMap.messageActionUser(message), group = groupsMap.messageGroup(replyMessage),
actionGroup = groupsMap.messageActionGroup(message), actionUser = usersMap.messageActionUser(replyMessage),
) actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
) )
} }
@@ -195,7 +202,7 @@ class MessagesRepositoryImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -203,7 +210,7 @@ class MessagesRepositoryImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
) )
@@ -238,7 +245,7 @@ class MessagesRepositoryImpl(
offset = offset, offset = offset,
preserveOrder = true, preserveOrder = true,
attachmentTypes = attachmentTypes, attachmentTypes = attachmentTypes,
conversationMessageId = cmId, cmId = cmId,
fields = VkConstants.ALL_FIELDS fields = VkConstants.ALL_FIELDS
) )
@@ -292,7 +299,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesPinMessageRequest( val requestModel = MessagesPinMessageRequest(
peerId = peerId, peerId = peerId,
messageId = messageId, messageId = messageId,
conversationMessageId = cmId cmId = cmId
) )
messagesService.pin(requestModel.map).mapApiResult( messagesService.pin(requestModel.map).mapApiResult(
@@ -338,7 +345,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesDeleteRequest( val requestModel = MessagesDeleteRequest(
peerId = peerId, peerId = peerId,
messagesIds = messageIds, messagesIds = messageIds,
conversationsMessagesIds = cmIds, cmIds = cmIds,
isSpam = spam, isSpam = spam,
deleteForAll = deleteForAll deleteForAll = deleteForAll
) )
@@ -349,6 +356,28 @@ class MessagesRepositoryImpl(
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> = withContext(Dispatchers.IO) {
messageDao.getAll(convoId).map(VkMessageEntity::asExternalModel)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getById(messageId)?.asExternalModel()
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getByConvoMessageId(convoId, cmId)?.asExternalModel()
}
override suspend fun getLocalMaxMessageId(): Long? = withContext(Dispatchers.IO) {
messageDao.getMaxId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
withContext(Dispatchers.IO) {
messageDao.deleteByIds(messageIds)
}
}
override suspend fun edit( override suspend fun edit(
peerId: Long, peerId: Long,
messageId: Long?, messageId: Long?,
@@ -389,15 +418,15 @@ class MessagesRepositoryImpl(
messagesService.getChat(requestModel.map).mapApiDefault() messagesService.getChat(requestModel.map).mapApiDefault()
} }
override suspend fun getConversationMembers( override suspend fun getConvoMembers(
peerId: Long, peerId: Long,
offset: Int?, offset: Int?,
count: Int?, count: Int?,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> = ): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requestModel = MessagesGetConversationMembersRequest( val requestModel = MessagesGetConvoMembersRequest(
peerId = peerId, peerId = peerId,
offset = offset, offset = offset,
count = count, count = count,
@@ -405,7 +434,7 @@ class MessagesRepositoryImpl(
fields = fields fields = fields
) )
messagesService.getConversationMembers(requestModel.map).mapApiDefault() messagesService.getConvoMembers(requestModel.map).mapApiDefault()
} }
override suspend fun removeChatUser( override suspend fun removeChatUser(
@@ -419,4 +448,18 @@ class MessagesRepositoryImpl(
messagesService.removeChatUser(requestModel.map).mapApiDefault() 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()
}
} }
@@ -23,5 +23,6 @@ interface OAuthRepository {
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
} }
@@ -79,7 +79,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty() captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
) )
} }
@@ -122,6 +123,7 @@ class OAuthRepositoryImpl(
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> = ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest( val requestModel = AuthDirectRequest(
@@ -135,6 +137,7 @@ class OAuthRepositoryImpl(
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey, captchaKey = captchaKey,
successToken = successToken
) )
oAuthService.getSilentToken(requestModel.map).mapResult( oAuthService.getSilentToken(requestModel.map).mapResult(
@@ -175,7 +178,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty() captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
) )
} }
@@ -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.audios.AudiosRepository
import dev.meloda.fast.data.api.auth.AuthRepository import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.api.auth.AuthRepositoryImpl import dev.meloda.fast.data.api.auth.AuthRepositoryImpl
import dev.meloda.fast.data.api.conversations.ConversationsRepository import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepositoryImpl import dev.meloda.fast.data.api.convos.ConvosRepositoryImpl
import dev.meloda.fast.data.api.files.FilesRepository import dev.meloda.fast.data.api.files.FilesRepository
import dev.meloda.fast.data.api.friends.FriendsRepository import dev.meloda.fast.data.api.friends.FriendsRepository
import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl
@@ -45,7 +45,7 @@ val dataModule = module {
singleOf(::AuthRepositoryImpl) bind AuthRepository::class singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class singleOf(::ConvosRepositoryImpl) bind ConvosRepository::class
singleOf(::FilesRepository) singleOf(::FilesRepository)
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 10, "version": 10,
"identityHash": "fa307a5eb2e1f7d601bd1374174635cd", "identityHash": "6c315b7f800694f635318d86032746ec",
"entities": [ "entities": [
{ {
"tableName": "users", "tableName": "users",
@@ -41,50 +41,42 @@
{ {
"fieldPath": "onlineAppId", "fieldPath": "onlineAppId",
"columnName": "onlineAppId", "columnName": "onlineAppId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "lastSeen", "fieldPath": "lastSeen",
"columnName": "lastSeen", "columnName": "lastSeen",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "lastSeenStatus", "fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus", "columnName": "lastSeenStatus",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "birthday", "fieldPath": "birthday",
"columnName": "birthday", "columnName": "birthday",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo50", "fieldPath": "photo50",
"columnName": "photo50", "columnName": "photo50",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo100", "fieldPath": "photo100",
"columnName": "photo100", "columnName": "photo100",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo200", "fieldPath": "photo200",
"columnName": "photo200", "columnName": "photo200",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo400Orig", "fieldPath": "photo400Orig",
"columnName": "photo400Orig", "columnName": "photo400Orig",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@@ -92,9 +84,7 @@
"columnNames": [ "columnNames": [
"id" "id"
] ]
}, }
"indices": [],
"foreignKeys": []
}, },
{ {
"tableName": "groups", "tableName": "groups",
@@ -121,26 +111,22 @@
{ {
"fieldPath": "photo50", "fieldPath": "photo50",
"columnName": "photo50", "columnName": "photo50",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo100", "fieldPath": "photo100",
"columnName": "photo100", "columnName": "photo100",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo200", "fieldPath": "photo200",
"columnName": "photo200", "columnName": "photo200",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "membersCount", "fieldPath": "membersCount",
"columnName": "membersCount", "columnName": "membersCount",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@@ -148,13 +134,11 @@
"columnNames": [ "columnNames": [
"id" "id"
] ]
}, }
"indices": [],
"foreignKeys": []
}, },
{ {
"tableName": "messages", "tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversationMessageId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -163,16 +147,15 @@
"notNull": true "notNull": true
}, },
{ {
"fieldPath": "conversationMessageId", "fieldPath": "cmId",
"columnName": "conversationMessageId", "columnName": "cmId",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{ {
"fieldPath": "text", "fieldPath": "text",
"columnName": "text", "columnName": "text",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "isOut", "fieldPath": "isOut",
@@ -207,38 +190,32 @@
{ {
"fieldPath": "action", "fieldPath": "action",
"columnName": "action", "columnName": "action",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "actionMemberId", "fieldPath": "actionMemberId",
"columnName": "actionMemberId", "columnName": "actionMemberId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "actionText", "fieldPath": "actionText",
"columnName": "actionText", "columnName": "actionText",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "actionConversationMessageId", "fieldPath": "actionCmId",
"columnName": "actionConversationMessageId", "columnName": "actionCmId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "actionMessage", "fieldPath": "actionMessage",
"columnName": "actionMessage", "columnName": "actionMessage",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "updateTime", "fieldPath": "updateTime",
"columnName": "updateTime", "columnName": "updateTime",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "important", "fieldPath": "important",
@@ -249,32 +226,27 @@
{ {
"fieldPath": "forwardIds", "fieldPath": "forwardIds",
"columnName": "forwardIds", "columnName": "forwardIds",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "attachments", "fieldPath": "attachments",
"columnName": "attachments", "columnName": "attachments",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "replyMessageId", "fieldPath": "replyMessageId",
"columnName": "replyMessageId", "columnName": "replyMessageId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "geoType", "fieldPath": "geoType",
"columnName": "geoType", "columnName": "geoType",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "pinnedAt", "fieldPath": "pinnedAt",
"columnName": "pinnedAt", "columnName": "pinnedAt",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "isPinned", "fieldPath": "isPinned",
@@ -288,13 +260,11 @@
"columnNames": [ "columnNames": [
"id" "id"
] ]
}, }
"indices": [],
"foreignKeys": []
}, },
{ {
"tableName": "conversations", "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, `lastConversationMessageId` 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`))", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -311,32 +281,27 @@
{ {
"fieldPath": "ownerId", "fieldPath": "ownerId",
"columnName": "ownerId", "columnName": "ownerId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "title", "fieldPath": "title",
"columnName": "title", "columnName": "title",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo50", "fieldPath": "photo50",
"columnName": "photo50", "columnName": "photo50",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo100", "fieldPath": "photo100",
"columnName": "photo100", "columnName": "photo100",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "photo200", "fieldPath": "photo200",
"columnName": "photo200", "columnName": "photo200",
"affinity": "TEXT", "affinity": "TEXT"
"notNull": false
}, },
{ {
"fieldPath": "isPhantom", "fieldPath": "isPhantom",
@@ -345,8 +310,8 @@
"notNull": true "notNull": true
}, },
{ {
"fieldPath": "lastConversationMessageId", "fieldPath": "lastCmId",
"columnName": "lastConversationMessageId", "columnName": "lastCmId",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
@@ -377,8 +342,7 @@
{ {
"fieldPath": "lastMessageId", "fieldPath": "lastMessageId",
"columnName": "lastMessageId", "columnName": "lastMessageId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "unreadCount", "fieldPath": "unreadCount",
@@ -389,8 +353,7 @@
{ {
"fieldPath": "membersCount", "fieldPath": "membersCount",
"columnName": "membersCount", "columnName": "membersCount",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "canChangePin", "fieldPath": "canChangePin",
@@ -419,8 +382,7 @@
{ {
"fieldPath": "pinnedMessageId", "fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId", "columnName": "pinnedMessageId",
"affinity": "INTEGER", "affinity": "INTEGER"
"notNull": false
}, },
{ {
"fieldPath": "peerType", "fieldPath": "peerType",
@@ -440,15 +402,12 @@
"columnNames": [ "columnNames": [
"id" "id"
] ]
}, }
"indices": [],
"foreignKeys": []
} }
], ],
"views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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, 'fa307a5eb2e1f7d601bd1374174635cd')" "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')"
]
}
}
@@ -3,12 +3,12 @@ package dev.meloda.fast.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.database.typeconverters.Converters 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.VkGroupEntity
import dev.meloda.fast.model.database.VkMessageEntity import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.database.VkUserEntity import dev.meloda.fast.model.database.VkUserEntity
@@ -18,15 +18,15 @@ import dev.meloda.fast.model.database.VkUserEntity
VkUserEntity::class, VkUserEntity::class,
VkGroupEntity::class, VkGroupEntity::class,
VkMessageEntity::class, VkMessageEntity::class,
VkConversationEntity::class VkConvoEntity::class
], ],
version = 10 version = 11
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UserDao abstract fun userDao(): UserDao
abstract fun groupDao(): GroupDao abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao abstract fun convoDao(): ConvoDao
} }
@@ -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: Long): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,37 @@
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 ORDER BY majorId DESC, minorId DESC")
abstract suspend fun getAll(): List<VkConvoEntity>
@Transaction
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
abstract suspend fun getAllWithMessage(): List<ConvoWithMessage>
@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 id IS (:id)")
abstract suspend fun deleteById(id: Long): Int
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -10,15 +10,21 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages") @Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity> abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:conversationId)") @Query("SELECT * FROM messages WHERE peerId IS (:convoId) ORDER BY date DESC, id DESC")
abstract suspend fun getAll(conversationId: Long): List<VkMessageEntity> abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)") @Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity> abstract suspend fun getAllByIds(ids: List<Long>): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IS (:messageId)") @Query("SELECT * FROM messages WHERE id IS (:messageId)")
abstract suspend fun getById(messageId: Long): VkMessageEntity? abstract suspend fun getById(messageId: Long): VkMessageEntity?
@Query("SELECT * FROM messages WHERE peerId IS (:convoId) AND cmId IS (:cmId)")
abstract suspend fun getByConvoMessageId(convoId: Long, cmId: Long): VkMessageEntity?
@Query("SELECT MAX(id) FROM messages")
abstract suspend fun getMaxId(): Long?
@Query("DELETE FROM messages WHERE id IN (:ids)") @Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Long>): Int
} }
@@ -23,7 +23,7 @@ val databaseModule = module {
single { cacheDB().userDao() } single { cacheDB().userDao() }
single { cacheDB().groupDao() } single { cacheDB().groupDao() }
single { cacheDB().messageDao() } single { cacheDB().messageDao() }
single { cacheDB().conversationDao() } single { cacheDB().convoDao() }
} }
private fun Scope.cacheDB(): CacheDatabase = get() private fun Scope.cacheDB(): CacheDatabase = get()
@@ -4,13 +4,32 @@ import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel 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.properties.Delegates
import kotlin.reflect.KClass 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 { object AppSettings {
private var preferences: SharedPreferences by Delegates.notNull() 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) { fun init(preferences: SharedPreferences) {
this.preferences = preferences this.preferences = preferences
} }
@@ -211,6 +230,21 @@ object AppSettings {
set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value) set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value)
} }
object LongPoll {
var ts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_TS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_TS, value ?: 0)
var pts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_PTS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_PTS, value ?: 0)
fun clear() {
ts = null
pts = null
}
}
object Debug { object Debug {
var showAlertAfterCrash: Boolean var showAlertAfterCrash: Boolean
get() = get( get() = get(
@@ -39,6 +39,8 @@ object SettingsKeys {
const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯" const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯"
const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background" const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background"
const val DEFAULT_LONG_POLL_IN_BACKGROUND = false const val DEFAULT_LONG_POLL_IN_BACKGROUND = false
const val KEY_LONG_POLL_TS = "lp_ts"
const val KEY_LONG_POLL_PTS = "lp_pts"
const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status" const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status"
const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false
+6 -1
View File
@@ -8,11 +8,16 @@ android {
} }
dependencies { dependencies {
api(projects.core.common)
api(projects.core.data) api(projects.core.data)
api(projects.core.model) api(projects.core.model)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
} }
@@ -1,25 +1,28 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ConversationsUseCase : BaseUseCase { interface ConvoUseCase : BaseUseCase {
suspend fun storeConversations(conversations: List<VkConversation>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
fun getConversations( fun getConvos(
count: Int? = null, count: Int? = null,
offset: Int? = null, offset: Int? = null,
filter: ConversationsFilter filter: ConvosFilter
): Flow<State<List<VkConversation>>> ): Flow<State<List<VkConvo>>>
fun getById( fun getById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): Flow<State<List<VkConversation>>> ): Flow<State<List<VkConvo>>>
fun delete(peerId: Long): Flow<State<Long>> fun delete(peerId: Long): Flow<State<Long>>
@@ -1,30 +1,42 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl( class ConvoUseCaseImpl(
private val repository: ConversationsRepository, private val repository: ConvosRepository,
) : ConversationsUseCase { ) : ConvoUseCase {
override suspend fun storeConversations( override suspend fun storeConvos(
conversations: List<VkConversation> convos: List<VkConvo>
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations) repository.storeConvos(convos)
} }
override fun getConversations( override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
repository.getLocalConvos()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
repository.getLocalConvoById(peerId)
}
override suspend fun deleteLocalConvo(peerId: Long) = withContext(Dispatchers.IO) {
repository.deleteLocalConvo(peerId)
}
override fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConversationsFilter filter: ConvosFilter
): Flow<State<List<VkConversation>>> = flowNewState { ): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConversations( repository.getConvos(
count = count, count = count,
offset = offset, offset = offset,
filter = filter filter = filter
@@ -35,8 +47,8 @@ class ConversationsUseCaseImpl(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): Flow<State<List<VkConversation>>> = flowNewState { ): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConversationsById( repository.getConvosById(
peerIds = peerIds, peerIds = peerIds,
extended = extended, extended = extended,
fields = fields fields = fields
@@ -6,10 +6,7 @@ import dev.meloda.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase( class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
private val accountsRepository: AccountsRepository
) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) { suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId) accountsRepository.getAccountById(UserConfig.currentUserId)
} }
@@ -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,22 +1,22 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class LoadConversationsByIdUseCase( class LoadConvosByIdUseCase(
private val conversationsRepository: ConversationsRepository private val convosRepository: ConvosRepository
) : BaseUseCase { ) : BaseUseCase {
operator fun invoke( operator fun invoke(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): Flow<State<List<VkConversation>>> = flowNewState { ): Flow<State<List<VkConvo>>> = flowNewState {
conversationsRepository convosRepository
.getConversationsById( .getConvosById(
peerIds = peerIds, peerIds = peerIds,
extended = extended, extended = extended,
fields = fields, fields = fields,
@@ -0,0 +1,36 @@
package dev.meloda.fast.domain
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
internal class LongPollEventDispatcher {
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> dispatch(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { callback ->
(callback as? VkEventCallback<T>)?.onEvent(event)
}
}
fun dispatchAll(eventType: LongPollEvent, events: List<LongPollParsedEvent>) {
events.forEach { event -> dispatch(eventType, event) }
}
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap[eventType] = (listenersMap[eventType] ?: mutableListOf())
.also { it.add(listener as VkEventCallback<LongPollParsedEvent>) }
}
fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
}
@@ -0,0 +1,537 @@
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.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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class LongPollEventParser(
private val coroutineScope: CoroutineScope,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val dispatch: (LongPollEvent, LongPollParsedEvent) -> Unit,
private val dispatchAll: (LongPollEvent, List<LongPollParsedEvent>) -> Unit
) {
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Unit
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.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
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
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend)
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_SPAM, 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
dispatch(LongPollEvent.MESSAGE_DELETED, eventToSend)
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.AUDIO_MESSAGE_LISTENED, eventToSend)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_SET_FLAGS, eventsToSend)
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
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
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, 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
dispatch(LongPollEvent.MARKED_AS_NOT_SPAM, eventToSend)
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
dispatch(LongPollEvent.MESSAGE_RESTORED, eventToSend)
}
}
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
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 {
dispatch(
LongPollEvent.MESSAGE_NEW,
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
)
)
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_EDITED, LongPollParsedEvent.MessageEdited(message))
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.INCOMING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.IncomingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.OUTGOING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.OutgoingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
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 -> {
handleArchivedChat(
peerId = peerId,
archived = false,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
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 -> {
handleArchivedChat(
peerId = peerId,
archived = true,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_SET_FLAGS, eventsToSend)
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val cmId = event[2].asLong()
dispatch(
LongPollEvent.CHAT_CLEARED,
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val majorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MAJOR_CHANGED,
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val minorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MINOR_CHANGED,
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
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.isEmpty()) return
dispatch(
longPollEvent,
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
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()
dispatch(
LongPollEvent.UNREAD_COUNTER_UPDATE,
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_UPDATED, LongPollParsedEvent.MessageUpdated(message))
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
dispatch(
LongPollEvent.MESSAGE_CACHE_CLEAR,
LongPollParsedEvent.MessageCacheClear(message)
)
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
val job = coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
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("LongPollEventParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
val job = coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollEventParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun handleArchivedChat(
peerId: Long,
archived: Boolean,
eventsToSend: MutableList<LongPollParsedEvent>
) {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = archived
)
eventsToSend += eventToSend
dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend)
}
private fun dispatchMessageRead(
longPollEvent: LongPollEvent,
parsedEvent: LongPollParsedEvent
) {
dispatch(longPollEvent, parsedEvent)
}
}
@@ -1,35 +1,17 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import android.util.Log 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.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConversationFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser( class LongPollUpdatesParser(
private val conversationsUseCase: ConversationsUseCase, convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase messagesUseCase: MessagesUseCase
) { ) {
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -43,747 +25,89 @@ class LongPollUpdatesParser(
get() = Dispatchers.Default + job + exceptionHandler get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val eventDispatcher = LongPollEventDispatcher()
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> = private val eventParser = LongPollEventParser(
mutableMapOf() coroutineScope = coroutineScope,
convoUseCase = convoUseCase,
messagesUseCase = messagesUseCase,
dispatch = eventDispatcher::dispatch,
dispatchAll = eventDispatcher::dispatchAll
)
fun parseNextUpdate(event: List<Any>) { fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt() eventParser.parseNextUpdate(event)
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
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.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(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 conversation =
async {
loadConversation(
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 = conversation?.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 = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.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 = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.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>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
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
listenersMap[longPollEvent]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
.onEvent(
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
}
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
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()
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
.onEvent(
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
}
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>)
.onEvent(LongPollParsedEvent.MessageUpdated(message))
}
}
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
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 suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
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", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
}
private suspend fun loadConversation(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConversation? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
conversationsUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConversation: error: $error")
continuation.resume(null)
},
success = { response ->
val conversation = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(conversation)
}
)
}
}
}
@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 onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) { fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
} }
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) { fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
} }
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) { fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
} }
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) { fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
} }
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) { fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onMessageUpdated(block: (LongPollParsedEvent.MessageUpdated) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_UPDATED, assembleEventCallback(block))
}
fun onMessageCacheClear(block: (LongPollParsedEvent.MessageCacheClear) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_CACHE_CLEAR, assembleEventCallback(block))
} }
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) { fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
} }
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) { fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
} }
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) { fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) { fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) { fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
} }
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) { fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
} }
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) { fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
} }
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) { fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
} }
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) { fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners( eventDispatcher.registerListeners(
eventTypes = listOf( eventTypes = listOf(
LongPollEvent.TYPING, LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING, LongPollEvent.AUDIO_MESSAGE_RECORDING,
@@ -0,0 +1,213 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class LongPollUpdatesReducer(
updatesParser: LongPollUpdatesParser,
private val messagesUseCase: MessagesUseCase,
private val convoUseCase: ConvoUseCase
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<LongPollParsedEvent>(extraBufferCapacity = 256)
val events: SharedFlow<LongPollParsedEvent> = _events.asSharedFlow()
val newMessages = events.filterIsInstance<LongPollParsedEvent.NewMessage>()
val messageEdited = events.filterIsInstance<LongPollParsedEvent.MessageEdited>()
val messageIncomingRead = events.filterIsInstance<LongPollParsedEvent.IncomingMessageRead>()
val messageOutgoingRead = events.filterIsInstance<LongPollParsedEvent.OutgoingMessageRead>()
val messageDeleted = events.filterIsInstance<LongPollParsedEvent.MessageDeleted>()
val messageRestored = events.filterIsInstance<LongPollParsedEvent.MessageRestored>()
val messageMarkedAsImportant = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsImportant>()
val messageMarkedAsSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsSpam>()
val messageMarkedAsNotSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsNotSpam>()
val interactions = events.filterIsInstance<LongPollParsedEvent.Interaction>()
val chatMajorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMajorChanged>()
val chatMinorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMinorChanged>()
val chatCleared = events.filterIsInstance<LongPollParsedEvent.ChatCleared>()
val chatArchived = events.filterIsInstance<LongPollParsedEvent.ChatArchived>()
val messageUpdated = events.filterIsInstance<LongPollParsedEvent.MessageUpdated>()
val messageCacheClear = events.filterIsInstance<LongPollParsedEvent.MessageCacheClear>()
init {
updatesParser.onNewMessage { publish(it) }
updatesParser.onMessageEdited { publish(it) }
updatesParser.onMessageIncomingRead { publish(it) }
updatesParser.onMessageOutgoingRead { publish(it) }
updatesParser.onMessageDeleted { publish(it) }
updatesParser.onMessageRestored { publish(it) }
updatesParser.onMessageUpdated { publish(it) }
updatesParser.onMessageCacheClear { publish(it) }
updatesParser.onMessageMarkedAsImportant { publish(it) }
updatesParser.onMessageMarkedAsSpam { publish(it) }
updatesParser.onMessageMarkedAsNotSpam { publish(it) }
updatesParser.onInteractions { publish(it) }
updatesParser.onChatMajorChanged { publish(it) }
updatesParser.onChatMinorChanged { publish(it) }
updatesParser.onChatCleared { publish(it) }
updatesParser.onChatArchived { publish(it) }
}
private fun publish(event: LongPollParsedEvent) {
scope.launch {
runCatching { applyCommon(event) }
.onFailure { throwable ->
Log.e("LongPollUpdatesReducer", "applyCommon failed: $event", throwable)
}
_events.emit(event)
}
}
private suspend fun applyCommon(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessages(listOf(event.message))
updateConvoForMessage(event.message, unreadIncrement = if (event.message.isOut) 0 else 1)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.IncomingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.MessageDeleted -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
) ?: return
messagesUseCase.storeMessage(message.copy(isImportant = event.marked))
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.ChatMajorChanged -> {
updateConvoSortState(event.peerId, majorId = event.majorId)
}
is LongPollParsedEvent.ChatMinorChanged -> {
updateConvoSortState(event.peerId, minorId = event.minorId)
}
is LongPollParsedEvent.ChatCleared -> {
convoUseCase.deleteLocalConvo(event.peerId)
}
is LongPollParsedEvent.ChatArchived -> {
event.convo.lastMessage?.let(messagesUseCase::storeMessage)
convoUseCase.storeConvos(listOf(event.convo.copy(isArchived = event.archived)))
}
is LongPollParsedEvent.Interaction -> Unit
}
}
private suspend fun updateConvoReadState(
peerId: Long,
inReadCmId: Long? = null,
outReadCmId: Long? = null,
unreadCount: Int
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
inReadCmId = inReadCmId ?: convo.inReadCmId,
outReadCmId = outReadCmId ?: convo.outReadCmId,
unreadCount = unreadCount
)
)
)
}
private suspend fun updateConvoSortState(
peerId: Long,
majorId: Int? = null,
minorId: Int? = null
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
majorId = majorId ?: convo.majorId,
minorId = minorId ?: convo.minorId
)
)
)
}
private suspend fun updateConvoForMessage(
message: VkMessage,
unreadIncrement: Int
) {
val convo = convoUseCase.getLocalConvoById(message.peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = convo.unreadCount + unreadIncrement
)
)
)
}
}
@@ -1,6 +1,7 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -21,4 +22,14 @@ interface LongPollUseCase {
mode: Int, mode: Int,
version: Int version: Int
): Flow<State<LongPollUpdates>> ): Flow<State<LongPollUpdates>>
fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): Flow<State<LongPollHistoryResponse>>
} }
@@ -3,6 +3,7 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.longpoll.LongPollRepository import dev.meloda.fast.data.api.longpoll.LongPollRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -48,4 +49,27 @@ class LongPollUseCaseImpl(
).mapToState() ).mapToState()
emit(newState) emit(newState)
} }
override fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): Flow<State<LongPollHistoryResponse>> = flow {
emit(State.Loading)
val newState = repository.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
).mapToState()
emit(newState)
}
} }
@@ -12,9 +12,14 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
fun getMessagesHistory( fun getMessagesHistory(
conversationId: Long, convoId: Long,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryInfo>> ): Flow<State<MessagesHistoryInfo>>
@@ -32,7 +37,7 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> ): Flow<State<MessagesSendResponse>>
@@ -22,13 +22,33 @@ class MessagesUseCaseImpl(
repository.storeMessages(messages) repository.storeMessages(messages)
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> {
return repository.getLocalMessages(convoId)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? {
return repository.getLocalMessageById(messageId)
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? {
return repository.getLocalMessageByConvoMessageId(convoId, cmId)
}
override suspend fun getLocalMaxMessageId(): Long? {
return repository.getLocalMaxMessageId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
repository.deleteLocalMessages(messageIds)
}
override fun getMessagesHistory( override fun getMessagesHistory(
conversationId: Long, convoId: Long,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState { ): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory( repository.getHistory(
conversationId = conversationId, convoId = convoId,
offset = offset, offset = offset,
count = count count = count
).mapToState() ).mapToState()
@@ -56,7 +76,7 @@ class MessagesUseCaseImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState { ): Flow<State<MessagesSendResponse>> = flowNewState {
@@ -64,7 +84,7 @@ class MessagesUseCaseImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
).mapToState() ).mapToState()
@@ -21,7 +21,8 @@ interface OAuthUseCase {
password: String, password: String,
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String? = null,
captchaKey: String? captchaKey: String? = null,
successToken: String? = null
): Flow<State<GetSilentTokenResponse>> ): Flow<State<GetSilentTokenResponse>>
} }
@@ -22,22 +22,35 @@ class OAuthUseCaseImpl(
): Flow<State<AuthInfo>> = flow { ): Flow<State<AuthInfo>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = oAuthRepository.auth( val newState = when (val authResult = oAuthRepository.auth(
login = login, login = login,
password = password, password = password,
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey captchaKey = captchaKey
).asState( )) {
successMapper = { is com.slack.eithernet.ApiResult.Success -> {
AuthInfo( val value = authResult.value
userId = it.userId!!, val userId = value.userId
accessToken = it.accessToken!!, val accessToken = value.accessToken
validationHash = it.validationHash!! val validationHash = value.validationHash
)
if (userId == null || accessToken == null || validationHash == null) {
State.Error.InternalError
} else {
State.Success(
AuthInfo(
userId = userId,
accessToken = accessToken,
validationHash = validationHash
)
)
}
} }
)
else -> authResult.asState()
}
emit(newState) emit(newState)
} }
@@ -48,7 +61,8 @@ class OAuthUseCaseImpl(
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String? captchaKey: String?,
successToken: String?
): Flow<State<GetSilentTokenResponse>> = flow { ): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading) emit(State.Loading)
@@ -58,7 +72,8 @@ class OAuthUseCaseImpl(
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey captchaKey = captchaKey,
successToken = successToken
).asState() ).asState()
emit(newState) emit(newState)
@@ -6,7 +6,8 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase 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.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase import dev.meloda.fast.domain.StoreUsersUseCase
@@ -26,5 +27,7 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase) singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase) singleOf(::LoadConvosByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
} }
@@ -1,7 +1,6 @@
package dev.meloda.fast.conversations.util package dev.meloda.fast.domain.util
import android.content.res.Resources import android.content.res.Resources
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@@ -13,64 +12,22 @@ import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
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.VkMessage
import dev.meloda.fast.model.api.domain.VkVideoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.pow import kotlin.math.pow
fun VkConversation.asPresentation( fun VkConvo.extractAvatar(): UiImage = when (peerType) {
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConversationOption> = emptyImmutableList()
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(this, useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else getAttachmentConversationIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
fun VkConversation.extractAvatar() = when (peerType) {
PeerType.USER -> { PeerType.USER -> {
if (isAccount(id)) null if (isAccount(id)) null
else user?.photo200 else user?.photo200
@@ -83,20 +40,19 @@ fun VkConversation.extractAvatar() = when (peerType) {
PeerType.CHAT -> { PeerType.CHAT -> {
photo200 photo200
} }
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut) }?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_fill_round_24)
private fun extractTitle( fun VkConvo.extractTitle(
conversation: VkConversation,
useContactName: Boolean, useContactName: Boolean,
resources: Resources resources: Resources
) = when (conversation.peerType) { ) = when (peerType) {
PeerType.USER -> { PeerType.USER -> {
if (isAccount(conversation.id)) { if (isAccount(id)) {
UiText.Resource(R.string.favorites) UiText.Resource(R.string.favorites)
} else { } else {
val userName = conversation.user?.let { user -> val userName = user?.let { user ->
if (useContactName) { if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName VkMemoryCache.getContact(user.id)?.name
} else { } else {
user.fullName user.fullName
} }
@@ -106,22 +62,22 @@ private fun extractTitle(
} }
} }
PeerType.GROUP -> UiText.Simple(conversation.group?.name.orDots()) PeerType.GROUP -> UiText.Simple(group?.name.orDots())
PeerType.CHAT -> UiText.Simple(conversation.title.orDots()) PeerType.CHAT -> UiText.Simple(title.orDots())
}.parseString(resources).orDots() }.parseString(resources).orDots()
private fun extractUnreadCount( fun extractUnreadCount(
lastMessage: VkMessage?, lastMessage: VkMessage?,
conversation: VkConversation convo: VkConvo
): String? = when { ): String? = when {
lastMessage?.isOut == false && conversation.isInRead() -> null lastMessage?.isOut == false && convo.isInRead() -> null
conversation.unreadCount == 0 -> null convo.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString() convo.unreadCount < 1000 -> convo.unreadCount.toString()
else -> { else -> {
val exp = (ln(conversation.unreadCount.toDouble()) / ln(1000.0)).toInt() val exp = (ln(convo.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1] val suffix = "KMBT"[exp - 1]
val result = conversation.unreadCount / 1000.0.pow(exp.toDouble()) val result = convo.unreadCount / 1000.0.pow(exp.toDouble())
if (result.toLong().toDouble() == result) { if (result.toLong().toDouble() == result) {
String.format(Locale.getDefault(), "%.0f%s", result, suffix) String.format(Locale.getDefault(), "%.0f%s", result, suffix)
@@ -131,11 +87,12 @@ private fun extractUnreadCount(
} }
} }
private fun extractMessage( fun extractMessage(
resources: Resources, resources: Resources,
lastMessage: VkMessage?, lastMessage: VkMessage?,
peerId: Long, peerId: Long,
peerType: PeerType peerType: PeerType,
showPeer: Boolean = true
): AnnotatedString { ): AnnotatedString {
val youPrefix = UiText.Resource(R.string.you_message_prefix) val youPrefix = UiText.Resource(R.string.you_message_prefix)
.parseString(resources) .parseString(resources)
@@ -160,6 +117,8 @@ private fun extractMessage(
val messageText = lastMessage?.text.orEmpty() val messageText = lastMessage?.text.orEmpty()
val prefixText: AnnotatedString? = when { val prefixText: AnnotatedString? = when {
!showPeer -> null
actionMessage != null -> null actionMessage != null -> null
lastMessage == null -> null lastMessage == null -> null
@@ -226,16 +185,17 @@ private fun extractMessage(
.let { text -> .let { text ->
extractTextWithVisualizedMentions( extractTextWithVisualizedMentions(
isOut = lastMessage?.isOut == true, isOut = lastMessage?.isOut == true,
originalText = text originalText = text,
formatData = null
) )
} }
.let { text -> prefix + text } .let { text -> prefix + text.orEmpty() }
} }
return finalText return finalText
} }
private fun extractActionText( fun extractActionText(
lastMessage: VkMessage?, lastMessage: VkMessage?,
resources: Resources, resources: Resources,
youPrefix: String youPrefix: String
@@ -510,16 +470,25 @@ private fun extractActionText(
} }
} }
private fun extractAttachmentIcon( fun extractAttachmentIcon(
lastMessage: VkMessage? lastMessage: VkMessage?
): UiImage? = when { ): UiImage? = when {
lastMessage == null -> null lastMessage == null -> null
lastMessage.text == null -> null lastMessage.text == null -> null
lastMessage.geoType != null -> {
val geoType = lastMessage.geoType
if (geoType == "point") {
UiImage.Resource(R.drawable.ic_pin_drop_fill_round_24)
} else {
UiImage.Resource(R.drawable.ic_map_fill_round_24)
}
}
!lastMessage.forwards.isNullOrEmpty() -> { !lastMessage.forwards.isNullOrEmpty() -> {
if (lastMessage.forwards.orEmpty().size == 1) { if (lastMessage.forwards.orEmpty().size == 1) {
UiImage.Resource(R.drawable.ic_attachment_forwarded_message) UiImage.Resource(R.drawable.ic_reply_round_24)
} else { } else {
UiImage.Resource(R.drawable.ic_attachment_forwarded_messages) UiImage.Resource(R.drawable.ic_reply_all_round_24)
} }
} }
@@ -527,19 +496,15 @@ private fun extractAttachmentIcon(
lastMessage.attachments?.let { attachments -> lastMessage.attachments?.let { attachments ->
if (attachments.isEmpty()) return null if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) { if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
lastMessage.geoType?.let {
return UiImage.Resource(R.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type) getAttachmentIconByType(attachments.first().type)
} else { } else {
UiImage.Resource(R.drawable.ic_baseline_attach_file_24) UiImage.Resource(R.drawable.ic_attach_file_round_24)
} }
} }
} }
} }
private fun extractAttachmentText( fun extractAttachmentText(
resources: Resources, resources: Resources,
lastMessage: VkMessage? lastMessage: VkMessage?
): AnnotatedString? = when { ): AnnotatedString? = when {
@@ -605,22 +570,22 @@ private fun extractAttachmentText(
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? { private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
return when (attachmentType) { return when (attachmentType) {
AttachmentType.PHOTO -> R.drawable.ic_attachment_photo AttachmentType.PHOTO -> R.drawable.ic_image_fill_round_24
AttachmentType.VIDEO -> R.drawable.ic_attachment_video AttachmentType.VIDEO -> R.drawable.ic_video_fill_round_24
AttachmentType.AUDIO -> R.drawable.ic_attachment_audio AttachmentType.AUDIO -> R.drawable.ic_music_note_round_24
AttachmentType.FILE -> R.drawable.ic_attachment_file AttachmentType.FILE -> R.drawable.ic_draft_fill_round_24
AttachmentType.LINK -> R.drawable.ic_attachment_link AttachmentType.LINK -> R.drawable.ic_language_round_24
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_attachment_voice AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_mic_fill_round_24
AttachmentType.MINI_APP -> R.drawable.ic_attachment_mini_app AttachmentType.MINI_APP -> R.drawable.ic_widgets_fill_round_24
AttachmentType.STICKER -> R.drawable.ic_attachment_sticker AttachmentType.STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.GIFT -> R.drawable.ic_attachment_gift AttachmentType.GIFT -> R.drawable.ic_attachment_gift_old
AttachmentType.WALL -> R.drawable.ic_attachment_wall AttachmentType.WALL -> R.drawable.ic_brick_fill_round_24
AttachmentType.GRAFFITI -> R.drawable.ic_attachment_graffiti AttachmentType.GRAFFITI -> R.drawable.ic_fragrance_fill_round_24
AttachmentType.POLL -> R.drawable.ic_attachment_poll AttachmentType.POLL -> R.drawable.ic_insert_chart_fill_round_24
AttachmentType.WALL_REPLY -> R.drawable.ic_attachment_wall_reply AttachmentType.WALL_REPLY -> R.drawable.ic_comment_fill_round_24
AttachmentType.CALL -> R.drawable.ic_attachment_call AttachmentType.CALL -> R.drawable.ic_call_round_24
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_attachment_group_call AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_perm_phone_msg_fill_round_24
AttachmentType.STORY -> R.drawable.ic_attachment_story AttachmentType.STORY -> R.drawable.ic_history_toggle_off_round_24
AttachmentType.UNKNOWN -> null AttachmentType.UNKNOWN -> null
AttachmentType.CURATOR -> null AttachmentType.CURATOR -> null
AttachmentType.EVENT -> null AttachmentType.EVENT -> null
@@ -631,7 +596,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.NARRATIVE -> null AttachmentType.NARRATIVE -> null
AttachmentType.ARTICLE -> null AttachmentType.ARTICLE -> null
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_attachment_sticker AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -649,7 +614,7 @@ private fun isAttachmentsHaveOneType(attachments: List<VkAttachment>): Boolean {
return true return true
} }
private fun extractForwardsText( fun extractForwardsText(
resources: Resources, resources: Resources,
lastMessage: VkMessage? lastMessage: VkMessage?
): AnnotatedString? = when { ): AnnotatedString? = when {
@@ -670,69 +635,7 @@ private fun extractForwardsText(
else -> null else -> null
} }
fun extractTextWithVisualizedMentions( fun getAttachmentUiText(
isOut: Boolean,
originalText: String
): AnnotatedString = buildAnnotatedString {
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val result = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
append(result)
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
addStyle(
style = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = mention.idPrefix,
annotation = mention.id.toString(),
start = startIndex,
end = endIndex
)
}
}
data class MentionIndex(
val id: Long,
val idPrefix: String,
val indexRange: IntRange
)
private fun getAttachmentUiText(
attachment: VkAttachment, attachment: VkAttachment,
size: Int = 1, size: Int = 1,
): UiText { ): UiText {
@@ -787,22 +690,8 @@ private fun getAttachmentUiText(
}.let(UiText::Resource) }.let(UiText::Resource)
} }
private fun getAttachmentConversationIcon(message: VkMessage?): UiImage? { fun extractBirthday(convo: VkConvo): Boolean {
return message?.attachments?.let { attachments -> val birthday = convo.user?.birthday ?: return false
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
message.geoType?.let {
return UiImage.Resource(R.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
}
}
}
private fun extractBirthday(conversation: VkConversation): Boolean {
val birthday = conversation.user?.birthday ?: return false
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull) val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
if (splitBirthday.isEmpty()) return false if (splitBirthday.isEmpty()) return false
@@ -822,25 +711,23 @@ private fun extractBirthday(conversation: VkConversation): Boolean {
} else false } else false
} }
private fun extractReadCondition( fun extractReadCondition(
conversation: VkConversation, convo: VkConvo,
lastMessage: VkMessage? lastMessage: VkMessage?
): Boolean = !conversation.isRead(lastMessage) ): Boolean = !convo.isRead(lastMessage)
private fun isAccount(peerId: Long) = peerId == UserConfig.userId fun extractInteractionText(
private fun extractInteractionText(
resources: Resources, resources: Resources,
conversation: VkConversation convo: VkConvo
): String? { ): String? {
val interactionType = InteractionType.parse(conversation.interactionType) val interactionType = InteractionType.parse(convo.interactionType)
val interactiveUsers = extractInteractionUsers(conversation) val interactiveUsers = extractInteractionUsers(convo)
val typingText = val typingText =
if (interactionType == null) { if (interactionType == null) {
null null
} else { } else {
if (!conversation.peerType.isChat() && interactiveUsers.size == 1) { if (!convo.peerType.isChat() && interactiveUsers.size == 1) {
when (interactionType) { when (interactionType) {
InteractionType.File -> R.string.chat_interaction_uploading_file InteractionType.File -> R.string.chat_interaction_uploading_file
InteractionType.Photo -> R.string.chat_interaction_uploading_photo InteractionType.Photo -> R.string.chat_interaction_uploading_photo
@@ -865,8 +752,8 @@ private fun extractInteractionText(
return typingText return typingText
} }
private fun extractInteractionUsers(conversation: VkConversation): List<String> { fun extractInteractionUsers(convo: VkConvo): List<String> {
return conversation.interactionIds.mapNotNull { id -> return convo.interactionIds.mapNotNull { id ->
when { when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name id < 0 -> VkMemoryCache.getGroup(id)?.name
@@ -0,0 +1,49 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.ActionState
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
fun VkConvo.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConvoOption> = emptyImmutableList()
): UiConvo = UiConvo(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
minuteShort = { resources.getString(R.string.minute_short) },
secondShort = { resources.getString(R.string.second_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else extractAttachmentIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend import dev.meloda.fast.ui.model.vk.UiFriend
fun VkUser.asPresentation( fun VkUser.asPresentation(
useContactNames: Boolean = false useContactNames: Boolean = false
@@ -1,34 +1,21 @@
package dev.meloda.fast.messageshistory.util package dev.meloda.fast.domain.util
import android.content.res.Resources import android.content.res.Resources
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.AnnotatedString.Annotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.model.api.PeerType.Companion.getPeerType
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
fun VkMessage.extractAvatar() = when { fun VkMessage.extractAvatar() = when {
isUser() -> { isUser() -> {
if (isAccount(id)) null if (isAccount(id)) null
@@ -40,7 +27,7 @@ fun VkMessage.extractAvatar() = when {
} }
else -> null else -> null
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut) }?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_fill_round_24)
fun VkMessage.extractDate(): String = fun VkMessage.extractDate(): String =
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L) SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
@@ -58,111 +45,15 @@ fun VkMessage.extractTitle(): String = when {
fun VkMessage.extractReplyTitle(): String? = replyMessage?.extractTitle() fun VkMessage.extractReplyTitle(): String? = replyMessage?.extractTitle()
// TODO: 24-Jun-25, Danil Nikolaev: improve fun VkMessage.extractReplySummary(resources: Resources): AnnotatedString? =
fun VkMessage.extractReplySummary(): String? = when (val message = replyMessage) { extractMessage(
null -> null resources = resources,
else -> { lastMessage = this,
when { peerId = peerId,
message.text != null -> message.text peerType = getPeerType(),
else -> null showPeer = false
}
}
}
fun VkConversation.extractAvatar(): UiImage = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
else user?.photo200
}
PeerType.GROUP -> {
group?.photo200
}
PeerType.CHAT -> {
photo200
}
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
fun VkConversation.extractTitle(
useContactName: Boolean,
resources: Resources
) = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) {
UiText.Resource(R.string.favorites)
} else {
val userName = user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name
} else {
user.fullName
}
}
UiText.Simple(userName.orDots())
}
}
PeerType.GROUP -> UiText.Simple(group?.name.orDots())
PeerType.CHAT -> UiText.Simple(title.orDots())
}.parseString(resources).orDots()
fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): UiItem = when {
action != null -> UiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionConversationMessageId
) )
else -> UiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null },
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = extractReplySummary()
)
}
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean { fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId return nextMessage == null || nextMessage.fromId != fromId
@@ -568,153 +459,3 @@ fun VkMessage.extractActionText(
} }
} }
} }
// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
start = startIndex,
end = endIndex
)
}
if (formatData == null) return AnnotatedString(text = newText, annotations = annotations)
var current = 0
val newOffsets = formatData.items.map { (offset, length) ->
val r = replacements.filter { (range, _) ->
(range - current) collidesWith (offset..<offset + length) || offset > range.first
}
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
offset + current
}
formatData.items.forEachIndexed { index, item ->
val offset = newOffsets[index]
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> {
annotations += AnnotatedString.Range(
item = StringAnnotation(item.url.orEmpty()),
start = offset,
end = offset + item.length,
tag = newText.substring(offset, offset + item.length)
)
if (isOut) {
SpanStyle(
fontWeight = FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
)
} else {
SpanStyle(
fontWeight = FontWeight.SemiBold,
color = Color.Red
)
}
}
}
annotations += AnnotatedString.Range(
item = spanStyle,
start = offset,
end = offset + item.length
)
}
return AnnotatedString(text = newText, annotations = annotations)
}
data class MentionIndex(
val id: Long,
val idPrefix: String,
val indexRange: IntRange
)
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)
}
@@ -0,0 +1,65 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
fun VkMessage.asPresentation(
convo: VkConvo,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): MessageUiItem = when {
action != null -> MessageUiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionCmId
)
else -> MessageUiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(convo),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = replyMessage?.extractReplySummary(resourceProvider.resources)
)
}
@@ -0,0 +1,177 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.collidesWith
import dev.meloda.fast.common.extensions.minus
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MentionIndex
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun emptyAnnotatedString(): AnnotatedString = AnnotatedString(text = "")
fun AnnotatedString?.orEmpty(): AnnotatedString = this ?: emptyAnnotatedString()
fun String.annotated(): AnnotatedString = AnnotatedString(text = this)
fun isAccount(id: Long) = id == UserConfig.userId
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out androidx.compose.ui.text.AnnotatedString.Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
start = startIndex,
end = endIndex
)
}
if (formatData == null) {
return AnnotatedString(text = newText, annotations = annotations)
}
var current = 0
val newOffsets = formatData.items.map { (offset, length) ->
val r = replacements.filter { (range, _) ->
(range - current) collidesWith (offset..<offset + length) || offset > range.first
}
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
offset + current
}
formatData.items.forEachIndexed { index, item ->
val offset = newOffsets[index]
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> {
annotations += AnnotatedString.Range(
item = StringAnnotation(item.url.orEmpty()),
start = offset,
end = offset + item.length,
tag = newText.substring(offset, offset + item.length)
)
if (isOut) {
SpanStyle(
fontWeight = FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
)
} else {
SpanStyle(
fontWeight = FontWeight.SemiBold,
color = Color.Red
)
}
}
}
annotations += AnnotatedString.Range(
item = spanStyle,
start = offset,
end = offset + item.length
)
}
return AnnotatedString(text = newText, annotations = annotations)
}
fun List<MessageUiItem>.firstMessage(): MessageUiItem.Message =
filterIsInstance<MessageUiItem.Message>().first()
fun List<MessageUiItem>.firstMessageOrNull(): MessageUiItem.Message? =
filterIsInstance<MessageUiItem.Message>().firstOrNull()
fun List<MessageUiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<MessageUiItem>.findMessageById(messageId: Long): MessageUiItem.Message? =
firstOrNull { it.id == messageId } as MessageUiItem.Message?
fun List<MessageUiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<MessageUiItem>.findMessageByCmId(cmId: Long): MessageUiItem.Message =
first { it.cmId == cmId } as MessageUiItem.Message
+2 -2
View File
@@ -4,7 +4,7 @@ plugins {
} }
android { android {
namespace = "dev.meloda.fast.datastore" namespace = "dev.meloda.fast.model"
} }
dependencies { dependencies {
@@ -12,7 +12,7 @@ dependencies {
ksp(libs.moshi.kotlin.codegen) ksp(libs.moshi.kotlin.codegen)
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose) implementation(libs.compose.ui)
implementation(libs.room.ktx) implementation(libs.room.ktx)
implementation(libs.room.runtime) implementation(libs.room.runtime)
@@ -1,6 +1,6 @@
package dev.meloda.fast.model package dev.meloda.fast.model
enum class ConversationFlags(val value: Int) { enum class ConvoFlags(val value: Int) {
DISABLE_PUSH(16), DISABLE_PUSH(16),
DISABLE_SOUND(32), DISABLE_SOUND(32),
INCOMING_CHAT_REQUEST(256), INCOMING_CHAT_REQUEST(256),
@@ -17,10 +17,10 @@ enum class ConversationFlags(val value: Int) {
companion object { companion object {
fun parse(mask: Int): List<ConversationFlags> { fun parse(mask: Int): List<ConvoFlags> {
val flags = mutableListOf<ConversationFlags>() val flags = mutableListOf<ConvoFlags>()
ConversationFlags.entries.forEach { flag -> ConvoFlags.entries.forEach { flag ->
if (mask and flag.value > 0) { if (mask and flag.value > 0) {
flags.add(flag) flags.add(flag)
} }
@@ -1,5 +1,5 @@
package dev.meloda.fast.model package dev.meloda.fast.model
enum class ConversationsFilter { enum class ConvosFilter {
ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY
} }
@@ -1,6 +1,6 @@
package dev.meloda.fast.model package dev.meloda.fast.model
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.VkMessage
sealed interface LongPollParsedEvent { sealed interface LongPollParsedEvent {
@@ -92,7 +92,7 @@ sealed interface LongPollParsedEvent {
) : LongPollParsedEvent ) : LongPollParsedEvent
data class ChatArchived( data class ChatArchived(
val conversation: VkConversation, val convo: VkConvo,
val archived: Boolean val archived: Boolean
) : LongPollParsedEvent ) : LongPollParsedEvent
} }
@@ -1,5 +1,7 @@
package dev.meloda.fast.model.api package dev.meloda.fast.model.api
import dev.meloda.fast.model.api.domain.VkMessage
enum class PeerType(val value: String) { enum class PeerType(val value: String) {
USER("user"), USER("user"),
GROUP("group"), GROUP("group"),
@@ -13,5 +15,14 @@ enum class PeerType(val value: String) {
fun parse(type: String): PeerType { fun parse(type: String): PeerType {
return entries.first { it.value == type } return entries.first { it.value == type }
} }
fun VkMessage.getPeerType(): PeerType {
return when {
peerId > 2_000_000_000 -> CHAT
peerId > 0 -> USER
peerId < 0 -> GROUP
else -> throw IllegalArgumentException("Unknown peer type for peerId: 0")
}
}
} }
} }
@@ -0,0 +1,24 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LongPollHistoryResponse(
@Json(name = "history") val history: List<List<Any>>? = null,
@Json(name = "messages") val messages: Messages? = null,
@Json(name = "profiles") val profiles: List<VkUserData>? = null,
@Json(name = "groups") val groups: List<VkGroupData>? = null,
@Json(name = "new_pts") val newPts: Int? = null,
@Json(name = "from_pts") val fromPts: Int? = null,
@Json(name = "ts") val ts: Int? = null,
@Json(name = "pts") val pts: Int? = null,
@Json(name = "more") val more: Int? = null,
@Json(name = "conversations") val conversations: List<VkConvoData>? = null
) {
@JsonClass(generateAdapter = true)
data class Messages(
@Json(name = "count") val count: Int? = null,
@Json(name = "items") val items: List<VkMessageData>? = null
)
}
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
data class VkAttachmentHistoryMessageData( data class VkAttachmentHistoryMessageData(
@Json(name = "message_id") val messageId: Long, @Json(name = "message_id") val messageId: Long,
@Json(name = "date") val date: Int, @Json(name = "date") val date: Int,
@Json(name = "cmid") val conversationMessageId: Long, @Json(name = "cmid") val cmId: Long,
@Json(name = "from_id") val fromId: Long, @Json(name = "from_id") val fromId: Long,
@Json(name = "position") val position: Int, @Json(name = "position") val position: Int,
@Json(name = "attachment") val attachment: VkAttachmentItemData @Json(name = "attachment") val attachment: VkAttachmentItemData
@@ -16,7 +16,7 @@ data class VkAttachmentHistoryMessageData(
fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage( fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage(
messageId = messageId, messageId = messageId,
conversationMessageId = conversationMessageId, cmId = cmId,
date = date, date = date,
fromId = fromId, fromId = fromId,
position = position, position = position,
@@ -3,19 +3,19 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
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.VkMessage
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkConversationData( data class VkConvoData(
@Json(name = "peer") val peer: Peer, @Json(name = "peer") val peer: Peer,
@Json(name = "last_message_id") val lastMessageId: Long?, @Json(name = "last_message_id") val lastMessageId: Long?,
@Json(name = "in_read") val inRead: Long, @Json(name = "in_read") val inRead: Long,
@Json(name = "out_read") val outRead: Long, @Json(name = "out_read") val outRead: Long,
@Json(name = "in_read_cmid") val inReadConversationMessageId: Long, @Json(name = "in_read_cmid") val inReadCmId: Long,
@Json(name = "out_read_cmid") val outReadConversationMessageId: Long, @Json(name = "out_read_cmid") val outReadCmId: Long,
@Json(name = "sort_id") val sortId: SortId, @Json(name = "sort_id") val sortId: SortId,
@Json(name = "last_conversation_message_id") val lastConversationMessageId: Long, @Json(name = "last_conversation_message_id") val lastCmId: Long,
@Json(name = "is_marked_unread") val isMarkedUnread: Boolean, @Json(name = "is_marked_unread") val isMarkedUnread: Boolean,
@Json(name = "important") val important: Boolean, @Json(name = "important") val important: Boolean,
@Json(name = "push_settings") val pushSettings: PushSettings?, @Json(name = "push_settings") val pushSettings: PushSettings?,
@@ -111,7 +111,7 @@ data class VkConversationData(
fun asDomain( fun asDomain(
lastMessage: VkMessage? = null, lastMessage: VkMessage? = null,
): VkConversation = VkConversation( ): VkConvo = VkConvo(
id = peer.id, id = peer.id,
localId = peer.localId, localId = peer.localId,
title = chatSettings?.title, title = chatSettings?.title,
@@ -120,7 +120,7 @@ data class VkConversationData(
photo200 = chatSettings?.photo?.photo200, photo200 = chatSettings?.photo?.photo200,
isCallInProgress = callInProgress != null, isCallInProgress = callInProgress != null,
isPhantom = chatSettings?.isDisappearing == true, isPhantom = chatSettings?.isDisappearing == true,
lastCmId = lastConversationMessageId, lastCmId = lastCmId,
inRead = inRead, inRead = inRead,
outRead = outRead, outRead = outRead,
lastMessageId = lastMessageId, lastMessageId = lastMessageId,
@@ -132,8 +132,8 @@ data class VkConversationData(
canChangePin = chatSettings?.acl?.canChangePin == true, canChangePin = chatSettings?.acl?.canChangePin == true,
canChangeInfo = chatSettings?.acl?.canChangeInfo == true, canChangeInfo = chatSettings?.acl?.canChangeInfo == true,
pinnedMessageId = chatSettings?.pinnedMessage?.id, pinnedMessageId = chatSettings?.pinnedMessage?.id,
inReadCmId = inReadConversationMessageId, inReadCmId = inReadCmId,
outReadCmId = outReadConversationMessageId, outReadCmId = outReadCmId,
interactionType = -1, interactionType = -1,
interactionIds = emptyList(), interactionIds = emptyList(),
peerType = PeerType.parse(peer.type), peerType = PeerType.parse(peer.type),
@@ -56,7 +56,7 @@ data class VkMessageData(
@Json(name = "type") val type: String, @Json(name = "type") val type: String,
@Json(name = "member_id") val memberId: Long?, @Json(name = "member_id") val memberId: Long?,
@Json(name = "text") val text: String?, @Json(name = "text") val text: String?,
@Json(name = "conversation_message_id") val conversationMessageId: Long?, @Json(name = "conversation_message_id") val cmId: Long?,
@Json(name = "message") val message: String? @Json(name = "message") val message: String?
) )
@@ -102,7 +102,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
action = VkMessage.Action.parse(action?.type), action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId, actionMemberId = action?.memberId,
actionText = action?.text, actionText = action?.text,
actionConversationMessageId = action?.conversationMessageId, actionCmId = action?.cmId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
isImportant = important == true, isImportant = important == true,
@@ -12,7 +12,7 @@ data class VkPinnedMessageData(
@Json(name = "from_id") val fromId: Long, @Json(name = "from_id") val fromId: Long,
@Json(name = "out") val out: Boolean?, @Json(name = "out") val out: Boolean?,
@Json(name = "text") val text: String, @Json(name = "text") val text: String,
@Json(name = "conversation_message_id") val conversationMessageId: Long, @Json(name = "conversation_message_id") val cmId: Long,
@Json(name = "fwd_messages") val forwards: List<VkMessageData>?, @Json(name = "fwd_messages") val forwards: List<VkMessageData>?,
@Json(name = "important") val important: Boolean = false, @Json(name = "important") val important: Boolean = false,
@Json(name = "random_id") val randomId: Long = 0, @Json(name = "random_id") val randomId: Long = 0,
@@ -28,7 +28,7 @@ data class VkPinnedMessageData(
fun mapToDomain(): VkMessage = VkMessage( fun mapToDomain(): VkMessage = VkMessage(
id = id ?: -1, id = id ?: -1,
cmId = conversationMessageId, cmId = cmId,
text = text.ifBlank { null }, text = text.ifBlank { null },
isOut = out == true, isOut = out == true,
peerId = peerId ?: -1, peerId = peerId ?: -1,
@@ -38,7 +38,7 @@ data class VkPinnedMessageData(
action = VkMessage.Action.parse(action?.type), action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId, actionMemberId = action?.memberId,
actionText = action?.text, actionText = action?.text,
actionConversationMessageId = action?.conversationMessageId, actionCmId = action?.cmId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
isImportant = important, isImportant = important,
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkWidgetData( data class VkWidgetData(
val id: Long val id: Long?
) : VkAttachmentData { ) : VkAttachmentData {
fun toDomain() = VkWidgetDomain(id) fun toDomain() = VkWidgetDomain(id)
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
@Immutable
interface VkAttachment { interface VkAttachment {
val type: AttachmentType val type: AttachmentType
} }
@@ -2,7 +2,7 @@ package dev.meloda.fast.model.api.domain
data class VkAttachmentHistoryMessage( data class VkAttachmentHistoryMessage(
val messageId: Long, val messageId: Long,
val conversationMessageId: Long, val cmId: Long,
val date: Int, val date: Int,
val fromId: Long, val fromId: Long,
val position: Int, val position: Int,
@@ -1,9 +1,9 @@
package dev.meloda.fast.model.api.domain package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.database.VkConversationEntity import dev.meloda.fast.model.database.VkConvoEntity
data class VkConversation( data class VkConvo(
val id: Long, val id: Long,
val localId: Long, val localId: Long,
val ownerId: Long?, val ownerId: Long?,
@@ -54,7 +54,7 @@ data class VkConversation(
} }
companion object { companion object {
val EMPTY: VkConversation = VkConversation( val EMPTY: VkConvo = VkConvo(
id = -1, id = -1,
localId = -1, localId = -1,
ownerId = null, ownerId = null,
@@ -90,7 +90,7 @@ data class VkConversation(
} }
} }
fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity( fun VkConvo.asEntity(): VkConvoEntity = VkConvoEntity(
id = id, id = id,
localId = localId, localId = localId,
ownerId = ownerId, ownerId = ownerId,
@@ -99,7 +99,7 @@ fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
photo100 = photo100, photo100 = photo100,
photo200 = photo200, photo200 = photo200,
isPhantom = isPhantom, isPhantom = isPhantom,
lastConversationMessageId = lastCmId, lastCmId = lastCmId,
inReadCmId = inReadCmId, inReadCmId = inReadCmId,
outReadCmId = outReadCmId, outReadCmId = outReadCmId,
inRead = inRead, inRead = inRead,
@@ -16,7 +16,7 @@ data class VkMessage(
val action: Action?, val action: Action?,
val actionMemberId: Long?, val actionMemberId: Long?,
val actionText: String?, val actionText: String?,
val actionConversationMessageId: Long?, val actionCmId: Long?,
val actionMessage: String?, val actionMessage: String?,
val updateTime: Int?, val updateTime: Int?,
@@ -44,9 +44,9 @@ data class VkMessage(
fun isGroup() = fromId < 0 fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation): Boolean = when { fun isRead(convo: VkConvo): Boolean = when {
id <= 0 -> false id <= 0 -> false
else -> conversation.isRead(this) else -> convo.isRead(this)
} }
fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty() fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty()
@@ -98,7 +98,7 @@ data class VkMessage(
fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity( fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
id = id, id = id,
conversationMessageId = cmId, cmId = cmId,
text = text, text = text,
isOut = isOut, isOut = isOut,
peerId = peerId, peerId = peerId,
@@ -108,7 +108,7 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
action = action?.value, action = action?.value,
actionMemberId = actionMemberId, actionMemberId = actionMemberId,
actionText = actionText, actionText = actionText,
actionConversationMessageId = actionConversationMessageId, actionCmId = actionCmId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
important = isImportant, important = isImportant,
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
data class VkWidgetDomain( data class VkWidgetDomain(
val id: Long val id: Long?
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.WIDGET override val type: AttachmentType = AttachmentType.WIDGET
@@ -1,12 +1,12 @@
package dev.meloda.fast.model.api.requests package dev.meloda.fast.model.api.requests
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConvosFilter
data class ConversationsGetRequest( data class ConvosGetRequest(
val count: Int? = null, val count: Int? = null,
val offset: Int? = null, val offset: Int? = null,
val fields: String = "", val fields: String = "",
val filter: ConversationsFilter = ConversationsFilter.ALL, val filter: ConvosFilter = ConvosFilter.ALL,
val extended: Boolean? = true, val extended: Boolean? = true,
val startMessageId: Long? = null val startMessageId: Long? = null
) { ) {
@@ -19,3 +19,27 @@ data class LongPollGetUpdatesRequest(
"version" to version.toString() "version" to version.toString()
) )
} }
data class LongPollGetHistoryRequest(
val ts: Int,
val pts: Int,
val lpVersion: Int,
val lastN: Int? = null,
val maxMsgId: Long? = null,
val eventsLimit: Int? = null,
val msgsLimit: Int? = null,
val extended: Boolean = true,
) {
val map: Map<String, String>
get() = mutableMapOf(
"ts" to ts.toString(),
"pts" to pts.toString(),
"lp_version" to lpVersion.toString(),
"extended" to if (extended) "1" else "0",
).apply {
lastN?.let { this["last_n"] = it.toString() }
maxMsgId?.let { this["max_msg_id"] = it.toString() }
eventsLimit?.let { this["events_limit"] = it.toString() }
msgsLimit?.let { this["msgs_limit"] = it.toString() }
}
}
@@ -34,7 +34,7 @@ data class MessagesSendRequest(
val message: String?, val message: String?,
val lat: Int? = null, val lat: Int? = null,
val lon: Int? = null, val lon: Int? = null,
val replyTo: Long? = null, val forward: String? = null,
val stickerId: Long? = null, val stickerId: Long? = null,
val disableMentions: Boolean? = null, val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null, val doNotParseLinks: Boolean? = null,
@@ -51,7 +51,7 @@ data class MessagesSendRequest(
message?.let { this["message"] = it } message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() } lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() } lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() } forward?.let { this["forward"] = it }
stickerId?.let { this["sticker_id"] = it.toString() } stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() } disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() } doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
@@ -115,7 +115,7 @@ data class MessagesGetLongPollServerRequest(
data class MessagesPinMessageRequest( data class MessagesPinMessageRequest(
val peerId: Long, val peerId: Long,
val messageId: Long? = null, val messageId: Long? = null,
val conversationMessageId: Long? = null val cmId: Long? = null
) { ) {
val map: Map<String, String> val map: Map<String, String>
@@ -123,7 +123,7 @@ data class MessagesPinMessageRequest(
"peer_id" to peerId.toString() "peer_id" to peerId.toString()
).apply { ).apply {
messageId?.let { this["message_id"] = it.toString() } messageId?.let { this["message_id"] = it.toString() }
conversationMessageId?.let { this["conversation_message_id"] = it.toString() } cmId?.let { this["conversation_message_id"] = it.toString() }
} }
} }
@@ -136,7 +136,7 @@ data class MessagesUnpinMessageRequest(val peerId: Long) {
data class MessagesDeleteRequest( data class MessagesDeleteRequest(
val peerId: Long, val peerId: Long,
val messagesIds: List<Long>? = null, val messagesIds: List<Long>? = null,
val conversationsMessagesIds: List<Long>? = null, val cmIds: List<Long>? = null,
val isSpam: Boolean? = null, val isSpam: Boolean? = null,
val deleteForAll: Boolean? = null val deleteForAll: Boolean? = null
) { ) {
@@ -149,7 +149,7 @@ data class MessagesDeleteRequest(
deleteForAll?.let { this["delete_for_all"] = it.asInt().toString() } deleteForAll?.let { this["delete_for_all"] = it.asInt().toString() }
messagesIds?.let { this["message_ids"] = it.joinToString() } messagesIds?.let { this["message_ids"] = it.joinToString() }
conversationsMessagesIds?.let { cmIds?.let {
this["conversation_message_ids"] = it.joinToString() this["conversation_message_ids"] = it.joinToString()
} }
} }
@@ -228,7 +228,7 @@ data class MessagesGetChatRequest(
} }
data class MessagesGetConversationMembersRequest( data class MessagesGetConvoMembersRequest(
val peerId: Long, val peerId: Long,
val offset: Int? = null, val offset: Int? = null,
val count: Int? = null, val count: Int? = null,
@@ -267,14 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
val offset: Int?, val offset: Int?,
val preserveOrder: Boolean?, val preserveOrder: Boolean?,
val attachmentTypes: List<String>, val attachmentTypes: List<String>,
val conversationMessageId: Long, val cmId: Long,
val fields: String? val fields: String?
) { ) {
val map = mutableMapOf( val map = mutableMapOf(
"peer_id" to peerId.toString(), "peer_id" to peerId.toString(),
"attachment_types" to attachmentTypes.joinToString(","), "attachment_types" to attachmentTypes.joinToString(","),
"cmid" to conversationMessageId.toString() "cmid" to cmId.toString()
).apply { ).apply {
extended?.let { this["extended"] = it.toString() } extended?.let { this["extended"] = it.toString() }
count?.let { this["count"] = it.toString() } count?.let { this["count"] = it.toString() }
@@ -12,7 +12,8 @@ data class AuthDirectRequest(
val validationCode: String? = null, val validationCode: String? = null,
val captchaSid: String? = null, val captchaSid: String? = null,
val captchaKey: String? = null, val captchaKey: String? = null,
val trustedHash: String? = null val trustedHash: String? = null,
val successToken: String? = null
) { ) {
val map val map
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
captchaSid?.let { this["captcha_sid"] = it } captchaSid?.let { this["captcha_sid"] = it }
captchaKey?.let { this["captcha_key"] = it } captchaKey?.let { this["captcha_key"] = it }
trustedHash?.let { this["trusted_hash"] = it } trustedHash?.let { this["trusted_hash"] = it }
successToken?.let { this["success_token"] = it }
} }
} }
@@ -3,15 +3,15 @@ package dev.meloda.fast.model.api.responses
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkConversationData import dev.meloda.fast.model.api.data.VkConvoData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsGetResponse( data class ConvosGetResponse(
@Json(name = "count") val count: Int, @Json(name = "count") val count: Int,
@Json(name = "items") val items: List<ConversationsResponseItem>, @Json(name = "items") val items: List<ConvosResponseItem>,
@Json(name = "unread_count") val unreadCount: Int?, @Json(name = "unread_count") val unreadCount: Int?,
@Json(name = "profiles") val profiles: List<VkUserData>?, @Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?, @Json(name = "groups") val groups: List<VkGroupData>?,
@@ -19,21 +19,21 @@ data class ConversationsGetResponse(
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsGetByIdResponse( data class ConvosGetByIdResponse(
@Json(name = "count") val count: Int, @Json(name = "count") val count: Int,
@Json(name = "items") val items: List<VkConversationData>, @Json(name = "items") val items: List<VkConvoData>,
@Json(name = "profiles") val profiles: List<VkUserData>?, @Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?, @Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>? @Json(name = "contacts") val contacts: List<VkContactData>?
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsResponseItem( data class ConvosResponseItem(
@Json(name = "conversation") val conversation: VkConversationData, @Json(name = "conversation") val convo: VkConvoData,
@Json(name = "last_message") val lastMessage: VkMessageData? @Json(name = "last_message") val lastMessage: VkMessageData?
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsDeleteResponse( data class ConvosDeleteResponse(
@Json(name = "last_deleted_id") val lastDeletedId: Long @Json(name = "last_deleted_id") val lastDeletedId: Long
) )
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkConversationData import dev.meloda.fast.model.api.data.VkConvoData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
@@ -14,7 +14,7 @@ import dev.meloda.fast.model.api.data.VkUserData
data class MessagesGetHistoryResponse( data class MessagesGetHistoryResponse(
val count: Int, val count: Int,
val items: List<VkMessageData>, val items: List<VkMessageData>,
val conversations: List<VkConversationData>?, val convos: List<VkConvoData>?,
val profiles: List<VkUserData>?, val profiles: List<VkUserData>?,
val groups: List<VkGroupData>?, val groups: List<VkGroupData>?,
val contacts: List<VkContactData>? val contacts: List<VkContactData>?
@@ -30,7 +30,7 @@ data class MessagesGetByIdResponse(
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MessagesGetConversationMembersResponse( data class MessagesGetConvoMembersResponse(
val count: Int, val count: Int,
val items: List<VkChatMemberData>?, val items: List<VkChatMemberData>?,
val profiles: List<VkUserData>?, val profiles: List<VkUserData>?,
@@ -70,3 +70,10 @@ data class MessagesMarkAsImportantResponse(
@Json(name = "peer_id") val peerId: Long @Json(name = "peer_id") val peerId: Long
) )
} }
@JsonClass(generateAdapter = true)
data class MessagesGetReadPeersResponse(
@Json(name = "items") val items: List<Long>,
@Json(name = "total_count") val totalCount: Int,
@Json(name = "profiles") val profiles: List<VkUserData>?,
)
@@ -3,8 +3,8 @@ package dev.meloda.fast.model.database
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Relation import androidx.room.Relation
data class ConversationWithMessage( data class ConvoWithMessage(
@Embedded val conversation: VkConversationEntity, @Embedded val convo: VkConvoEntity,
@Relation( @Relation(
parentColumn = "lastMessageId", parentColumn = "lastMessageId",
entityColumn = "id" entityColumn = "id"
@@ -3,10 +3,10 @@ package dev.meloda.fast.model.database
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConvo
@Entity(tableName = "conversations") @Entity(tableName = "convos")
data class VkConversationEntity( data class VkConvoEntity(
@PrimaryKey val id: Long, @PrimaryKey val id: Long,
val localId: Long, val localId: Long,
val ownerId: Long?, val ownerId: Long?,
@@ -15,7 +15,7 @@ data class VkConversationEntity(
val photo100: String?, val photo100: String?,
val photo200: String?, val photo200: String?,
val isPhantom: Boolean, val isPhantom: Boolean,
val lastConversationMessageId: Long, val lastCmId: Long,
val inReadCmId: Long, val inReadCmId: Long,
val outReadCmId: Long, val outReadCmId: Long,
val inRead: Long, val inRead: Long,
@@ -32,7 +32,7 @@ data class VkConversationEntity(
val isArchived: Boolean val isArchived: Boolean
) )
fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation( fun VkConvoEntity.asExternalModel(): VkConvo = VkConvo(
id = id, id = id,
localId = localId, localId = localId,
ownerId = ownerId, ownerId = ownerId,
@@ -42,7 +42,7 @@ fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation(
photo200 = photo200, photo200 = photo200,
isCallInProgress = false, isCallInProgress = false,
isPhantom = isPhantom, isPhantom = isPhantom,
lastCmId = lastConversationMessageId, lastCmId = lastCmId,
inReadCmId = inReadCmId, inReadCmId = inReadCmId,
outReadCmId = outReadCmId, outReadCmId = outReadCmId,
inRead = inRead, inRead = inRead,

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