Browse Source

Merge pull request #3952 from nextcloud/feature/217/offlineSupport

Offline support
Marcel Hibbe 10 months ago
parent
commit
1e257fc150
100 changed files with 5869 additions and 1531 deletions
  1. 1 0
      .idea/inspectionProfiles/ktlint.xml
  2. 26 4
      app/build.gradle
  3. 565 2
      app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/10.json
  4. 719 0
      app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/11.json
  5. 233 0
      app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt
  6. 269 0
      app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt
  7. 1 1
      app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
  8. 120 30
      app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt
  9. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/CallStartedViewHolder.kt
  10. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/CommonMessageInterface.kt
  11. 63 32
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLinkPreviewMessageViewHolder.kt
  12. 62 32
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLocationMessageViewHolder.kt
  13. 61 32
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPollMessageViewHolder.kt
  14. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPreviewMessageViewHolder.java
  15. 79 40
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt
  16. 63 32
      app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt
  17. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/LinkPreview.kt
  18. 54 26
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingLinkPreviewMessageViewHolder.kt
  19. 53 26
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingLocationMessageViewHolder.kt
  20. 53 26
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingPollMessageViewHolder.kt
  21. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingPreviewMessageViewHolder.java
  22. 61 32
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt
  23. 56 26
      app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt
  24. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageInterface.kt
  25. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt
  26. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/Reaction.kt
  27. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageInterface.kt
  28. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt
  29. 3 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java
  30. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/UnreadNoticeMessageViewHolder.java
  31. 1 1
      app/src/main/java/com/nextcloud/talk/adapters/messages/VoiceMessageInterface.kt
  32. 3 1
      app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt
  33. 237 330
      app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
  34. 84 1
      app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
  35. 70 0
      app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt
  36. 7 127
      app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt
  37. 2 2
      app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt
  38. 623 0
      app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt
  39. 3 4
      app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt
  40. 148 121
      app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
  41. 48 5
      app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt
  42. 7 7
      app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt
  43. 1 1
      app/src/main/java/com/nextcloud/talk/contacts/ContactsActivityCompose.kt
  44. 2 2
      app/src/main/java/com/nextcloud/talk/conversation/CreateConversationDialogFragment.kt
  45. 5 2
      app/src/main/java/com/nextcloud/talk/conversation/repository/ConversationRepository.kt
  46. 22 21
      app/src/main/java/com/nextcloud/talk/conversation/repository/ConversationRepositoryImpl.kt
  47. 2 2
      app/src/main/java/com/nextcloud/talk/conversation/viewmodel/ConversationViewModel.kt
  48. 17 16
      app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
  49. 2 2
      app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt
  50. 7 7
      app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt
  51. 7 8
      app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt
  52. 3 3
      app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt
  53. 2 2
      app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt
  54. 148 174
      app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
  55. 0 9
      app/src/main/java/com/nextcloud/talk/conversationlist/data/ConversationsListRepository.kt
  56. 0 11
      app/src/main/java/com/nextcloud/talk/conversationlist/data/ConversationsListRepositoryImpl.kt
  57. 39 0
      app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt
  58. 16 0
      app/src/main/java/com/nextcloud/talk/conversationlist/data/network/ConversationsNetworkDataSource.kt
  59. 118 0
      app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt
  60. 28 0
      app/src/main/java/com/nextcloud/talk/conversationlist/data/network/RetrofitConversationsNetwork.kt
  61. 26 5
      app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt
  62. 27 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/DaosModule.kt
  63. 8 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java
  64. 53 11
      app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt
  65. 101 0
      app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt
  66. 143 0
      app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt
  67. 49 0
      app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt
  68. 93 0
      app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt
  69. 162 0
      app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt
  70. 41 0
      app/src/main/java/com/nextcloud/talk/data/database/model/ChatBlockEntity.kt
  71. 69 0
      app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt
  72. 111 0
      app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt
  73. 17 0
      app/src/main/java/com/nextcloud/talk/data/network/NetworkMonitor.kt
  74. 83 0
      app/src/main/java/com/nextcloud/talk/data/network/NetworkMonitorImpl.kt
  75. 124 0
      app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt
  76. 31 7
      app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt
  77. 38 0
      app/src/main/java/com/nextcloud/talk/data/source/local/converters/ArrayListConverter.kt
  78. 3 3
      app/src/main/java/com/nextcloud/talk/data/source/local/converters/HashMapHashMapConverter.kt
  79. 59 0
      app/src/main/java/com/nextcloud/talk/data/source/local/converters/LinkedHashMapConverter.kt
  80. 50 0
      app/src/main/java/com/nextcloud/talk/data/source/local/converters/LinkedHashMapStringIntConverter.kt
  81. 7 7
      app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt
  82. 13 8
      app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java
  83. 5 5
      app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
  84. 108 96
      app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt
  85. 1 1
      app/src/main/java/com/nextcloud/talk/models/domain/ReactionAddedModel.kt
  86. 1 1
      app/src/main/java/com/nextcloud/talk/models/domain/ReactionDeletedModel.kt
  87. 13 13
      app/src/main/java/com/nextcloud/talk/models/domain/converters/DomainEnumNotificationLevelConverter.kt
  88. 46 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt
  89. 1 1
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.kt
  90. 1 1
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCSSingleMessage.kt
  91. 1 2
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.kt
  92. 1 1
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatUtils.kt
  93. 25 55
      app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt
  94. 48 0
      app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt
  95. 11 11
      app/src/main/java/com/nextcloud/talk/models/json/converters/ConversationObjectTypeConverter.kt
  96. 7 6
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumLobbyStateConverter.java
  97. 9 8
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumNotificationLevelConverter.java
  98. 7 6
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumReadOnlyConversationConverter.java
  99. 11 11
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumRoomTypeConverter.java
  100. 60 60
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt

+ 1 - 0
.idea/inspectionProfiles/ktlint.xml

@@ -39,5 +39,6 @@
       <option name="previewFile" value="true" />
     </inspection_tool>
     <inspection_tool class="RedundantSemicolon" enabled="true" level="ERROR" enabled_by_default="true" />
+    <inspection_tool class="SerializableCtor" enabled="true" level="WARNING" enabled_by_default="true" />
   </profile>
 </component>

+ 26 - 4
app/build.gradle

@@ -93,6 +93,12 @@ android {
         buildConfigField "String", "PERMISSION_LOCAL_BROADCAST", "\"${localBroadcastPermission}\""
     }
 
+    testOptions {
+        unitTests.all {
+            useJUnitPlatform()
+        }
+    }
+
     buildTypes {
         release {
             minifyEnabled false
@@ -157,6 +163,7 @@ ext {
     roomVersion = "2.6.1"
     workVersion = "2.9.1"
     espressoVersion = "3.6.1"
+    androidxTestVersion = "1.5.0"
     media3_version = "1.4.0"
     coroutines_version = "1.8.1"
     mockitoKotlinVersion = "5.4.0"
@@ -170,10 +177,14 @@ configurations.configureEach {
 }
 
 dependencies {
+    spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.13.0'
+    spotbugsPlugins 'com.mebigfatguy.fb-contrib:fb-contrib:7.6.4'
+
     implementation("androidx.compose.runtime:runtime:1.6.8")
     implementation 'androidx.preference:preference-ktx:1.2.1'
     implementation 'androidx.datastore:datastore-core:1.1.1'
     implementation 'androidx.datastore:datastore-preferences:1.1.1'
+    implementation 'androidx.test.ext:junit-ktx:1.1.5'
     detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.6")
 	
     implementation fileTree(include: ['*'], dir: 'libs')
@@ -192,7 +203,6 @@ dependencies {
     implementation "androidx.work:work-runtime:${workVersion}"
     implementation "androidx.work:work-rxjava2:${workVersion}"
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
-    androidTestImplementation "androidx.work:work-testing:${workVersion}"
     implementation 'com.google.android.flexbox:flexbox:3.0.0'
     implementation ('com.github.bitfireAT:dav4jvm:2.1.3', {
         exclude group: 'org.ogce', module: 'xpp3'	// Android comes with its own XmlPullParser
@@ -289,6 +299,12 @@ dependencies {
     })
 
     implementation 'androidx.core:core-ktx:1.13.1'
+    implementation 'androidx.activity:activity-ktx:1.9.0'
+    implementation 'com.github.nextcloud.android-common:ui:0.21.0'
+    implementation 'com.github.nextcloud-deps:android-talk-webrtc:121.6167.0'
+
+    gplayImplementation 'com.google.android.gms:play-services-base:18.4.0'
+    gplayImplementation "com.google.firebase:firebase-messaging:23.4.1"
 
     //compose
     implementation(platform("androidx.compose:compose-bom:2024.06.00"))
@@ -305,11 +321,14 @@ dependencies {
 
     testImplementation 'junit:junit:4.13.2'
     testImplementation 'org.mockito:mockito-core:5.12.0'
-    androidTestImplementation 'org.mockito:mockito-android:5.12.0'
     testImplementation 'androidx.arch.core:core-testing:2.2.0'
 
-    androidTestImplementation "androidx.test:core:1.6.1"
+    androidTestImplementation "androidx.test:core:1.5.0"
 
+    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1"
+    androidTestImplementation 'androidx.test:core-ktx:1.6.1'
+    androidTestImplementation 'org.mockito:mockito-android:5.12.0'
+    androidTestImplementation "androidx.work:work-testing:${workVersion}"
     // Espresso core
     androidTestImplementation ("androidx.test.espresso:espresso-core:$espressoVersion", {
         exclude group: 'com.android.support', module: 'support-annotations'
@@ -317,6 +336,9 @@ dependencies {
     androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
     androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion"
     androidTestImplementation "androidx.test.espresso:espresso-accessibility:$espressoVersion"
+
+    androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
+
     androidTestImplementation('com.android.support.test.espresso:espresso-intents:3.0.2')
 
     spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.13.0'
@@ -325,7 +347,7 @@ dependencies {
     gplayImplementation 'com.google.android.gms:play-services-base:18.5.0'
     gplayImplementation "com.google.firebase:firebase-messaging:24.0.0"
 
-     implementation 'androidx.activity:activity-ktx:1.9.1'
+    implementation 'androidx.activity:activity-ktx:1.9.1'
 
     implementation 'com.github.nextcloud.android-common:ui:0.23.0'
 

+ 565 - 2
app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/10.json

@@ -2,7 +2,7 @@
   "formatVersion": 1,
   "database": {
     "version": 10,
-    "identityHash": "1b2dab0ea495c45c9c9ee6e64ba74039",
+    "identityHash": "c07a2543aa583e08e7b3208f44fcc7ac",
     "entities": [
       {
         "tableName": "User",
@@ -135,12 +135,575 @@
         },
         "indices": [],
         "foreignKeys": []
+      },
+      {
+        "tableName": "Conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT, `name` TEXT, `displayName` TEXT, `description` TEXT, `type` TEXT, `lastPing` INTEGER NOT NULL, `participantType` TEXT, `hasPassword` INTEGER NOT NULL, `sessionId` TEXT, `actorId` TEXT, `actorType` TEXT, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `unreadMention` INTEGER NOT NULL, `lastMessageJson` TEXT, `objectType` TEXT, `notificationLevel` TEXT, `readOnly` TEXT, `lobbyState` TEXT, `lobbyTimer` INTEGER, `lastReadMessage` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `hasCall` INTEGER NOT NULL, `callFlag` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `canLeaveConversation` INTEGER, `canDeleteConversation` INTEGER, `unreadMentionDirect` INTEGER, `notificationCalls` INTEGER, `permissions` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `status` TEXT, `statusIcon` TEXT, `statusMessage` TEXT, `statusClearAt` INTEGER, `callRecording` INTEGER NOT NULL, `avatarVersion` TEXT, `isCustomAvatar` INTEGER, `callStartTime` INTEGER, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "internalId",
+            "columnName": "internalId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastPing",
+            "columnName": "lastPing",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "participantType",
+            "columnName": "participantType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "hasPassword",
+            "columnName": "hasPassword",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sessionId",
+            "columnName": "sessionId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "actorId",
+            "columnName": "actorId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "actorType",
+            "columnName": "actorType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "favorite",
+            "columnName": "isFavorite",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastActivity",
+            "columnName": "lastActivity",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unreadMessages",
+            "columnName": "unreadMessages",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unreadMention",
+            "columnName": "unreadMention",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastMessageJson",
+            "columnName": "lastMessageJson",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "objectType",
+            "columnName": "objectType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "notificationLevel",
+            "columnName": "notificationLevel",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "conversationReadOnlyState",
+            "columnName": "readOnly",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lobbyState",
+            "columnName": "lobbyState",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lobbyTimer",
+            "columnName": "lobbyTimer",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastReadMessage",
+            "columnName": "lastReadMessage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastCommonReadMessage",
+            "columnName": "lastCommonReadMessage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasCall",
+            "columnName": "hasCall",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "callFlag",
+            "columnName": "callFlag",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "canStartCall",
+            "columnName": "canStartCall",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "canLeaveConversation",
+            "columnName": "canLeaveConversation",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "canDeleteConversation",
+            "columnName": "canDeleteConversation",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "unreadMentionDirect",
+            "columnName": "unreadMentionDirect",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "notificationCalls",
+            "columnName": "notificationCalls",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "messageExpiration",
+            "columnName": "messageExpiration",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusIcon",
+            "columnName": "statusIcon",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusMessage",
+            "columnName": "statusMessage",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusClearAt",
+            "columnName": "statusClearAt",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "callRecording",
+            "columnName": "callRecording",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarVersion",
+            "columnName": "avatarVersion",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "hasCustomAvatar",
+            "columnName": "isCustomAvatar",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "callStartTime",
+            "columnName": "callStartTime",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "recordingConsentRequired",
+            "columnName": "recordingConsent",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "remoteServer",
+            "columnName": "remoteServer",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "remoteToken",
+            "columnName": "remoteToken",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "internalId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_Conversations_accountId",
+            "unique": false,
+            "columnNames": [
+              "accountId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "User",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "accountId"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "ChatMessages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `id` INTEGER NOT NULL, `internalConversationId` TEXT, `actorType` TEXT, `actorId` TEXT, `actorDisplayName` TEXT, `timestamp` INTEGER NOT NULL, `systemMessage` TEXT, `messageType` TEXT, `isReplyable` INTEGER NOT NULL, `message` TEXT, `messageParameters` TEXT, `expirationTimestamp` INTEGER NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `markdown` INTEGER, `lastEditActorType` TEXT, `lastEditActorId` TEXT, `lastEditActorDisplayName` TEXT, `lastEditTimestamp` INTEGER, `deleted` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "internalId",
+            "columnName": "internalId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "internalConversationId",
+            "columnName": "internalConversationId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "actorType",
+            "columnName": "actorType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "actorId",
+            "columnName": "actorId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "actorDisplayName",
+            "columnName": "actorDisplayName",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "timestamp",
+            "columnName": "timestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "systemMessageType",
+            "columnName": "systemMessage",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "messageType",
+            "columnName": "messageType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "replyable",
+            "columnName": "isReplyable",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "message",
+            "columnName": "message",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "messageParameters",
+            "columnName": "messageParameters",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expirationTimestamp",
+            "columnName": "expirationTimestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parent",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reactions",
+            "columnName": "reactions",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reactionsSelf",
+            "columnName": "reactionsSelf",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "renderMarkdown",
+            "columnName": "markdown",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditActorType",
+            "columnName": "lastEditActorType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditActorId",
+            "columnName": "lastEditActorId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditActorDisplayName",
+            "columnName": "lastEditActorDisplayName",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditTimestamp",
+            "columnName": "lastEditTimestamp",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "deleted",
+            "columnName": "deleted",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "internalId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ChatMessages_internalId",
+            "unique": true,
+            "columnNames": [
+              "internalId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)"
+          },
+          {
+            "name": "index_ChatMessages_internalConversationId",
+            "unique": false,
+            "columnNames": [
+              "internalConversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "Conversations",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "internalConversationId"
+            ],
+            "referencedColumns": [
+              "internalId"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "ChatBlocks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "internalConversationId",
+            "columnName": "internalConversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "oldestMessageId",
+            "columnName": "oldestMessageId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "newestMessageId",
+            "columnName": "newestMessageId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasHistory",
+            "columnName": "hasHistory",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": [
+          {
+            "table": "Conversations",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "internalConversationId"
+            ],
+            "referencedColumns": [
+              "internalId"
+            ]
+          }
+        ]
       }
     ],
     "views": [],
     "setupQueries": [
       "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
-      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b2dab0ea495c45c9c9ee6e64ba74039')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c07a2543aa583e08e7b3208f44fcc7ac')"
     ]
   }
 }

+ 719 - 0
app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/11.json

@@ -0,0 +1,719 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 11,
+    "identityHash": "bc802cadfdef41d3eb94ffbb0729eb89",
+    "entities": [
+      {
+        "tableName": "User",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userId",
+            "columnName": "userId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "username",
+            "columnName": "username",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "pushConfigurationState",
+            "columnName": "pushConfigurationState",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverVersion",
+            "columnName": "serverVersion",
+            "affinity": "TEXT",
+            "notNull": false,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "clientCertificate",
+            "columnName": "clientCertificate",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "externalSignalingServer",
+            "columnName": "externalSignalingServer",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "current",
+            "columnName": "current",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scheduledForDeletion",
+            "columnName": "scheduledForDeletion",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "ArbitraryStorage",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "accountIdentifier",
+            "columnName": "accountIdentifier",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "storageObject",
+            "columnName": "object",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "accountIdentifier",
+            "key"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "Conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "internalId",
+            "columnName": "internalId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actorId",
+            "columnName": "actorId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actorType",
+            "columnName": "actorType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarVersion",
+            "columnName": "avatarVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "callFlag",
+            "columnName": "callFlag",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "callRecording",
+            "columnName": "callRecording",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "callStartTime",
+            "columnName": "callStartTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "canDeleteConversation",
+            "columnName": "canDeleteConversation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "canLeaveConversation",
+            "columnName": "canLeaveConversation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "canStartCall",
+            "columnName": "canStartCall",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasCall",
+            "columnName": "hasCall",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasPassword",
+            "columnName": "hasPassword",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasCustomAvatar",
+            "columnName": "isCustomAvatar",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "favorite",
+            "columnName": "isFavorite",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastActivity",
+            "columnName": "lastActivity",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastCommonReadMessage",
+            "columnName": "lastCommonReadMessage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastMessage",
+            "columnName": "lastMessage",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastPing",
+            "columnName": "lastPing",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastReadMessage",
+            "columnName": "lastReadMessage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lobbyState",
+            "columnName": "lobbyState",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lobbyTimer",
+            "columnName": "lobbyTimer",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "messageExpiration",
+            "columnName": "messageExpiration",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationCalls",
+            "columnName": "notificationCalls",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "notificationLevel",
+            "columnName": "notificationLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "objectType",
+            "columnName": "objectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "participantType",
+            "columnName": "participantType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationReadOnlyState",
+            "columnName": "readOnly",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "recordingConsentRequired",
+            "columnName": "recordingConsent",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "remoteServer",
+            "columnName": "remoteServer",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "remoteToken",
+            "columnName": "remoteToken",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sessionId",
+            "columnName": "sessionId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusClearAt",
+            "columnName": "statusClearAt",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusIcon",
+            "columnName": "statusIcon",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "statusMessage",
+            "columnName": "statusMessage",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unreadMention",
+            "columnName": "unreadMention",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unreadMentionDirect",
+            "columnName": "unreadMentionDirect",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "unreadMessages",
+            "columnName": "unreadMessages",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "internalId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_Conversations_accountId",
+            "unique": false,
+            "columnNames": [
+              "accountId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "User",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "accountId"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "ChatMessages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `systemMessage` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "internalId",
+            "columnName": "internalId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "internalConversationId",
+            "columnName": "internalConversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actorDisplayName",
+            "columnName": "actorDisplayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "message",
+            "columnName": "message",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actorId",
+            "columnName": "actorId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actorType",
+            "columnName": "actorType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deleted",
+            "columnName": "deleted",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "expirationTimestamp",
+            "columnName": "expirationTimestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "replyable",
+            "columnName": "isReplyable",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastEditActorDisplayName",
+            "columnName": "lastEditActorDisplayName",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditActorId",
+            "columnName": "lastEditActorId",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditActorType",
+            "columnName": "lastEditActorType",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastEditTimestamp",
+            "columnName": "lastEditTimestamp",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "renderMarkdown",
+            "columnName": "markdown",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "messageParameters",
+            "columnName": "messageParameters",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "messageType",
+            "columnName": "messageType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parent",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reactions",
+            "columnName": "reactions",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "reactionsSelf",
+            "columnName": "reactionsSelf",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "systemMessageType",
+            "columnName": "systemMessage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "timestamp",
+            "columnName": "timestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "internalId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ChatMessages_internalId",
+            "unique": true,
+            "columnNames": [
+              "internalId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)"
+          },
+          {
+            "name": "index_ChatMessages_internalConversationId",
+            "unique": false,
+            "columnNames": [
+              "internalConversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "Conversations",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "internalConversationId"
+            ],
+            "referencedColumns": [
+              "internalId"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "ChatBlocks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "internalConversationId",
+            "columnName": "internalConversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "accountId",
+            "columnName": "accountId",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "oldestMessageId",
+            "columnName": "oldestMessageId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "newestMessageId",
+            "columnName": "newestMessageId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hasHistory",
+            "columnName": "hasHistory",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ChatBlocks_internalConversationId",
+            "unique": false,
+            "columnNames": [
+              "internalConversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "Conversations",
+            "onDelete": "CASCADE",
+            "onUpdate": "CASCADE",
+            "columns": [
+              "internalConversationId"
+            ],
+            "referencedColumns": [
+              "internalId"
+            ]
+          }
+        ]
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc802cadfdef41d3eb94ffbb0729eb89')"
+    ]
+  }
+}

+ 233 - 0
app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt

@@ -0,0 +1,233 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.dao
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.runner.AndroidJUnit4
+import com.nextcloud.talk.data.database.model.ChatBlockEntity
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import com.nextcloud.talk.data.source.local.TalkDatabase
+import com.nextcloud.talk.data.user.UsersDao
+import com.nextcloud.talk.data.user.model.UserEntity
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.models.json.participants.Participant
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ChatBlocksDaoTest {
+    private lateinit var usersDao: UsersDao
+    private lateinit var conversationsDao: ConversationsDao
+    private lateinit var chatBlocksDao: ChatBlocksDao
+    private lateinit var db: TalkDatabase
+    private val tag = ChatBlocksDaoTest::class.java.simpleName
+
+    @Before
+    fun createDb() {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        db = Room.inMemoryDatabaseBuilder(
+            context,
+            TalkDatabase::class.java
+        ).build()
+        usersDao = db.usersDao()
+        conversationsDao = db.conversationsDao()
+        chatBlocksDao = db.chatBlocksDao()
+    }
+
+    @After
+    fun closeDb() = db.close()
+
+    @Test
+    fun testGetConnectedChatBlocks() =
+        runTest {
+
+            usersDao.saveUser(createUserEntity("account1", "Account 1"))
+            val account1 = usersDao.getUserWithUserId("account1").blockingGet()
+
+            conversationsDao.upsertConversations(
+                listOf(
+                    createConversationEntity(
+                        accountId = account1.id,
+                        "abc",
+                        roomName = "Conversation One"
+                    ),
+                    createConversationEntity(
+                        accountId = account1.id,
+                        "def",
+                        roomName = "Conversation Two"
+                    ),
+                )
+            )
+
+            val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
+            val conversation2 = conversationsDao.getConversationsForUser(account1.id).first()[1]
+
+            val searchedChatBlock = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 50,
+                newestMessageId = 60,
+                hasHistory = true
+            )
+
+            val chatBlockTooOld = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 10,
+                newestMessageId = 20,
+                hasHistory = true
+            )
+
+            val chatBlockOverlap1 = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 45,
+                newestMessageId = 55,
+                hasHistory = true
+            )
+
+            val chatBlockWithin = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 52,
+                newestMessageId = 58,
+                hasHistory = true
+            )
+
+            val chatBlockOverall = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 1,
+                newestMessageId = 99,
+                hasHistory = true
+            )
+
+            val chatBlockOverlap2 = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 59,
+                newestMessageId = 70,
+                hasHistory = true
+            )
+
+            val chatBlockTooNew = ChatBlockEntity(
+                internalConversationId = conversation1.internalId,
+                accountId = conversation1.accountId,
+                token = conversation1.token,
+                oldestMessageId = 80,
+                newestMessageId = 90,
+                hasHistory = true
+            )
+
+            val chatBlockWithinButOtherConversation = ChatBlockEntity(
+                internalConversationId = conversation2.internalId,
+                accountId = conversation2.accountId,
+                token = conversation2.token,
+                oldestMessageId = 53,
+                newestMessageId = 57,
+                hasHistory = true
+            )
+
+            chatBlocksDao.upsertChatBlock(searchedChatBlock)
+
+            chatBlocksDao.upsertChatBlock(chatBlockTooOld)
+            chatBlocksDao.upsertChatBlock(chatBlockOverlap1)
+            chatBlocksDao.upsertChatBlock(chatBlockWithin)
+            chatBlocksDao.upsertChatBlock(chatBlockOverall)
+            chatBlocksDao.upsertChatBlock(chatBlockOverlap2)
+            chatBlocksDao.upsertChatBlock(chatBlockTooNew)
+            chatBlocksDao.upsertChatBlock(chatBlockWithinButOtherConversation)
+
+            val results = chatBlocksDao.getConnectedChatBlocks(
+                conversation1.internalId,
+                searchedChatBlock.oldestMessageId,
+                searchedChatBlock.newestMessageId
+            )
+
+            assertEquals(5, results.first().size)
+        }
+
+    private fun createUserEntity(userId: String, userName: String) =
+        UserEntity(
+            userId = userId,
+            username = userName,
+            baseUrl = null,
+            token = null,
+            displayName = null,
+            pushConfigurationState = null,
+            capabilities = null,
+            serverVersion = null,
+            clientCertificate = null,
+            externalSignalingServer = null,
+            current = java.lang.Boolean.FALSE,
+            scheduledForDeletion = java.lang.Boolean.FALSE
+        )
+
+    private fun createConversationEntity(accountId: Long, token: String, roomName: String): ConversationEntity {
+        return ConversationEntity(
+            internalId = "$accountId@$token",
+            accountId = accountId,
+            token = token,
+            name = roomName,
+            actorId = "",
+            actorType = "",
+            messageExpiration = 0,
+            unreadMessages = 0,
+            statusMessage = null,
+            lastMessage = null,
+            canDeleteConversation = false,
+            canLeaveConversation = false,
+            lastCommonReadMessage = 0,
+            lastReadMessage = 0,
+            type = ConversationEnums.ConversationType.DUMMY,
+            status = "",
+            callFlag = 1,
+            favorite = false,
+            lastPing = 0,
+            hasCall = false,
+            sessionId = "",
+            canStartCall = false,
+            lastActivity = 0,
+            remoteServer = "",
+            avatarVersion = "",
+            unreadMentionDirect = false,
+            callRecording = 1,
+            callStartTime = 0,
+            statusClearAt = 0,
+            unreadMention = false,
+            lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY,
+            lobbyTimer = 0,
+            objectType = ConversationEnums.ObjectType.FILE,
+            statusIcon = null,
+            description = "",
+            displayName = "",
+            hasPassword = false,
+            permissions = 0,
+            notificationCalls = 0,
+            remoteToken = "",
+            notificationLevel = ConversationEnums.NotificationLevel.ALWAYS,
+            conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY,
+            hasCustomAvatar = false,
+            participantType = Participant.ParticipantType.DUMMY,
+            recordingConsentRequired = 1
+        )
+    }
+}

+ 269 - 0
app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt

@@ -0,0 +1,269 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.dao
+
+import android.content.Context
+import android.util.Log
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.runner.AndroidJUnit4
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import com.nextcloud.talk.data.source.local.TalkDatabase
+import com.nextcloud.talk.data.user.UsersDao
+import com.nextcloud.talk.data.user.model.UserEntity
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.models.json.participants.Participant
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ChatMessagesDaoTest {
+
+    private lateinit var usersDao: UsersDao
+    private lateinit var conversationsDao: ConversationsDao
+    private lateinit var chatMessagesDao: ChatMessagesDao
+    private lateinit var db: TalkDatabase
+    private val tag = ChatMessagesDaoTest::class.java.simpleName
+
+    var chatMessageCounter: Long = 1
+
+    @Before
+    fun createDb() {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        db = Room.inMemoryDatabaseBuilder(
+            context,
+            TalkDatabase::class.java
+        ).build()
+        usersDao = db.usersDao()
+        conversationsDao = db.conversationsDao()
+        chatMessagesDao = db.chatMessagesDao()
+    }
+
+    @After
+    fun closeDb() = db.close()
+
+    @Test
+    fun test() =
+        runTest {
+            usersDao.saveUser(createUserEntity("account1", "Account 1"))
+            usersDao.saveUser(createUserEntity("account2", "Account 2"))
+
+            val account1 = usersDao.getUserWithUserId("account1").blockingGet()
+            val account2 = usersDao.getUserWithUserId("account2").blockingGet()
+
+            // Problem: lets say we want to update the conv list -> We don#t know the primary keys!
+            // with account@token that would be easier!
+            conversationsDao.upsertConversations(
+                listOf(
+                    createConversationEntity(
+                        accountId = account1.id,
+                        roomName = "Conversation One"
+                    ),
+                    createConversationEntity(
+                        accountId = account1.id,
+                        roomName = "Conversation Two"
+                    ),
+                    createConversationEntity(
+                        accountId = account2.id,
+                        roomName = "Conversation Three"
+                    )
+                )
+            )
+
+            assertEquals(2, conversationsDao.getConversationsForUser(account1.id).first().size)
+            assertEquals(1, conversationsDao.getConversationsForUser(account2.id).first().size)
+
+            // Lets imagine we are on conversations screen...
+            conversationsDao.getConversationsForUser(account1.id).first().forEach {
+                Log.d(tag, "- next Conversation for account1 -")
+                Log.d(tag, "internalId (PK): " + it.internalId)
+                Log.d(tag, "accountId: " + it.accountId)
+                Log.d(tag, "name: " + it.name)
+                Log.d(tag, "token: " + it.token)
+            }
+
+            // User sees all conversations and clicks on a item. That's how we get a conversation
+            val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
+            val conversation2 = conversationsDao.getConversationsForUser(account1.id).first()[1]
+
+            // Having a conversation token, we can also get a conversation directly
+            val conversation1GotByToken = conversationsDao.getConversationForUser(
+                account1.id,
+                conversation1.token!!
+            ).first()
+
+            assertEquals(conversation1, conversation1GotByToken)
+
+            // Lets insert some messages to the conversations
+            chatMessagesDao.upsertChatMessages(
+                listOf(
+                    createChatMessageEntity(conversation1.internalId, "hello"),
+                    createChatMessageEntity(conversation1.internalId, "here"),
+                    createChatMessageEntity(conversation1.internalId, "are"),
+                    createChatMessageEntity(conversation1.internalId, "some"),
+                    createChatMessageEntity(conversation1.internalId, "messages")
+                )
+            )
+            chatMessagesDao.upsertChatMessages(
+                listOf(
+                    createChatMessageEntity(conversation2.internalId, "first message in conversation 2")
+                )
+            )
+
+            chatMessagesDao.getMessagesForConversation(conversation1.internalId).first().forEach {
+                Log.d(tag, "- next Message for conversation1 (account1)-")
+                Log.d(tag, "id (PK): " + it.id)
+                Log.d(tag, "message: " + it.message)
+            }
+
+            val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId)
+            assertEquals(5, chatMessagesConv1.first().size)
+
+            val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId)
+            assertEquals(1, chatMessagesConv2.first().size)
+
+            assertEquals("some", chatMessagesConv1.first()[1].message)
+
+            val conv1chatMessage3 = chatMessagesDao.getChatMessageForConversation(conversation1.internalId, 3).first()
+            assertEquals("are", conv1chatMessage3.message)
+
+            val chatMessagesConv1Since =
+                chatMessagesDao.getMessagesForConversationSince(conversation1.internalId, conv1chatMessage3.id)
+            assertEquals(3, chatMessagesConv1Since.first().size)
+            assertEquals("are", chatMessagesConv1Since.first()[0].message)
+            assertEquals("some", chatMessagesConv1Since.first()[1].message)
+            assertEquals("messages", chatMessagesConv1Since.first()[2].message)
+
+            val chatMessagesConv1To =
+                chatMessagesDao.getMessagesForConversationBeforeAndEqual(
+                    conversation1.internalId,
+                    conv1chatMessage3.id,
+                    3
+                )
+            assertEquals(3, chatMessagesConv1To.first().size)
+            assertEquals("hello", chatMessagesConv1To.first()[2].message)
+            assertEquals("here", chatMessagesConv1To.first()[1].message)
+            assertEquals("are", chatMessagesConv1To.first()[0].message)
+        }
+
+    private fun createUserEntity(userId: String, userName: String) =
+        UserEntity(
+            userId = userId,
+            username = userName,
+            baseUrl = null,
+            token = null,
+            displayName = null,
+            pushConfigurationState = null,
+            capabilities = null,
+            serverVersion = null,
+            clientCertificate = null,
+            externalSignalingServer = null,
+            current = java.lang.Boolean.FALSE,
+            scheduledForDeletion = java.lang.Boolean.FALSE
+        )
+
+    private fun createConversationEntity(accountId: Long, roomName: String): ConversationEntity {
+        val token = (0..10000000).random().toString()
+
+        return ConversationEntity(
+            internalId = "$accountId@$token",
+            accountId = accountId,
+            token = token,
+            name = roomName,
+            actorId = "",
+            actorType = "",
+            messageExpiration = 0,
+            unreadMessages = 0,
+            statusMessage = null,
+            lastMessage = null,
+            canDeleteConversation = false,
+            canLeaveConversation = false,
+            lastCommonReadMessage = 0,
+            lastReadMessage = 0,
+            type = ConversationEnums.ConversationType.DUMMY,
+            status = "",
+            callFlag = 1,
+            favorite = false,
+            lastPing = 0,
+            hasCall = false,
+            sessionId = "",
+            canStartCall = false,
+            lastActivity = 0,
+            remoteServer = "",
+            avatarVersion = "",
+            unreadMentionDirect = false,
+            callRecording = 1,
+            callStartTime = 0,
+            statusClearAt = 0,
+            unreadMention = false,
+            lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY,
+            lobbyTimer = 0,
+            objectType = ConversationEnums.ObjectType.FILE,
+            statusIcon = null,
+            description = "",
+            displayName = "",
+            hasPassword = false,
+            permissions = 0,
+            notificationCalls = 0,
+            remoteToken = "",
+            notificationLevel = ConversationEnums.NotificationLevel.ALWAYS,
+            conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY,
+            hasCustomAvatar = false,
+            participantType = Participant.ParticipantType.DUMMY,
+            recordingConsentRequired = 1
+        )
+    }
+
+    private fun createChatMessageEntity(internalConversationId: String, message: String): ChatMessageEntity {
+        val id = chatMessageCounter++
+
+        val emoji1 = "\uD83D\uDE00" // 😀
+        val emoji2 = "\uD83D\uDE1C" // 😜
+        val reactions = LinkedHashMap<String, Int>()
+        reactions[emoji1] = 3
+        reactions[emoji2] = 4
+
+        val reactionsSelf = ArrayList<String>()
+        reactionsSelf.add(emoji1)
+
+        val entity = ChatMessageEntity(
+            internalId = "$internalConversationId@$id",
+            internalConversationId = internalConversationId,
+            id = id,
+            message = message,
+            reactions = reactions,
+            reactionsSelf = reactionsSelf,
+            deleted = false,
+            token = "",
+            actorId = "",
+            actorType = "",
+            accountId = 1,
+            messageParameters = null,
+            messageType = "",
+            parentMessageId = null,
+            systemMessageType = ChatMessage.SystemMessageType.DUMMY,
+            replyable = false,
+            timestamp = 0,
+            expirationTimestamp = 0,
+            actorDisplayName = "",
+            lastEditActorType = null,
+            lastEditTimestamp = null,
+            renderMarkdown = true,
+            lastEditActorId = "",
+            lastEditActorDisplayName = ""
+        )
+        return entity
+    }
+}

+ 1 - 1
app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

@@ -1734,7 +1734,7 @@ class CallActivity : CallBaseActivity() {
     private fun setInitialApplicationWideCurrentRoomHolderValues(conversation: Conversation) {
         ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
         ApplicationWideCurrentRoomHolder.getInstance().session = conversation.sessionId
-        ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = conversation.roomId
+        // ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = conversation.roomId
         ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = conversation.token
         ApplicationWideCurrentRoomHolder.getInstance().callStartTime = conversation.callStartTime
     }

+ 120 - 30
app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt

@@ -22,21 +22,21 @@ import androidx.core.content.res.ResourcesCompat
 import com.nextcloud.talk.R
 import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHolder
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.data.model.ChatMessage.MessageType
+import com.nextcloud.talk.data.database.mappers.asModel
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding
 import com.nextcloud.talk.extensions.loadConversationAvatar
 import com.nextcloud.talk.extensions.loadNoteToSelfAvatar
 import com.nextcloud.talk.extensions.loadSystemAvatar
 import com.nextcloud.talk.extensions.loadUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
-import com.nextcloud.talk.models.json.conversations.Conversation
-import com.nextcloud.talk.models.json.conversations.Conversation.ConversationType
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.ui.StatusDrawable
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
-import com.nextcloud.talk.utils.SpreedFeatures
 import com.nextcloud.talk.utils.DisplayUtils
-
+import com.nextcloud.talk.utils.SpreedFeatures
 import eu.davidea.flexibleadapter.FlexibleAdapter
 import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
 import eu.davidea.flexibleadapter.items.IFilterable
@@ -46,7 +46,7 @@ import eu.davidea.viewholders.FlexibleViewHolder
 import java.util.regex.Pattern
 
 class ConversationItem(
-    val model: Conversation,
+    val model: ConversationModel,
     private val user: User,
     private val context: Context,
     private val viewThemeUtils: ViewThemeUtils
@@ -54,9 +54,10 @@ class ConversationItem(
     ISectionable<ConversationItemViewHolder, GenericTextHeaderItem?>,
     IFilterable<String?> {
     private var header: GenericTextHeaderItem? = null
+    private val chatMessage = model.lastMessage?.asModel()
 
     constructor(
-        conversation: Conversation,
+        conversation: ConversationModel,
         user: User,
         activityContext: Context,
         genericTextHeaderItem: GenericTextHeaderItem?,
@@ -127,7 +128,7 @@ class ConversationItem(
         } else {
             holder.binding.favoriteConversationImageView.visibility = View.GONE
         }
-        if (ConversationType.ROOM_SYSTEM !== model.type) {
+        if (ConversationEnums.ConversationType.ROOM_SYSTEM !== model.type) {
             val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, appContext)
             holder.binding.userStatusImage.visibility = View.VISIBLE
             holder.binding.userStatusImage.setImageDrawable(
@@ -149,13 +150,13 @@ class ConversationItem(
     private fun showAvatar(holder: ConversationItemViewHolder) {
         holder.binding.dialogAvatar.visibility = View.VISIBLE
         var shouldLoadAvatar = shouldLoadAvatar(holder)
-        if (ConversationType.ROOM_SYSTEM == model.type) {
+        if (ConversationEnums.ConversationType.ROOM_SYSTEM == model.type) {
             holder.binding.dialogAvatar.loadSystemAvatar()
             shouldLoadAvatar = false
         }
         if (shouldLoadAvatar) {
             when (model.type) {
-                ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> {
+                ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> {
                     if (!TextUtils.isEmpty(model.name)) {
                         holder.binding.dialogAvatar.loadUserAvatar(
                             user,
@@ -168,11 +169,12 @@ class ConversationItem(
                     }
                 }
 
-                ConversationType.ROOM_GROUP_CALL,
-                ConversationType.FORMER_ONE_TO_ONE,
-                ConversationType.ROOM_PUBLIC_CALL ->
+                ConversationEnums.ConversationType.ROOM_GROUP_CALL,
+                ConversationEnums.ConversationType.FORMER_ONE_TO_ONE,
+                ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
                     holder.binding.dialogAvatar.loadConversationAvatar(user, model, false, viewThemeUtils)
-                ConversationType.NOTE_TO_SELF ->
+
+                ConversationEnums.ConversationType.NOTE_TO_SELF ->
                     holder.binding.dialogAvatar.loadNoteToSelfAvatar()
 
                 else -> holder.binding.dialogAvatar.visibility = View.GONE
@@ -182,7 +184,7 @@ class ConversationItem(
 
     private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean {
         return when (model.objectType) {
-            Conversation.ObjectType.SHARE_PASSWORD -> {
+            ConversationEnums.ObjectType.SHARE_PASSWORD -> {
                 holder.binding.dialogAvatar.setImageDrawable(
                     ContextCompat.getDrawable(
                         context,
@@ -192,7 +194,7 @@ class ConversationItem(
                 false
             }
 
-            Conversation.ObjectType.FILE -> {
+            ConversationEnums.ObjectType.FILE -> {
                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                     holder.binding.dialogAvatar.loadUserAvatar(
                         viewThemeUtils.talk.themePlaceholderAvatar(
@@ -213,7 +215,7 @@ class ConversationItem(
     }
 
     private fun setLastMessage(holder: ConversationItemViewHolder, appContext: Context) {
-        if (model.lastMessage != null) {
+        if (chatMessage != null) {
             holder.binding.dialogDate.visibility = View.VISIBLE
             holder.binding.dialogDate.text = DateUtils.getRelativeTimeSpanString(
                 model.lastActivity * MILLIES,
@@ -221,20 +223,20 @@ class ConversationItem(
                 0,
                 DateUtils.FORMAT_ABBREV_RELATIVE
             )
-            if (!TextUtils.isEmpty(model.lastMessage!!.systemMessage) ||
-                ConversationType.ROOM_SYSTEM === model.type
+            if (!TextUtils.isEmpty(chatMessage?.systemMessage) ||
+                ConversationEnums.ConversationType.ROOM_SYSTEM === model.type
             ) {
-                holder.binding.dialogLastMessage.text = model.lastMessage!!.text
+                holder.binding.dialogLastMessage.text = chatMessage?.text
             } else {
-                model.lastMessage!!.activeUser = user
+                chatMessage?.activeUser = user
 
                 val text =
                     if (
-                        model.lastMessage!!.getCalculateMessageType() === ChatMessage.MessageType.REGULAR_TEXT_MESSAGE
+                        chatMessage?.messageType === MessageType.REGULAR_TEXT_MESSAGE.toString()
                     ) {
                         calculateRegularLastMessageText(appContext)
                     } else {
-                        model.lastMessage!!.lastMessageDisplayText
+                        lastMessageDisplayText
                     }
                 holder.binding.dialogLastMessage.text = text
             }
@@ -245,16 +247,16 @@ class ConversationItem(
     }
 
     private fun calculateRegularLastMessageText(appContext: Context): String {
-        return if (model.lastMessage!!.actorId == user.userId) {
+        return if (chatMessage?.actorId == user.userId) {
             String.format(
                 appContext.getString(R.string.nc_formatted_message_you),
-                model.lastMessage!!.lastMessageDisplayText
+                lastMessageDisplayText
             )
         } else {
             val authorDisplayName =
-                if (!TextUtils.isEmpty(model.lastMessage!!.actorDisplayName)) {
-                    model.lastMessage!!.actorDisplayName
-                } else if ("guests" == model.lastMessage!!.actorType) {
+                if (!TextUtils.isEmpty(chatMessage?.actorDisplayName)) {
+                    chatMessage?.actorDisplayName
+                } else if ("guests" == chatMessage?.actorType) {
                     appContext.getString(R.string.nc_guest)
                 } else {
                     ""
@@ -262,7 +264,7 @@ class ConversationItem(
             String.format(
                 appContext.getString(R.string.nc_formatted_message),
                 authorDisplayName,
-                model.lastMessage!!.lastMessageDisplayText
+                lastMessageDisplayText
             )
         }
     }
@@ -286,7 +288,7 @@ class ConversationItem(
             context,
             R.color.conversation_unread_bubble_text
         )
-        if (model.type === ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
+        if (model.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
             viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
         } else if (model.unreadMention) {
             if (hasSpreedFeatureCapability(user.capabilities?.spreedCapability!!, SpreedFeatures.DIRECT_MENTION_FLAG)) {
@@ -323,6 +325,94 @@ class ConversationItem(
         this.header = header
     }
 
+    private val lastMessageDisplayText: String
+        get() {
+            if (chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE ||
+                chatMessage?.getCalculateMessageType() == MessageType.SYSTEM_MESSAGE ||
+                chatMessage?.getCalculateMessageType() == MessageType.SINGLE_LINK_MESSAGE
+            ) {
+                return chatMessage.text
+            } else {
+                if (MessageType.SINGLE_LINK_GIPHY_MESSAGE == chatMessage?.getCalculateMessageType() ||
+                    MessageType.SINGLE_LINK_TENOR_MESSAGE == chatMessage?.getCalculateMessageType() ||
+                    MessageType.SINGLE_LINK_GIF_MESSAGE == chatMessage?.getCalculateMessageType()
+                ) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_a_gif_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_a_gif),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_an_attachment_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_an_attachment),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.SINGLE_NC_GEOLOCATION_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_location_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_location),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.VOICE_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_voice_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_voice),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.SINGLE_LINK_AUDIO_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_an_audio_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_an_audio),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.SINGLE_LINK_VIDEO_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_a_video_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_a_video),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.SINGLE_LINK_IMAGE_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_an_image_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_an_image),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                } else if (MessageType.POLL_MESSAGE == chatMessage?.getCalculateMessageType()) {
+                    return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
+                        sharedApplication!!.getString(R.string.nc_sent_poll_you)
+                    } else {
+                        String.format(
+                            sharedApplication!!.resources.getString(R.string.nc_sent_poll),
+                            chatMessage?.getNullsafeActorDisplayName()
+                        )
+                    }
+                }
+            }
+            return ""
+        }
+
     class ConversationItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
         var binding: RvItemConversationWithLastMessageBinding
 

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/CallStartedViewHolder.kt

@@ -16,7 +16,7 @@ import coil.target.Target
 import coil.transform.CircleCropTransformation
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.databinding.CallStartedMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.users.UserManager
 import com.nextcloud.talk.utils.ApiUtils

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/CommonMessageInterface.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.adapters.messages
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 interface CommonMessageInterface {
     fun onLongClickReactions(chatMessage: ChatMessage)

+ 63 - 32
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLinkPreviewMessageViewHolder.kt

@@ -10,26 +10,35 @@ package com.nextcloud.talk.adapters.messages
 import android.annotation.SuppressLint
 import android.content.Context
 import android.text.TextUtils
+import android.util.Log
 import android.view.View
 import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.OutcomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomIncomingLinkPreviewMessageBinding
 import com.nextcloud.talk.extensions.loadBotsAvatar
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -168,40 +177,62 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            true,
+                            viewThemeUtils
+                        )
+
+                    binding.messageQuote.quotedMessageAuthor
+                        .setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
+
+                    if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
+                        viewThemeUtils.platform.colorViewBackground(
+                            binding.messageQuote.quoteColoredView,
+                            ColorRole.PRIMARY
+                        )
+                    } else {
+                        binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+                    }
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
-            }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    true,
-                    viewThemeUtils
-                )
-
-            binding.messageQuote.quotedMessageAuthor
-                .setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
-
-            if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
-                viewThemeUtils.platform.colorViewBackground(binding.messageQuote.quoteColoredView, ColorRole.PRIMARY)
-            } else {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
             }
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 62 - 32
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingLocationMessageViewHolder.kt

@@ -20,18 +20,21 @@ import android.view.MotionEvent
 import android.view.View
 import android.webkit.WebView
 import android.webkit.WebViewClient
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.google.android.material.snackbar.Snackbar
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.IncomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomIncomingLocationMessageBinding
 import com.nextcloud.talk.extensions.loadBotsAvatar
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DateUtils
@@ -39,6 +42,11 @@ import com.nextcloud.talk.utils.UriUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import java.net.URLEncoder
 import javax.inject.Inject
 
@@ -150,40 +158,62 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            true,
+                            viewThemeUtils
+                        )
+
+                    binding.messageQuote.quotedMessageAuthor
+                        .setTextColor(context.resources.getColor(R.color.textColorMaxContrast, null))
+
+                    if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
+                        viewThemeUtils.platform.colorViewBackground(
+                            binding.messageQuote.quoteColoredView,
+                            ColorRole.PRIMARY
+                        )
+                    } else {
+                        binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+                    }
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
-            }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    true,
-                    viewThemeUtils
-                )
-
-            binding.messageQuote.quotedMessageAuthor
-                .setTextColor(context.resources.getColor(R.color.textColorMaxContrast, null))
-
-            if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
-                viewThemeUtils.platform.colorViewBackground(binding.messageQuote.quoteColoredView, ColorRole.PRIMARY)
-            } else {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
             }
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 61 - 32
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPollMessageViewHolder.kt

@@ -9,12 +9,15 @@ package com.nextcloud.talk.adapters.messages
 import android.annotation.SuppressLint
 import android.content.Context
 import android.text.TextUtils
+import android.util.Log
 import android.view.View
 import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.IncomingTextMessageViewHolder.Companion
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
@@ -23,7 +26,7 @@ import com.nextcloud.talk.databinding.ItemCustomIncomingPollMessageBinding
 import com.nextcloud.talk.extensions.loadBotsAvatar
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.polls.ui.PollMainDialogFragment
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
@@ -31,6 +34,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -176,40 +184,61 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
-                }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
-            }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    true,
-                    viewThemeUtils
-                )
-
-            binding.messageQuote.quotedMessageAuthor
-                .setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
 
-            if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
-                viewThemeUtils.platform.colorViewBackground(binding.messageQuote.quoteColoredView, ColorRole.PRIMARY)
-            } else {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            true,
+                            viewThemeUtils
+                        )
+
+                    binding.messageQuote.quotedMessageAuthor
+                        .setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
+
+                    if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
+                        viewThemeUtils.platform.colorViewBackground(
+                            binding.messageQuote.quoteColoredView,
+                            ColorRole.PRIMARY
+                        )
+                    } else {
+                        binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+                    }
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
+                }
             }
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingPreviewMessageViewHolder.java

@@ -18,7 +18,7 @@ import com.google.android.material.card.MaterialCardView;
 import com.nextcloud.talk.R;
 import com.nextcloud.talk.databinding.ItemCustomIncomingPreviewMessageBinding;
 import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
-import com.nextcloud.talk.models.json.chat.ChatMessage;
+import com.nextcloud.talk.chat.data.model.ChatMessage;
 import com.nextcloud.talk.utils.TextMatchers;
 
 import java.util.HashMap;

+ 79 - 40
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt

@@ -11,9 +11,11 @@ package com.nextcloud.talk.adapters.messages
 
 import android.content.Context
 import android.text.TextUtils
+import android.util.Log
 import android.util.TypedValue
 import android.view.View
 import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
@@ -25,7 +27,7 @@ import com.nextcloud.talk.databinding.ItemCustomIncomingTextMessageBinding
 import com.nextcloud.talk.extensions.loadBotsAvatar
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DateUtils
@@ -33,6 +35,13 @@ import com.nextcloud.talk.utils.TextMatchers
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -99,14 +108,14 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
 
         if (message.lastEditTimestamp != 0L && !message.isDeleted) {
             binding.messageEditIndicator.visibility = View.VISIBLE
-            binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp)
+            binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
         } else {
             binding.messageEditIndicator.visibility = View.GONE
             binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
         }
 
         // parent message handling
-        if (!message.isDeleted && message.parentMessage != null) {
+        if (!message.isDeleted && message.parentMessageId != null) {
             processParentMessage(message)
             binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
@@ -176,44 +185,73 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
     }
 
     private fun processParentMessage(message: ChatMessage) {
-        val parentChatMessage = message.parentMessage
-        parentChatMessage!!.activeUser = message.activeUser
-        parentChatMessage.imageUrl?.let {
-            binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-            binding.messageQuote.quotedMessageImage.load(it) {
-                addHeader(
-                    "Authorization",
-                    ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
-                )
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
+                    )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text =
+                        if (parentChatMessage.actorDisplayName.isNullOrEmpty()) {
+                            context.getText(R.string.nc_nick_guest)
+                        } else {
+                            parentChatMessage.actorDisplayName
+                        }
+
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            true,
+                            viewThemeUtils
+                        )
+
+                    if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
+                        viewThemeUtils.platform.colorViewBackground(
+                            binding.messageQuote.quoteColoredView,
+                            ColorRole.PRIMARY
+                        )
+                    } else {
+                        binding.messageQuote.quoteColoredView.setBackgroundColor(
+                            ContextCompat.getColor(
+                                binding.messageQuote.quoteColoredView.context,
+                                R.color.high_emphasis_text
+                            )
+                        )
+                    }
+
+                    binding.messageQuote.quotedChatMessageView.setOnClickListener {
+                        val chatActivity = commonMessageInterface as ChatActivity
+                        chatActivity.jumpToQuotedMessage(parentChatMessage)
+                    }
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
+                }
             }
-        } ?: run {
-            binding.messageQuote.quotedMessageImage.visibility = View.GONE
-        }
-        binding.messageQuote.quotedMessageAuthor.text = if (parentChatMessage.actorDisplayName.isNullOrEmpty()) {
-            context.getText(R.string.nc_nick_guest)
-        } else {
-            parentChatMessage.actorDisplayName
-        }
-
-        binding.messageQuote.quotedMessage.text = messageUtils
-            .enrichChatReplyMessageText(
-                binding.messageQuote.quotedMessage.context,
-                parentChatMessage,
-                true,
-                viewThemeUtils
-            )
-
-        if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
-            viewThemeUtils.platform.colorViewBackground(binding.messageQuote.quoteColoredView, ColorRole.PRIMARY)
-        } else {
-            binding.messageQuote.quoteColoredView.setBackgroundColor(
-                ContextCompat.getColor(binding.messageQuote.quoteColoredView.context, R.color.high_emphasis_text)
-            )
-        }
-
-        binding.messageQuote.quotedChatMessageView.setOnClickListener {
-            val chatActivity = commonMessageInterface as ChatActivity
-            chatActivity.jumpToQuotedMessage(parentChatMessage)
         }
     }
 
@@ -234,5 +272,6 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
 
     companion object {
         const val TEXT_SIZE_MULTIPLIER = 2.5
+        private val TAG = IncomingTextMessageViewHolder::class.java.simpleName
     }
 }

+ 63 - 32
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt

@@ -24,17 +24,23 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.databinding.ItemCustomIncomingVoiceMessageBinding
 import com.nextcloud.talk.extensions.loadBotsAvatar
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import java.util.concurrent.ExecutionException
 import javax.inject.Inject
 
@@ -203,14 +209,17 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
                     Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
                     showVoiceMessageLoading()
                 }
+
                 WorkInfo.State.SUCCEEDED -> {
                     Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
                     showPlayButton()
                 }
+
                 WorkInfo.State.FAILED -> {
                     Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
                     showPlayButton()
                 }
+
                 else -> {
                 }
             }
@@ -269,40 +278,62 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context!!.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            true,
+                            viewThemeUtils
+                        )
+
+                    binding.messageQuote.quotedMessageAuthor
+                        .setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
+
+                    if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
+                        viewThemeUtils.platform.colorViewBackground(
+                            binding.messageQuote.quoteColoredView,
+                            ColorRole.PRIMARY
+                        )
+                    } else {
+                        binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
+                    }
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
-            }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context!!.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    true,
-                    viewThemeUtils
-                )
-
-            binding.messageQuote.quotedMessageAuthor
-                .setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
-
-            if (parentChatMessage.actorId?.equals(message.activeUser!!.userId) == true) {
-                viewThemeUtils.platform.colorViewBackground(binding.messageQuote.quoteColoredView, ColorRole.PRIMARY)
-            } else {
-                binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast)
             }
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/LinkPreview.kt

@@ -14,7 +14,7 @@ import android.view.View
 import coil.load
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.databinding.ReferenceInsideMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.opengraph.OpenGraphOverall
 import com.nextcloud.talk.utils.ApiUtils
 import io.reactivex.Observer

+ 54 - 26
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingLinkPreviewMessageViewHolder.kt

@@ -9,17 +9,21 @@ package com.nextcloud.talk.adapters.messages
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.util.Log
 import android.view.View
 import androidx.appcompat.content.res.AppCompatResources
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.OutcomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomOutcomingLinkPreviewMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
@@ -27,6 +31,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -138,34 +147,53 @@ class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                        parentChatMessage.activeUser = message.activeUser
+                        parentChatMessage.imageUrl?.let {
+                            binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                            binding.messageQuote.quotedMessageImage.load(it) {
+                                addHeader(
+                                    "Authorization",
+                                    ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                                )
+                            }
+                        } ?: run {
+                            binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                        }
+                        binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                            ?: context.getText(R.string.nc_nick_guest)
+                        binding.messageQuote.quotedMessage.text = messageUtils
+                            .enrichChatReplyMessageText(
+                                binding.messageQuote.quotedMessage.context,
+                                parentChatMessage,
+                                false,
+                                viewThemeUtils
+                            )
+                        viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
+                        viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
+                        viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
+
+                        binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
             }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    false,
-                    viewThemeUtils
-                )
-            viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
-            viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
-            viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 53 - 26
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingLocationMessageViewHolder.kt

@@ -18,16 +18,19 @@ import android.view.View
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import androidx.appcompat.content.res.AppCompatResources
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.google.android.flexbox.FlexboxLayout
 import com.google.android.material.snackbar.Snackbar
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.IncomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomOutcomingLocationMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
@@ -35,6 +38,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.UriUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import java.net.URLEncoder
 import javax.inject.Inject
 import kotlin.math.roundToInt
@@ -190,34 +198,53 @@ class OutcomingLocationMessageViewHolder(incomingView: View) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            false,
+                            viewThemeUtils
+                        )
+                    viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
+                    viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
+                    viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
             }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    false,
-                    viewThemeUtils
-                )
-            viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
-            viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
-            viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 53 - 26
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingPollMessageViewHolder.kt

@@ -9,18 +9,21 @@ package com.nextcloud.talk.adapters.messages
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.util.Log
 import android.view.View
 import androidx.appcompat.content.res.AppCompatResources
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.IncomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomOutcomingPollMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.polls.ui.PollMainDialogFragment
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
@@ -29,6 +32,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -153,34 +161,53 @@ class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            false,
+                            viewThemeUtils
+                        )
+                    viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
+                    viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
+                    viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
             }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    false,
-                    viewThemeUtils
-                )
-            viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
-            viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
-            viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingPreviewMessageViewHolder.java

@@ -17,7 +17,7 @@ import com.google.android.material.card.MaterialCardView;
 import com.nextcloud.talk.R;
 import com.nextcloud.talk.databinding.ItemCustomOutcomingPreviewMessageBinding;
 import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
-import com.nextcloud.talk.models.json.chat.ChatMessage;
+import com.nextcloud.talk.chat.data.model.ChatMessage;
 import com.nextcloud.talk.utils.TextMatchers;
 
 import java.util.HashMap;

+ 61 - 32
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt

@@ -9,6 +9,7 @@
 package com.nextcloud.talk.adapters.messages
 
 import android.content.Context
+import android.util.Log
 import android.util.TypedValue
 import android.view.View
 import androidx.core.content.res.ResourcesCompat
@@ -20,8 +21,8 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.chat.ChatActivity
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
@@ -29,6 +30,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.TextMatchers
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.stfalcon.chatkit.messages.MessageHolders.OutcomingTextMessageViewHolder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
@@ -91,14 +97,14 @@ class OutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessageViewH
 
         if (message.lastEditTimestamp != 0L && !message.isDeleted) {
             binding.messageEditIndicator.visibility = View.VISIBLE
-            binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp)
+            binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
         } else {
             binding.messageEditIndicator.visibility = View.GONE
             binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
         }
 
         // parent message handling
-        if (!message.isDeleted && message.parentMessage != null) {
+        if (!message.isDeleted && message.parentMessageId != null) {
             processParentMessage(message)
             binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
@@ -148,36 +154,58 @@ class OutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessageViewH
     }
 
     private fun processParentMessage(message: ChatMessage) {
-        val parentChatMessage = message.parentMessage
-        parentChatMessage!!.activeUser = message.activeUser
-        parentChatMessage.imageUrl?.let {
-            binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-            binding.messageQuote.quotedMessageImage.load(it) {
-                addHeader(
-                    "Authorization",
-                    ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
-                )
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
+                    )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+
+                    parentChatMessage!!.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            false,
+                            viewThemeUtils
+                        )
+
+                    viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
+                    viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
+                    viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
+
+                    binding.messageQuote.quotedChatMessageView.setOnClickListener {
+                        val chatActivity = commonMessageInterface as ChatActivity
+                        chatActivity.jumpToQuotedMessage(parentChatMessage)
+                    }
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
+                }
             }
-        } ?: run {
-            binding.messageQuote.quotedMessageImage.visibility = View.GONE
-        }
-        binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-            ?: context.getText(R.string.nc_nick_guest)
-        binding.messageQuote.quotedMessage.text = messageUtils
-            .enrichChatReplyMessageText(
-                binding.messageQuote.quotedMessage.context,
-                parentChatMessage,
-                false,
-                viewThemeUtils
-            )
-
-        viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
-        viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
-        viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
-
-        binding.messageQuote.quotedChatMessageView.setOnClickListener {
-            val chatActivity = commonMessageInterface as ChatActivity
-            chatActivity.jumpToQuotedMessage(parentChatMessage)
         }
     }
 
@@ -191,5 +219,6 @@ class OutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessageViewH
 
     companion object {
         const val TEXT_SIZE_MULTIPLIER = 2.5
+        private val TAG = OutcomingTextMessageViewHolder::class.java.simpleName
     }
 }

+ 56 - 26
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt

@@ -17,16 +17,19 @@ import android.view.View
 import android.widget.SeekBar
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
 import androidx.work.WorkInfo
 import androidx.work.WorkManager
 import autodagger.AutoInjector
 import coil.load
 import com.nextcloud.android.common.ui.theme.utils.ColorRole
 import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.messages.IncomingPollMessageViewHolder.Companion
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
@@ -34,6 +37,11 @@ import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.message.MessageUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.messages.MessageHolders
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import java.util.concurrent.ExecutionException
 import javax.inject.Inject
 
@@ -238,14 +246,17 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
                     Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
                     showVoiceMessageLoading()
                 }
+
                 WorkInfo.State.SUCCEEDED -> {
                     Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
                     showPlayButton()
                 }
+
                 WorkInfo.State.FAILED -> {
                     Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
                     showPlayButton()
                 }
+
                 else -> {
                     Log.d(TAG, "WorkInfo.State unused in ViewHolder")
                 }
@@ -264,34 +275,53 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
     }
 
     private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
-        if (!message.isDeleted && message.parentMessage != null) {
-            val parentChatMessage = message.parentMessage
-            parentChatMessage!!.activeUser = message.activeUser
-            parentChatMessage.imageUrl?.let {
-                binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
-                binding.messageQuote.quotedMessageImage.load(it) {
-                    addHeader(
-                        "Authorization",
-                        ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+        if (message.parentMessageId != null && !message.isDeleted) {
+            CoroutineScope(Dispatchers.Main).launch {
+                try {
+                    val chatActivity = commonMessageInterface as ChatActivity
+                    val urlForChatting = ApiUtils.getUrlForChat(
+                        chatActivity.chatApiVersion,
+                        chatActivity.conversationUser?.baseUrl,
+                        chatActivity.roomToken
                     )
+
+                    val parentChatMessage = withContext(Dispatchers.IO) {
+                        chatActivity.chatViewModel.getMessageById(
+                            urlForChatting,
+                            chatActivity.currentConversation!!,
+                            message.parentMessageId!!
+                        ).first()
+                    }
+                    parentChatMessage.activeUser = message.activeUser
+                    parentChatMessage.imageUrl?.let {
+                        binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
+                        binding.messageQuote.quotedMessageImage.load(it) {
+                            addHeader(
+                                "Authorization",
+                                ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
+                            )
+                        }
+                    } ?: run {
+                        binding.messageQuote.quotedMessageImage.visibility = View.GONE
+                    }
+                    binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
+                        ?: context!!.getText(R.string.nc_nick_guest)
+                    binding.messageQuote.quotedMessage.text = messageUtils
+                        .enrichChatReplyMessageText(
+                            binding.messageQuote.quotedMessage.context,
+                            parentChatMessage,
+                            false,
+                            viewThemeUtils
+                        )
+                    viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
+                    viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
+                    viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
+
+                    binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
+                } catch (e: Exception) {
+                    Log.d(TAG, "Error when processing parent message in view holder", e)
                 }
-            } ?: run {
-                binding.messageQuote.quotedMessageImage.visibility = View.GONE
             }
-            binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
-                ?: context!!.getText(R.string.nc_nick_guest)
-            binding.messageQuote.quotedMessage.text = messageUtils
-                .enrichChatReplyMessageText(
-                    binding.messageQuote.quotedMessage.context,
-                    parentChatMessage,
-                    false,
-                    viewThemeUtils
-                )
-            viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
-            viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
-            viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
-
-            binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
         } else {
             binding.messageQuote.quotedChatMessageView.visibility = View.GONE
         }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageInterface.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.adapters.messages
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 interface PreviewMessageInterface {
     fun onPreviewMessageLongClick(chatMessage: ChatMessage)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt

@@ -34,7 +34,7 @@ import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
 import com.nextcloud.talk.extensions.loadChangelogBotAvatar
 import com.nextcloud.talk.extensions.loadFederatedUserAvatar
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.users.UserManager
 import com.nextcloud.talk.utils.DateUtils

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/Reaction.kt

@@ -12,7 +12,7 @@ import android.view.ViewGroup
 import android.widget.LinearLayout
 import android.widget.TextView
 import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.DisplayUtils
 import com.vanniktech.emoji.EmojiTextView

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageInterface.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.adapters.messages
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 interface SystemMessageInterface {
     fun expandSystemMessage(chatMessage: ChatMessage)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt

@@ -19,7 +19,7 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.databinding.ItemSystemMessageBinding
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.DisplayUtils
 import com.nextcloud.talk.utils.preferences.AppPreferences

+ 3 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java

@@ -33,7 +33,7 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
 
     @Override
     public void onBindViewHolder(ViewHolder holder, int position) {
-        super.onBindViewHolder(holder, position);
+
 
         if (holder instanceof IncomingTextMessageViewHolder) {
             ((IncomingTextMessageViewHolder) holder).assignCommonMessageInterface(chatActivity);
@@ -66,5 +66,7 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
         } else if (holder instanceof CallStartedViewHolder) {
             ((CallStartedViewHolder) holder).assignCallStartedMessageInterface(chatActivity);
         }
+
+        super.onBindViewHolder(holder, position);
     }
 }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/UnreadNoticeMessageViewHolder.java

@@ -9,7 +9,7 @@ package com.nextcloud.talk.adapters.messages;
 
 import android.view.View;
 
-import com.nextcloud.talk.models.json.chat.ChatMessage;
+import com.nextcloud.talk.chat.data.model.ChatMessage;
 import com.stfalcon.chatkit.messages.MessageHolders;
 
 public class UnreadNoticeMessageViewHolder extends MessageHolders.SystemMessageViewHolder<ChatMessage> {

+ 1 - 1
app/src/main/java/com/nextcloud/talk/adapters/messages/VoiceMessageInterface.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.adapters.messages
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 interface VoiceMessageInterface {
     fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int)

+ 3 - 1
app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt

@@ -36,6 +36,7 @@ import com.nextcloud.talk.BuildConfig
 import com.nextcloud.talk.components.filebrowser.webdav.DavUtils
 import com.nextcloud.talk.dagger.modules.BusModule
 import com.nextcloud.talk.dagger.modules.ContextModule
+import com.nextcloud.talk.dagger.modules.DaosModule
 import com.nextcloud.talk.dagger.modules.DatabaseModule
 import com.nextcloud.talk.dagger.modules.ManagerModule
 import com.nextcloud.talk.dagger.modules.RepositoryModule
@@ -79,7 +80,8 @@ import javax.inject.Singleton
         RepositoryModule::class,
         UtilsModule::class,
         ThemeModule::class,
-        ManagerModule::class
+        ManagerModule::class,
+        DaosModule::class
     ]
 )
 @Singleton

File diff suppressed because it is too large
+ 237 - 330
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt


+ 84 - 1
app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt

@@ -27,6 +27,7 @@ import android.view.View
 import android.view.ViewGroup
 import android.view.animation.AlphaAnimation
 import android.view.animation.Animation
+import android.view.animation.Animation.AnimationListener
 import android.view.animation.LinearInterpolator
 import android.widget.ImageButton
 import android.widget.ImageView
@@ -40,6 +41,7 @@ import androidx.core.content.ContextCompat
 import androidx.core.widget.doAfterTextChanged
 import androidx.emoji2.widget.EmojiTextView
 import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
 import autodagger.AutoInjector
 import coil.load
 import com.google.android.flexbox.FlexboxLayout
@@ -50,10 +52,11 @@ import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.callbacks.MentionAutocompleteCallback
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.chat.viewmodels.ChatViewModel
+import com.nextcloud.talk.data.network.NetworkMonitor
 import com.nextcloud.talk.databinding.FragmentMessageInputBinding
 import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
-import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.models.json.mention.Mention
 import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
 import com.nextcloud.talk.presenters.MentionAutocompletePresenter
@@ -70,6 +73,9 @@ import com.nextcloud.talk.utils.text.Spans
 import com.otaliastudios.autocomplete.Autocomplete
 import com.stfalcon.chatkit.commons.models.IMessage
 import com.vanniktech.emoji.EmojiPopup
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
 import java.util.Objects
 import javax.inject.Inject
 
@@ -101,6 +107,9 @@ class MessageInputFragment : Fragment() {
     @Inject
     lateinit var userManager: UserManager
 
+    @Inject
+    lateinit var networkMonitor: NetworkMonitor
+
     lateinit var binding: FragmentMessageInputBinding
     private var typedWhileTypingTimerIsRunning: Boolean = false
     private var typingTimer: CountDownTimer? = null
@@ -158,6 +167,79 @@ class MessageInputFragment : Fragment() {
                 else -> {}
             }
         }
+
+        viewLifecycleOwner.lifecycleScope.launch {
+            var wasOnline = true
+            networkMonitor.isOnline.onEach { isOnline ->
+                val connectionGained = (!wasOnline && isOnline)
+                wasOnline = !binding.fragmentMessageInputView.isShown
+                Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained")
+
+                // FIXME timeout exception - maybe something to do with the room?
+                // handleMessageQueue(isOnline)
+                handleUI(isOnline, connectionGained)
+            }.collect()
+        }
+    }
+
+    private fun handleUI(isOnline: Boolean, connectionGained: Boolean) {
+        if (isOnline) {
+            if (connectionGained) {
+                val animation: Animation = AlphaAnimation(1.0f, 0.0f)
+                animation.duration = 3000
+                animation.interpolator = LinearInterpolator()
+                binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityGreen))
+                binding.fragmentConnectionLost.text = getString(R.string.connection_gained)
+                binding.fragmentConnectionLost.startAnimation(animation)
+                binding.fragmentConnectionLost.animation.setAnimationListener(object : AnimationListener {
+                    override fun onAnimationStart(animation: Animation?) {
+                        // unused atm
+                    }
+
+                    override fun onAnimationEnd(animation: Animation?) {
+                        binding.fragmentConnectionLost.visibility = View.GONE
+                        binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed))
+                        binding.fragmentConnectionLost.text =
+                            getString(R.string.connection_lost_sent_messages_are_queued)
+                    }
+
+                    override fun onAnimationRepeat(animation: Animation?) {
+                        // unused atm
+                    }
+                })
+            }
+
+            binding.fragmentMessageInputView.attachmentButton.isEnabled = true
+            binding.fragmentMessageInputView.recordAudioButton.isEnabled = true
+            binding.fragmentMessageInputView.messageInput.isEnabled = true
+        } else {
+            binding.fragmentConnectionLost.clearAnimation()
+            binding.fragmentConnectionLost.visibility = View.GONE
+            binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed))
+            binding.fragmentConnectionLost.text =
+                getString(R.string.connection_lost_sent_messages_are_queued)
+            binding.fragmentConnectionLost.visibility = View.VISIBLE
+            binding.fragmentMessageInputView.attachmentButton.isEnabled = false
+            binding.fragmentMessageInputView.recordAudioButton.isEnabled = false
+            binding.fragmentMessageInputView.messageInput.isEnabled = false
+        }
+    }
+
+    private fun handleMessageQueue(isOnline: Boolean) {
+        if (isOnline) {
+            chatActivity.messageInputViewModel.switchToMessageQueue(false)
+            chatActivity.messageInputViewModel.sendAndEmptyMessageQueue(
+                chatActivity.roomToken,
+                chatActivity.conversationUser!!.getCredentials(),
+                ApiUtils.getUrlForChat(
+                    chatActivity.chatApiVersion,
+                    chatActivity.conversationUser!!.baseUrl!!,
+                    chatActivity.roomToken
+                )
+            )
+        } else {
+            chatActivity.messageInputViewModel.switchToMessageQueue(true)
+        }
     }
 
     private fun restoreState() {
@@ -694,6 +776,7 @@ class MessageInputFragment : Fragment() {
 
     private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) {
         chatActivity.messageInputViewModel.sendChatMessage(
+            chatActivity.roomToken,
             chatActivity.conversationUser!!.getCredentials(),
             ApiUtils.getUrlForChat(
                 chatActivity.chatApiVersion,

+ 70 - 0
app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt

@@ -0,0 +1,70 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat.data
+
+import android.os.Bundle
+import com.nextcloud.talk.chat.data.io.LifecycleAwareManager
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.models.domain.ConversationModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+
+interface ChatMessageRepository : LifecycleAwareManager {
+
+    /**
+     * Stream of a list of messages to be handled using the associated boolean
+     * false for past messages, true for future messages.
+     */
+    val messageFlow:
+        Flow<
+            Pair<
+                Boolean,
+                List<ChatMessage>
+                >
+            >
+
+    val updateMessageFlow: Flow<ChatMessage>
+
+    val lastCommonReadFlow: Flow<Int>
+
+    fun setData(
+        conversationModel: ConversationModel,
+        credentials: String,
+        urlForChatting: String
+    )
+
+    fun loadInitialMessages(withNetworkParams: Bundle): Job
+
+    /**
+     * Loads messages from local storage. If the messages are not found, then it
+     * synchronizes the database with the server, before retrying exactly once. Only
+     * emits to [messageFlow] if the message list is not empty.
+     *
+     * [withNetworkParams] credentials and url
+     */
+    fun loadMoreMessages(
+        beforeMessageId: Long,
+        roomToken: String,
+        withMessageLimit: Int,
+        withNetworkParams: Bundle
+    ): Job
+
+    /**
+     * Long polls the server for any updates to the chat, if found, it synchronizes
+     * the database with the server and emits the new messages to [messageFlow],
+     * else it simply retries after timeout.
+     *
+     * [withNetworkParams] credentials and url.
+     */
+    fun initMessagePolling(): Job
+
+    /**
+     * Gets a individual message.
+     */
+    suspend fun getMessage(messageId: Long, bundle: Bundle): Flow<ChatMessage>
+}

+ 7 - 127
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt → app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt

@@ -2,120 +2,88 @@
  * Nextcloud Talk - Android Client
  *
  * SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
- * SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-FileCopyrightText: 2022-2024 Marcel Hibbe <dev@mhibbe.de>
  * SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
  * SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
-package com.nextcloud.talk.models.json.chat
+package com.nextcloud.talk.chat.data.model
 
-import android.os.Parcelable
 import android.text.TextUtils
 import android.util.Log
-import com.bluelinelabs.logansquare.annotation.JsonField
 import com.bluelinelabs.logansquare.annotation.JsonIgnore
-import com.bluelinelabs.logansquare.annotation.JsonObject
 import com.nextcloud.talk.R
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
+import com.nextcloud.talk.models.json.chat.ReadStatus
 import com.nextcloud.talk.models.json.converters.EnumSystemMessageTypeConverter
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.CapabilitiesUtil
 import com.stfalcon.chatkit.commons.models.IUser
 import com.stfalcon.chatkit.commons.models.MessageContentType
-import kotlinx.parcelize.Parcelize
 import java.security.MessageDigest
 import java.util.Date
 
-@Parcelize
-@JsonObject
 data class ChatMessage(
-    @JsonIgnore
     var isGrouped: Boolean = false,
 
-    @JsonIgnore
     var isOneToOneConversation: Boolean = false,
 
-    @JsonIgnore
     var isFormerOneToOneConversation: Boolean = false,
 
-    @JsonIgnore
     var activeUser: User? = null,
 
-    @JsonIgnore
     var selectedIndividualHashMap: Map<String?, String?>? = null,
 
-    @JsonIgnore
     var isDeleted: Boolean = false,
 
-    @JsonField(name = ["id"])
     var jsonMessageId: Int = 0,
 
-    @JsonIgnore
     var previousMessageId: Int = -1,
 
-    @JsonField(name = ["token"])
     var token: String? = null,
 
     // guests or users
-    @JsonField(name = ["actorType"])
     var actorType: String? = null,
 
-    @JsonField(name = ["actorId"])
     var actorId: String? = null,
 
     // send when crafting a message
-    @JsonField(name = ["actorDisplayName"])
     var actorDisplayName: String? = null,
 
-    @JsonField(name = ["timestamp"])
     var timestamp: Long = 0,
 
     // send when crafting a message, max 1000 lines
-    @JsonField(name = ["message"])
     var message: String? = null,
 
-    @JsonField(name = ["messageParameters"])
     var messageParameters: HashMap<String?, HashMap<String?, String?>>? = null,
 
-    @JsonField(name = ["systemMessage"], typeConverter = EnumSystemMessageTypeConverter::class)
     var systemMessageType: SystemMessageType? = null,
 
-    @JsonField(name = ["isReplyable"])
     var replyable: Boolean = false,
 
-    @JsonField(name = ["parent"])
-    var parentMessage: ChatMessage? = null,
+    var parentMessageId: Long? = null,
 
     var readStatus: Enum<ReadStatus> = ReadStatus.NONE,
 
-    @JsonField(name = ["messageType"])
     var messageType: String? = null,
 
-    @JsonField(name = ["reactions"])
     var reactions: LinkedHashMap<String, Int>? = null,
 
-    @JsonField(name = ["reactionsSelf"])
     var reactionsSelf: ArrayList<String>? = null,
 
-    @JsonField(name = ["expirationTimestamp"])
     var expirationTimestamp: Int = 0,
 
-    @JsonField(name = ["markdown"])
     var renderMarkdown: Boolean? = null,
 
-    @JsonField(name = ["lastEditActorDisplayName"])
     var lastEditActorDisplayName: String? = null,
 
-    @JsonField(name = ["lastEditActorId"])
     var lastEditActorId: String? = null,
 
-    @JsonField(name = ["lastEditActorType"])
     var lastEditActorType: String? = null,
 
-    @JsonField(name = ["lastEditTimestamp"])
-    var lastEditTimestamp: Long = 0,
+    var lastEditTimestamp: Long? = 0,
 
     var isDownloadingVoiceMessage: Boolean = false,
 
@@ -145,7 +113,7 @@ data class ChatMessage(
 
     var openWhenDownloaded: Boolean = true
 
-) : Parcelable, MessageContentType, MessageContentType.Image {
+) : MessageContentType, MessageContentType.Image {
 
     var extractedUrlToPreview: String? = null
 
@@ -282,95 +250,7 @@ data class ChatMessage(
         }
     }
 
-    val lastMessageDisplayText: String
-        get() {
-            if (getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE ||
-                getCalculateMessageType() == MessageType.SYSTEM_MESSAGE ||
-                getCalculateMessageType() == MessageType.SINGLE_LINK_MESSAGE
-            ) {
-                return text
-            } else {
-                if (MessageType.SINGLE_LINK_GIPHY_MESSAGE == getCalculateMessageType() ||
-                    MessageType.SINGLE_LINK_TENOR_MESSAGE == getCalculateMessageType() ||
-                    MessageType.SINGLE_LINK_GIF_MESSAGE == getCalculateMessageType()
-                ) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_a_gif_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_a_gif),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_an_attachment_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_an_attachment),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.SINGLE_NC_GEOLOCATION_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_location_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_location),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.VOICE_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_voice_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_voice),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.SINGLE_LINK_AUDIO_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_an_audio_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_an_audio),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.SINGLE_LINK_VIDEO_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_a_video_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_a_video),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.SINGLE_LINK_IMAGE_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_an_image_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_an_image),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                } else if (MessageType.POLL_MESSAGE == getCalculateMessageType()) {
-                    return if (actorId == activeUser!!.userId) {
-                        sharedApplication!!.getString(R.string.nc_sent_poll_you)
-                    } else {
-                        String.format(
-                            sharedApplication!!.resources.getString(R.string.nc_sent_poll),
-                            getNullsafeActorDisplayName()
-                        )
-                    }
-                }
-            }
-            return ""
-        }
-
-    private fun getNullsafeActorDisplayName() =
+    fun getNullsafeActorDisplayName() =
         if (!TextUtils.isEmpty(actorDisplayName)) {
             actorDisplayName
         } else {

+ 2 - 2
app/src/main/java/com/nextcloud/talk/chat/data/ChatRepository.kt → app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt

@@ -4,7 +4,7 @@
  * SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
-package com.nextcloud.talk.chat.data
+package com.nextcloud.talk.chat.data.network
 
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
@@ -19,7 +19,7 @@ import io.reactivex.Observable
 import retrofit2.Response
 
 @Suppress("LongParameterList", "TooManyFunctions")
-interface ChatRepository {
+interface ChatNetworkDataSource {
     fun getRoom(user: User, roomToken: String): Observable<ConversationModel>
     fun getCapabilities(user: User, roomToken: String): Observable<SpreedCapability>
     fun joinRoom(user: User, roomToken: String, roomPassword: String): Observable<ConversationModel>

+ 623 - 0
app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt

@@ -0,0 +1,623 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat.data.network
+
+import android.os.Bundle
+import android.util.Log
+import com.nextcloud.talk.chat.data.ChatMessageRepository
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.database.dao.ChatBlocksDao
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.mappers.asEntity
+import com.nextcloud.talk.data.database.mappers.asModel
+import com.nextcloud.talk.data.database.model.ChatBlockEntity
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.data.network.NetworkMonitor
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.models.json.chat.ChatMessageJson
+import com.nextcloud.talk.models.json.chat.ChatOverall
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
+import com.nextcloud.talk.utils.preferences.AppPreferences
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class OfflineFirstChatRepository @Inject constructor(
+    private val chatDao: ChatMessagesDao,
+    private val chatBlocksDao: ChatBlocksDao,
+    private val network: ChatNetworkDataSource,
+    private val datastore: AppPreferences,
+    private val monitor: NetworkMonitor,
+    private val userProvider: CurrentUserProviderNew
+) : ChatMessageRepository {
+
+    val currentUser: User = userProvider.currentUser.blockingGet()
+
+    override val messageFlow:
+        Flow<
+            Pair<
+                Boolean,
+                List<ChatMessage>
+                >
+            >
+        get() = _messageFlow
+
+    private val _messageFlow:
+        MutableSharedFlow<
+            Pair<
+                Boolean,
+                List<ChatMessage>
+                >
+            > = MutableSharedFlow()
+
+    override val updateMessageFlow:
+        Flow<ChatMessage>
+        get() = _updateMessageFlow
+
+    private val _updateMessageFlow:
+        MutableSharedFlow<ChatMessage> = MutableSharedFlow()
+
+    override val lastCommonReadFlow:
+        Flow<Int>
+        get() = _lastCommonReadFlow
+
+    private val _lastCommonReadFlow:
+        MutableSharedFlow<Int> = MutableSharedFlow()
+
+    private var newXChatLastCommonRead: Int? = null
+    private var itIsPaused = false
+    private val scope = CoroutineScope(Dispatchers.IO)
+
+    lateinit var internalConversationId: String
+    private lateinit var conversationModel: ConversationModel
+    private lateinit var credentials: String
+    private lateinit var urlForChatting: String
+
+    override fun setData(
+        conversationModel: ConversationModel,
+        credentials: String,
+        urlForChatting: String
+    ) {
+        this.conversationModel = conversationModel
+        this.credentials = credentials
+        this.urlForChatting = urlForChatting
+        internalConversationId = conversationModel.internalId
+    }
+
+    override fun loadInitialMessages(withNetworkParams: Bundle): Job =
+        scope.launch {
+            Log.d(TAG, "---- loadInitialMessages ------------")
+
+            newXChatLastCommonRead = conversationModel.lastCommonReadMessage
+
+            val fieldMap = getFieldMap(
+                lookIntoFuture = false,
+                includeLastKnown = true,
+                setReadMarker = true,
+                lastKnown = null
+            )
+            withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
+            withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token)
+
+            sync(withNetworkParams)
+
+            Log.d(TAG, "newestMessageId after sync: " + chatDao.getNewestMessageId(internalConversationId))
+
+            showLast100MessagesBeforeAndEqual(
+                internalConversationId,
+                chatDao.getNewestMessageId(internalConversationId)
+            )
+            updateUiForLastCommonRead(200)
+
+            initMessagePolling()
+        }
+
+    private fun updateUiForLastCommonRead(delay: Long) {
+        scope.launch {
+            // delay is a dirty workaround to make sure messages are added to adapter on initial load before setting
+            // their read status(otherwise there is a race condition between adding messages and setting their read
+            // status).
+            if (delay > 0) {
+                delay(delay)
+            }
+            newXChatLastCommonRead?.let {
+                _lastCommonReadFlow.emit(it)
+            }
+        }
+    }
+
+    override fun loadMoreMessages(
+        beforeMessageId: Long,
+        roomToken: String,
+        withMessageLimit: Int,
+        withNetworkParams: Bundle
+    ): Job =
+        scope.launch {
+            Log.d(TAG, "---- loadMoreMessages for $beforeMessageId ------------")
+
+            val fieldMap = getFieldMap(
+                lookIntoFuture = false,
+                includeLastKnown = false,
+                setReadMarker = true,
+                lastKnown = beforeMessageId.toInt()
+            )
+            withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
+
+            val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId)
+
+            if (loadFromServer) {
+                sync(withNetworkParams)
+            }
+
+            showLast100MessagesBefore(internalConversationId, beforeMessageId)
+            updateUiForLastCommonRead(0)
+        }
+
+    override fun initMessagePolling(): Job =
+        scope.launch {
+            Log.d(TAG, "---- initMessagePolling ------------")
+
+            val initialMessageId = chatDao.getNewestMessageId(internalConversationId).toInt()
+            Log.d(TAG, "newestMessage: $initialMessageId")
+
+            var fieldMap = getFieldMap(
+                lookIntoFuture = true,
+                includeLastKnown = false,
+                setReadMarker = true,
+                lastKnown = initialMessageId
+            )
+
+            val networkParams = Bundle()
+
+            while (!itIsPaused) {
+                if (!monitor.isOnline.first()) Thread.sleep(500)
+
+                // sync database with server (This is a long blocking call because long polling (lookIntoFuture) is set)
+                networkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
+
+                val resultsFromSync = sync(networkParams)
+                if (!resultsFromSync.isNullOrEmpty()) {
+                    val chatMessages = resultsFromSync.map(ChatMessageEntity::asModel)
+                    val pair = Pair(true, chatMessages)
+                    _messageFlow.emit(pair)
+                }
+
+                updateUiForLastCommonRead(0)
+
+                val newestMessage = chatDao.getNewestMessageId(internalConversationId).toInt()
+
+                // update field map vars for next cycle
+                fieldMap = getFieldMap(
+                    lookIntoFuture = true,
+                    includeLastKnown = false,
+                    setReadMarker = true,
+                    lastKnown = newestMessage
+                )
+            }
+        }
+
+    private suspend fun hasToLoadPreviousMessagesFromServer(
+        beforeMessageId: Long
+    ): Boolean {
+        val loadFromServer: Boolean
+
+        val blockForMessage = getBlockOfMessage(beforeMessageId.toInt())
+
+        if (blockForMessage == null) {
+            Log.d(TAG, "No blocks for this message were found so we have to ask server")
+            loadFromServer = true
+        } else if (!blockForMessage.hasHistory) {
+            Log.d(TAG, "The last chatBlock is reached so we won't request server for older messages")
+            loadFromServer = false
+        } else {
+            // we know that beforeMessageId and blockForMessage.oldestMessageId are in the same block.
+            // As we want the last 100 entries before beforeMessageId, we calculate if these messages are 100
+            // entries apart from each other
+
+            val amountBetween = chatDao.getCountBetweenMessageIds(
+                internalConversationId,
+                beforeMessageId,
+                blockForMessage.oldestMessageId
+            )
+            loadFromServer = amountBetween < 100
+
+            Log.d(
+                TAG, "Amount between messageId " + beforeMessageId + " and " + blockForMessage.oldestMessageId +
+                    " is: " + amountBetween + " so 'loadFromServer' is " + loadFromServer
+            )
+        }
+        return loadFromServer
+    }
+
+    private fun getFieldMap(
+        lookIntoFuture: Boolean,
+        includeLastKnown: Boolean,
+        setReadMarker: Boolean,
+        lastKnown: Int?
+    ): HashMap<String, Int> {
+        val fieldMap = HashMap<String, Int>()
+
+        fieldMap["includeLastKnown"] = if (includeLastKnown) 1 else 0
+
+        if (lastKnown != null) {
+            fieldMap["lastKnownMessageId"] = lastKnown
+        }
+
+        newXChatLastCommonRead?.let {
+            fieldMap["lastCommonReadId"] = it
+        }
+
+        fieldMap["timeout"] = if (lookIntoFuture) 30 else 0
+        fieldMap["limit"] = 100
+        fieldMap["lookIntoFuture"] = if (lookIntoFuture) 1 else 0
+        fieldMap["setReadMarker"] = if (setReadMarker) 1 else 0
+
+        return fieldMap
+    }
+
+    override suspend fun getMessage(messageId: Long, bundle: Bundle):
+        Flow<ChatMessage> {
+
+        Log.d(TAG, "Get message with id $messageId")
+        val loadFromServer = hasToLoadPreviousMessagesFromServer(messageId)
+
+        if (loadFromServer) {
+            val fieldMap = getFieldMap(
+                lookIntoFuture = false,
+                includeLastKnown = true,
+                setReadMarker = false,
+                lastKnown = messageId.toInt()
+            )
+            bundle.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
+
+            // Although only the single message will be returned, a server request will load 100 messages.
+            // If this turns out to be confusion for debugging we could load set the limit to 1 for this request.
+            sync(bundle)
+        }
+        return chatDao.getChatMessageForConversation(internalConversationId, messageId)
+            .map(ChatMessageEntity::asModel)
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    private fun getMessagesFromServer(bundle: Bundle): Pair<Int, List<ChatMessageJson>>? {
+        Log.d(TAG, "An online request is made!!!!!!!!!!!!!!!!!!!!")
+        val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap<String, Int>
+
+        try {
+            val result = network.pullChatMessages(credentials, urlForChatting, fieldMap)
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                // .timeout(3, TimeUnit.SECONDS)
+                .map { it ->
+                    when (it.code()) {
+                        HTTP_CODE_OK -> {
+                            Log.d(TAG, "getMessagesFromServer HTTP_CODE_OK")
+                            newXChatLastCommonRead = it.headers()["X-Chat-Last-Common-Read"]?.let {
+                                Integer.parseInt(it)
+                            }
+
+                            return@map Pair(
+                                HTTP_CODE_OK,
+                                (it.body() as ChatOverall).ocs!!.data!!
+                            )
+                        }
+
+                        HTTP_CODE_NOT_MODIFIED -> {
+                            Log.d(TAG, "getMessagesFromServer HTTP_CODE_NOT_MODIFIED")
+
+                            return@map Pair(
+                                HTTP_CODE_NOT_MODIFIED,
+                                listOf<ChatMessageJson>()
+                            )
+                        }
+
+                        HTTP_CODE_PRECONDITION_FAILED -> {
+                            Log.d(TAG, "getMessagesFromServer HTTP_CODE_PRECONDITION_FAILED")
+
+                            return@map Pair(
+                                HTTP_CODE_PRECONDITION_FAILED,
+                                listOf<ChatMessageJson>()
+                            )
+                        }
+
+                        else -> {
+                            return@map Pair(
+                                HTTP_CODE_PRECONDITION_FAILED,
+                                listOf<ChatMessageJson>()
+                            )
+                        }
+                    }
+                }
+                .blockingSingle()
+            return result
+        } catch (e: Exception) {
+            Log.e(TAG, "Something went wrong when pulling chat messages", e)
+        }
+        return null
+    }
+
+    private suspend fun sync(bundle: Bundle): List<ChatMessageEntity>? {
+        if (!monitor.isOnline.first()) {
+            Log.d(TAG, "Device is offline, can't load chat messages from server")
+            return null
+        }
+
+        val result = getMessagesFromServer(bundle) ?: return listOf()
+        var chatMessagesFromSync: List<ChatMessageEntity>? = null
+
+        val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap<String, Int>
+        val queriedMessageId = fieldMap["lastKnownMessageId"]
+        val lookIntoFuture = fieldMap["lookIntoFuture"] == 1
+
+        val statusCode = result.first
+
+        val hasHistory = getHasHistory(statusCode, lookIntoFuture)
+
+        Log.d(
+            TAG,
+            "internalConv=$internalConversationId statusCode=$statusCode lookIntoFuture=$lookIntoFuture " +
+                "hasHistory=$hasHistory " +
+                "queriedMessageId=$queriedMessageId"
+        )
+
+        val blockContainingQueriedMessage: ChatBlockEntity? = getBlockOfMessage(queriedMessageId)
+
+        if (blockContainingQueriedMessage != null && !hasHistory) {
+            blockContainingQueriedMessage.hasHistory = false
+            chatBlocksDao.upsertChatBlock(blockContainingQueriedMessage)
+            Log.d(TAG, "End of chat was reached so hasHistory=false is set")
+        }
+
+        if (result.second.isNotEmpty()) {
+            val chatMessagesJson = result.second
+
+            handleUpdateMessages(chatMessagesJson)
+
+            chatMessagesFromSync = chatMessagesJson.map {
+                it.asEntity(currentUser.id!!)
+            }
+
+            chatDao.upsertChatMessages(chatMessagesFromSync)
+
+            val oldestIdFromSync = chatMessagesFromSync.minByOrNull { it.id }!!.id
+            val newestIdFromSync = chatMessagesFromSync.maxByOrNull { it.id }!!.id
+            Log.d(TAG, "oldestIdFromSync: $oldestIdFromSync")
+            Log.d(TAG, "newestIdFromSync: $newestIdFromSync")
+
+            var oldestMessageIdForNewChatBlock = oldestIdFromSync
+            var newestMessageIdForNewChatBlock = newestIdFromSync
+
+            if (blockContainingQueriedMessage != null) {
+                if (lookIntoFuture) {
+                    val oldestMessageIdFromBlockOfQueriedMessage = blockContainingQueriedMessage.oldestMessageId
+                    Log.d(TAG, "oldestMessageIdFromBlockOfQueriedMessage: $oldestMessageIdFromBlockOfQueriedMessage")
+                    oldestMessageIdForNewChatBlock = oldestMessageIdFromBlockOfQueriedMessage
+                } else {
+                    val newestMessageIdFromBlockOfQueriedMessage = blockContainingQueriedMessage.newestMessageId
+                    Log.d(TAG, "newestMessageIdFromBlockOfQueriedMessage: $newestMessageIdFromBlockOfQueriedMessage")
+                    newestMessageIdForNewChatBlock = newestMessageIdFromBlockOfQueriedMessage
+                }
+            }
+
+            Log.d(TAG, "oldestMessageIdForNewChatBlock: $oldestMessageIdForNewChatBlock")
+            Log.d(TAG, "newestMessageIdForNewChatBlock: $newestMessageIdForNewChatBlock")
+
+            val newChatBlock = ChatBlockEntity(
+                internalConversationId = internalConversationId,
+                accountId = conversationModel.accountId,
+                token = conversationModel.token,
+                oldestMessageId = oldestMessageIdForNewChatBlock,
+                newestMessageId = newestMessageIdForNewChatBlock,
+                hasHistory = hasHistory
+            )
+            chatBlocksDao.upsertChatBlock(newChatBlock)
+
+            updateBlocks(newChatBlock)
+        } else {
+            Log.d(TAG, "no data is updated...")
+        }
+
+        return chatMessagesFromSync
+    }
+
+    private suspend fun handleUpdateMessages(messagesJson: List<ChatMessageJson>) {
+        messagesJson.forEach { messageJson ->
+            when (messageJson.systemMessageType) {
+                ChatMessage.SystemMessageType.REACTION,
+                ChatMessage.SystemMessageType.REACTION_REVOKED,
+                ChatMessage.SystemMessageType.REACTION_DELETED,
+                ChatMessage.SystemMessageType.MESSAGE_DELETED,
+                ChatMessage.SystemMessageType.POLL_VOTED,
+                ChatMessage.SystemMessageType.MESSAGE_EDITED -> {
+                    // the parent message is always the newest state, no matter how old the system message is.
+                    // that's why we can just take the parent, update it in DB and update the UI
+                    messageJson.parentMessage?.let { parentMessageJson ->
+                        val parentMessageEntity = parentMessageJson.asEntity(currentUser.id!!)
+                        chatDao.upsertChatMessage(parentMessageEntity)
+                        _updateMessageFlow.emit(parentMessageEntity.asModel())
+                    }
+                }
+
+                ChatMessage.SystemMessageType.CLEARED_CHAT -> {
+                    // for lookIntoFuture just deleting everything would be fine.
+                    // But lets say we did not open the chat for a while and in between it was cleared.
+                    // We just load the last 100 messages but this don't contain the system message.
+                    // We scroll up and load the system message. Deleting everything is not an option as we
+                    // would loose the messages that we want to keep. We only want to
+                    // delete the messages and chatBlocks older than the system message.
+                    chatDao.deleteMessagesOlderThan(internalConversationId, messageJson.id)
+                    chatBlocksDao.deleteChatBlocksOlderThan(internalConversationId, messageJson.id)
+                }
+
+                else -> {}
+            }
+        }
+    }
+
+    /**
+     *  304 is returned when oldest message of chat was queried or when long polling request returned with no
+     *  modification. hasHistory is only set to false, when 304 was returned for the the oldest message
+     */
+    private fun getHasHistory(statusCode: Int, lookIntoFuture: Boolean): Boolean {
+        return if (statusCode == HTTP_CODE_NOT_MODIFIED) {
+            lookIntoFuture
+        } else {
+            true
+        }
+    }
+
+    private suspend fun getBlockOfMessage(queriedMessageId: Int?): ChatBlockEntity? {
+        var blockContainingQueriedMessage: ChatBlockEntity? = null
+        if (queriedMessageId != null) {
+            val blocksContainingQueriedMessage =
+                chatBlocksDao.getChatBlocksContainingMessageId(internalConversationId, queriedMessageId.toLong())
+
+            val chatBlocks = blocksContainingQueriedMessage.first()
+            if (chatBlocks.size > 1) {
+                Log.w(TAG, "multiple chat blocks with messageId $queriedMessageId were found")
+            }
+
+            blockContainingQueriedMessage = if (chatBlocks.isNotEmpty()) {
+                chatBlocks.first()
+            } else {
+                null
+            }
+        }
+        return blockContainingQueriedMessage
+    }
+
+    private suspend fun updateBlocks(chatBlock: ChatBlockEntity): ChatBlockEntity? {
+        val connectedChatBlocks =
+            chatBlocksDao.getConnectedChatBlocks(
+                internalConversationId,
+                chatBlock.oldestMessageId,
+                chatBlock.newestMessageId
+            ).first()
+
+        if (connectedChatBlocks.size == 1) {
+            Log.d(TAG, "This chatBlock is not connected to others")
+            val chatBlockFromDb = connectedChatBlocks[0]
+            Log.d(TAG, "chatBlockFromDb.oldestMessageId: " + chatBlockFromDb.oldestMessageId)
+            Log.d(TAG, "chatBlockFromDb.newestMessageId: " + chatBlockFromDb.newestMessageId)
+            return chatBlockFromDb
+        } else if (connectedChatBlocks.size > 1) {
+            Log.d(TAG, "Found " + connectedChatBlocks.size + " chat blocks that are connected")
+            val oldestIdFromDbChatBlocks =
+                connectedChatBlocks.minByOrNull { it.oldestMessageId }!!.oldestMessageId
+            val newestIdFromDbChatBlocks =
+                connectedChatBlocks.maxByOrNull { it.newestMessageId }!!.newestMessageId
+
+            val hasNoHistory = connectedChatBlocks.any { !it.hasHistory }
+            val hasHistory = !hasNoHistory
+            Log.d(TAG, "hasHistory = $hasHistory")
+
+            chatBlocksDao.deleteChatBlocks(connectedChatBlocks)
+            Log.d(TAG, "These chat blocks were deleted")
+
+            val newChatBlock = ChatBlockEntity(
+                internalConversationId = internalConversationId,
+                accountId = conversationModel.accountId,
+                token = conversationModel.token,
+                oldestMessageId = oldestIdFromDbChatBlocks,
+                newestMessageId = newestIdFromDbChatBlocks,
+                hasHistory = hasHistory
+            )
+            chatBlocksDao.upsertChatBlock(newChatBlock)
+            Log.d(TAG, "A new chat block was created that covers all the range of the found chatblocks")
+            Log.d(TAG, "new chatBlock - oldest MessageId: $oldestIdFromDbChatBlocks")
+            Log.d(TAG, "new chatBlock - newest MessageId: $newestIdFromDbChatBlocks")
+            return newChatBlock
+        } else {
+            Log.d(TAG, "No chat block found ....")
+            return null
+        }
+    }
+
+    private suspend fun showLast100MessagesBeforeAndEqual(internalConversationId: String, messageId: Long) {
+        suspend fun getMessagesBeforeAndEqual(
+            messageId: Long,
+            internalConversationId: String,
+            messageLimit: Int
+        ): List<ChatMessage> =
+            chatDao.getMessagesForConversationBeforeAndEqual(
+                internalConversationId,
+                messageId,
+                messageLimit
+            ).map {
+                it.map(ChatMessageEntity::asModel)
+            }.first()
+
+        val list = getMessagesBeforeAndEqual(
+            messageId,
+            internalConversationId,
+            100
+        )
+
+        if (list.isNotEmpty()) {
+            val pair = Pair(false, list)
+            _messageFlow.emit(pair)
+        }
+    }
+
+    private suspend fun showLast100MessagesBefore(internalConversationId: String, messageId: Long) {
+        suspend fun getMessagesBefore(
+            messageId: Long,
+            internalConversationId: String,
+            messageLimit: Int
+        ): List<ChatMessage> =
+            chatDao.getMessagesForConversationBefore(
+                internalConversationId,
+                messageId,
+                messageLimit
+            ).map {
+                it.map(ChatMessageEntity::asModel)
+            }.first()
+
+        val list = getMessagesBefore(
+            messageId,
+            internalConversationId,
+            100
+        )
+
+        if (list.isNotEmpty()) {
+            val pair = Pair(false, list)
+            _messageFlow.emit(pair)
+        }
+    }
+
+    override fun handleOnPause() {
+        itIsPaused = true
+    }
+
+    override fun handleOnResume() {
+        itIsPaused = false
+    }
+
+    override fun handleOnStop() {
+        // unused atm
+    }
+
+    companion object {
+        val TAG = OfflineFirstChatRepository::class.simpleName
+        private const val HTTP_CODE_OK: Int = 200
+        private const val HTTP_CODE_NOT_MODIFIED = 304
+        private const val HTTP_CODE_PRECONDITION_FAILED = 412
+    }
+}

+ 3 - 4
app/src/main/java/com/nextcloud/talk/chat/data/network/NetworkChatRepositoryImpl.kt → app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt

@@ -7,7 +7,6 @@
 package com.nextcloud.talk.chat.data.network
 
 import com.nextcloud.talk.api.NcApi
-import com.nextcloud.talk.chat.data.ChatRepository
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
@@ -21,7 +20,7 @@ import com.nextcloud.talk.utils.ApiUtils
 import io.reactivex.Observable
 import retrofit2.Response
 
-class NetworkChatRepositoryImpl(private val ncApi: NcApi) : ChatRepository {
+class RetrofitChatNetwork(private val ncApi: NcApi) : ChatNetworkDataSource {
     override fun getRoom(user: User, roomToken: String): Observable<ConversationModel> {
         val credentials: String = ApiUtils.getCredentials(user.username, user.token)!!
         val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1))
@@ -29,7 +28,7 @@ class NetworkChatRepositoryImpl(private val ncApi: NcApi) : ChatRepository {
         return ncApi.getRoom(
             credentials,
             ApiUtils.getUrlForRoom(apiVersion, user.baseUrl!!, roomToken)
-        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!) }
+        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
     }
 
     override fun getCapabilities(user: User, roomToken: String): Observable<SpreedCapability> {
@@ -50,7 +49,7 @@ class NetworkChatRepositoryImpl(private val ncApi: NcApi) : ChatRepository {
             credentials,
             ApiUtils.getUrlForParticipantsActive(apiVersion, user.baseUrl!!, roomToken),
             roomPassword
-        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!) }
+        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
     }
 
     override fun setReminder(

+ 148 - 121
app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

@@ -8,22 +8,25 @@ package com.nextcloud.talk.chat.viewmodels
 
 import android.content.Context
 import android.net.Uri
+import android.os.Bundle
 import android.util.Log
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
-import com.nextcloud.talk.chat.data.ChatRepository
+import com.nextcloud.talk.chat.data.ChatMessageRepository
 import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager
 import com.nextcloud.talk.chat.data.io.MediaRecorderManager
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
+import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
 import com.nextcloud.talk.models.domain.ConversationModel
 import com.nextcloud.talk.models.domain.ReactionAddedModel
 import com.nextcloud.talk.models.domain.ReactionDeletedModel
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
-import com.nextcloud.talk.models.json.chat.ChatMessage
 import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.conversations.RoomsOverall
@@ -31,20 +34,30 @@ import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.models.json.reminder.Reminder
 import com.nextcloud.talk.repositories.reactions.ReactionsRepository
 import com.nextcloud.talk.utils.ConversationUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
 import io.reactivex.Observer
 import io.reactivex.android.schedulers.AndroidSchedulers
 import io.reactivex.disposables.Disposable
 import io.reactivex.schedulers.Schedulers
-import retrofit2.Response
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
 import java.io.File
 import javax.inject.Inject
 
 @Suppress("TooManyFunctions", "LongParameterList")
 class ChatViewModel @Inject constructor(
-    private val chatRepository: ChatRepository,
+    // should be removed here. Use it via RetrofitChatNetwork
+    private val chatNetworkDataSource: ChatNetworkDataSource,
+    private val chatRepository: ChatMessageRepository,
+    private val conversationRepository: OfflineConversationsRepository,
     private val reactionsRepository: ReactionsRepository,
     private val mediaRecorderManager: MediaRecorderManager,
-    private val audioFocusRequestManager: AudioFocusRequestManager
+    private val audioFocusRequestManager: AudioFocusRequestManager,
+    private val userProvider: CurrentUserProviderNew
 ) : ViewModel(), DefaultLifecycleObserver {
 
     enum class LifeCycleFlag {
@@ -52,6 +65,7 @@ class ChatViewModel @Inject constructor(
         RESUMED,
         STOPPED
     }
+
     lateinit var currentLifeCycleFlag: LifeCycleFlag
     val disposableSet = mutableSetOf<Disposable>()
 
@@ -59,6 +73,7 @@ class ChatViewModel @Inject constructor(
         super.onResume(owner)
         currentLifeCycleFlag = LifeCycleFlag.RESUMED
         mediaRecorderManager.handleOnResume()
+        chatRepository.handleOnResume()
     }
 
     override fun onPause(owner: LifecycleOwner) {
@@ -67,13 +82,16 @@ class ChatViewModel @Inject constructor(
         disposableSet.forEach { disposable -> disposable.dispose() }
         disposableSet.clear()
         mediaRecorderManager.handleOnPause()
+        chatRepository.handleOnPause()
     }
 
     override fun onStop(owner: LifecycleOwner) {
         super.onStop(owner)
         currentLifeCycleFlag = LifeCycleFlag.STOPPED
         mediaRecorderManager.handleOnStop()
+        chatRepository.handleOnStop()
     }
+
     val getAudioFocusChange: LiveData<AudioFocusRequestManager.ManagerState>
         get() = audioFocusRequestManager.getManagerState
 
@@ -89,9 +107,28 @@ class ChatViewModel @Inject constructor(
     val getVoiceRecordingLocked: LiveData<Boolean>
         get() = _getVoiceRecordingLocked
 
-    private val _getFieldMapForChat: MutableLiveData<HashMap<String, Int>> = MutableLiveData()
-    val getFieldMapForChat: LiveData<HashMap<String, Int>>
-        get() = _getFieldMapForChat
+    val getMessageFlow = chatRepository.messageFlow
+        .onEach {
+            _chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) {
+                ChatMessageStartState
+            } else {
+                ChatMessageUpdateState
+            }
+        }.catch {
+            _chatMessageViewState.value = ChatMessageErrorState
+        }
+
+    val getUpdateMessageFlow = chatRepository.updateMessageFlow
+
+    val getLastCommonReadFlow = chatRepository.lastCommonReadFlow
+
+    val getConversationFlow = conversationRepository.conversationFlow
+        .onEach {
+            _getRoomViewState.value = GetRoomSuccessState
+        }.catch {
+            _getRoomViewState.value = GetRoomErrorState
+        }
+
     sealed interface ViewState
 
     object GetReminderStartState : ViewState
@@ -111,7 +148,7 @@ class ChatViewModel @Inject constructor(
 
     object GetRoomStartState : ViewState
     object GetRoomErrorState : ViewState
-    open class GetRoomSuccessState(val conversationModel: ConversationModel) : ViewState
+    object GetRoomSuccessState : ViewState
 
     private val _getRoomViewState: MutableLiveData<ViewState> = MutableLiveData(GetRoomStartState)
     val getRoomViewState: LiveData<ViewState>
@@ -136,28 +173,24 @@ class ChatViewModel @Inject constructor(
 
     object LeaveRoomStartState : ViewState
     class LeaveRoomSuccessState(val funToCallWhenLeaveSuccessful: (() -> Unit)?) : ViewState
+
     private val _leaveRoomViewState: MutableLiveData<ViewState> = MutableLiveData(LeaveRoomStartState)
     val leaveRoomViewState: LiveData<ViewState>
         get() = _leaveRoomViewState
 
-    object SendChatMessageStartState : ViewState
-    class SendChatMessageSuccessState(val message: CharSequence) : ViewState
-    class SendChatMessageErrorState(val e: Throwable, val message: CharSequence) : ViewState
-    private val _sendChatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(SendChatMessageStartState)
-    val sendChatMessageViewState: LiveData<ViewState>
-        get() = _sendChatMessageViewState
-
-    object PullChatMessageStartState : ViewState
-    class PullChatMessageSuccessState(val response: Response<*>, val lookIntoFuture: Boolean) : ViewState
-    object PullChatMessageErrorState : ViewState
-    object PullChatMessageCompleteState : ViewState
-    private val _pullChatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(PullChatMessageStartState)
-    val pullChatMessageViewState: LiveData<ViewState>
-        get() = _pullChatMessageViewState
+    object ChatMessageInitialState : ViewState
+    object ChatMessageStartState : ViewState
+    object ChatMessageUpdateState : ViewState
+    object ChatMessageErrorState : ViewState
+
+    private val _chatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(ChatMessageInitialState)
+    val chatMessageViewState: LiveData<ViewState>
+        get() = _chatMessageViewState
 
     object DeleteChatMessageStartState : ViewState
     class DeleteChatMessageSuccessState(val msg: ChatOverallSingleMessage) : ViewState
     object DeleteChatMessageErrorState : ViewState
+
     private val _deleteChatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(DeleteChatMessageStartState)
     val deleteChatMessageViewState: LiveData<ViewState>
         get() = _deleteChatMessageViewState
@@ -172,29 +205,38 @@ class ChatViewModel @Inject constructor(
 
     object ReactionAddedStartState : ViewState
     class ReactionAddedSuccessState(val reactionAddedModel: ReactionAddedModel) : ViewState
+
     private val _reactionAddedViewState: MutableLiveData<ViewState> = MutableLiveData(ReactionAddedStartState)
     val reactionAddedViewState: LiveData<ViewState>
         get() = _reactionAddedViewState
 
     object ReactionDeletedStartState : ViewState
     class ReactionDeletedSuccessState(val reactionDeletedModel: ReactionDeletedModel) : ViewState
+
     private val _reactionDeletedViewState: MutableLiveData<ViewState> = MutableLiveData(ReactionDeletedStartState)
     val reactionDeletedViewState: LiveData<ViewState>
         get() = _reactionDeletedViewState
 
-    fun refreshChatParams(pullChatMessagesFieldMap: HashMap<String, Int>, overrideRefresh: Boolean = false) {
-        if (pullChatMessagesFieldMap != _getFieldMapForChat.value || overrideRefresh) {
-            _getFieldMapForChat.postValue(pullChatMessagesFieldMap)
-            Log.d(TAG, "FieldMap Refreshed with $pullChatMessagesFieldMap vs ${_getFieldMapForChat.value}")
-        }
+    fun setData(
+        conversationModel: ConversationModel,
+        credentials: String,
+        urlForChatting: String
+    ) {
+        chatRepository.setData(
+            conversationModel,
+            credentials,
+            urlForChatting
+        )
     }
 
     fun getRoom(user: User, token: String) {
         _getRoomViewState.value = GetRoomStartState
-        chatRepository.getRoom(user, token)
-            .subscribeOn(Schedulers.io())
-            ?.observeOn(AndroidSchedulers.mainThread())
-            ?.subscribe(GetRoomObserver())
+        conversationRepository.getConversationSettings(token)
+
+        // chatNetworkDataSource.getRoom(user, token)
+        //     .subscribeOn(Schedulers.io())
+        //     ?.observeOn(AndroidSchedulers.mainThread())
+        //     ?.subscribe(GetRoomObserver())
     }
 
     fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
@@ -208,7 +250,7 @@ class ChatViewModel @Inject constructor(
                 _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(user.capabilities!!.spreedCapability!!)
             }
         } else {
-            chatRepository.getCapabilities(user, token)
+            chatNetworkDataSource.getCapabilities(user, token)
                 .subscribeOn(Schedulers.io())
                 ?.observeOn(AndroidSchedulers.mainThread())
                 ?.subscribe(object : Observer<SpreedCapability> {
@@ -238,7 +280,7 @@ class ChatViewModel @Inject constructor(
 
     fun joinRoom(user: User, token: String, roomPassword: String) {
         _joinRoomViewState.value = JoinRoomStartState
-        chatRepository.joinRoom(user, token, roomPassword)
+        chatNetworkDataSource.joinRoom(user, token, roomPassword)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.retry(JOIN_ROOM_RETRY_COUNT)
@@ -246,21 +288,21 @@ class ChatViewModel @Inject constructor(
     }
 
     fun setReminder(user: User, roomToken: String, messageId: String, timestamp: Int, chatApiVersion: Int) {
-        chatRepository.setReminder(user, roomToken, messageId, timestamp, chatApiVersion)
+        chatNetworkDataSource.setReminder(user, roomToken, messageId, timestamp, chatApiVersion)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(SetReminderObserver())
     }
 
     fun getReminder(user: User, roomToken: String, messageId: String, chatApiVersion: Int) {
-        chatRepository.getReminder(user, roomToken, messageId, chatApiVersion)
+        chatNetworkDataSource.getReminder(user, roomToken, messageId, chatApiVersion)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(GetReminderObserver())
     }
 
     fun deleteReminder(user: User, roomToken: String, messageId: String, chatApiVersion: Int) {
-        chatRepository.deleteReminder(user, roomToken, messageId, chatApiVersion)
+        chatNetworkDataSource.deleteReminder(user, roomToken, messageId, chatApiVersion)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<GenericOverall> {
@@ -284,7 +326,7 @@ class ChatViewModel @Inject constructor(
 
     fun leaveRoom(credentials: String, url: String, funToCallWhenLeaveSuccessful: (() -> Unit)?) {
         val startNanoTime = System.nanoTime()
-        chatRepository.leaveRoom(credentials, url)
+        chatNetworkDataSource.leaveRoom(credentials, url)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<GenericOverall> {
@@ -309,7 +351,7 @@ class ChatViewModel @Inject constructor(
     }
 
     fun createRoom(credentials: String, url: String, queryMap: Map<String, String>) {
-        chatRepository.createRoom(credentials, url, queryMap)
+        chatNetworkDataSource.createRoom(credentials, url, queryMap)
             .subscribeOn(Schedulers.io())
             .observeOn(AndroidSchedulers.mainThread())
             .subscribe(object : Observer<RoomOverall> {
@@ -332,72 +374,42 @@ class ChatViewModel @Inject constructor(
             })
     }
 
-    fun sendChatMessage(
-        credentials: String,
-        url: String,
-        message: CharSequence,
-        displayName: String,
-        replyTo: Int,
-        sendWithoutNotification: Boolean
-    ) {
-        chatRepository.sendChatMessage(
-            credentials,
-            url,
-            message,
-            displayName,
-            replyTo,
-            sendWithoutNotification
-        ).subscribeOn(Schedulers.io())
-            ?.observeOn(AndroidSchedulers.mainThread())
-            ?.subscribe(object : Observer<GenericOverall> {
-                override fun onSubscribe(d: Disposable) {
-                    disposableSet.add(d)
-                }
-
-                override fun onError(e: Throwable) {
-                    _sendChatMessageViewState.value = SendChatMessageErrorState(e, message)
-                }
-
-                override fun onComplete() {
-                    // unused atm
-                }
-
-                override fun onNext(t: GenericOverall) {
-                    _sendChatMessageViewState.value = SendChatMessageSuccessState(message)
-                }
-            })
+    fun loadMessages(withCredentials: String, withUrl: String) {
+        val bundle = Bundle()
+        bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl)
+        bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials)
+        chatRepository.loadInitialMessages(
+            withNetworkParams = bundle
+        )
     }
 
-    fun pullChatMessages(credentials: String, url: String) {
-        chatRepository.pullChatMessages(credentials, url, _getFieldMapForChat.value!!)
-            .subscribeOn(Schedulers.io())
-            .takeUntil { (currentLifeCycleFlag == LifeCycleFlag.PAUSED) }
-            ?.observeOn(AndroidSchedulers.mainThread())
-            ?.subscribe(object : Observer<Response<*>> {
-                override fun onSubscribe(d: Disposable) {
-                    Log.d(TAG, "pullChatMessages - pullChatMessages SUBSCRIBE")
-                    disposableSet.add(d)
-                }
-
-                override fun onError(e: Throwable) {
-                    Log.e(TAG, "pullChatMessages - pullChatMessages ERROR", e)
-                    _pullChatMessageViewState.value = PullChatMessageErrorState
-                }
-
-                override fun onComplete() {
-                    Log.d(TAG, "pullChatMessages - pullChatMessages COMPLETE")
-                    _pullChatMessageViewState.value = PullChatMessageCompleteState
-                }
-
-                override fun onNext(response: Response<*>) {
-                    val lookIntoFuture = getFieldMapForChat.value?.get("lookIntoFuture") == 1
-                    _pullChatMessageViewState.value = PullChatMessageSuccessState(response, lookIntoFuture)
-                }
-            })
-    }
+    fun loadMoreMessages(
+        beforeMessageId: Long,
+        roomToken: String,
+        withMessageLimit: Int,
+        withCredentials: String,
+        withUrl: String
+    ) {
+        val bundle = Bundle()
+        bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl)
+        bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials)
+        chatRepository.loadMoreMessages(
+            beforeMessageId,
+            roomToken,
+            withMessageLimit,
+            withNetworkParams = bundle
+        )
+    }
+
+    // fun initMessagePolling(withCredentials: String, withUrl: String, roomToken: String) {
+    //     val bundle = Bundle()
+    //     bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl)
+    //     bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials)
+    //     chatRepository.initMessagePolling(roomToken, withNetworkParams = bundle)
+    // }
 
     fun deleteChatMessages(credentials: String, url: String, messageId: String) {
-        chatRepository.deleteChatMessage(credentials, url)
+        chatNetworkDataSource.deleteChatMessage(credentials, url)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<ChatOverallSingleMessage> {
@@ -426,7 +438,7 @@ class ChatViewModel @Inject constructor(
     }
 
     fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int) {
-        chatRepository.setChatReadMarker(credentials, url, previousMessageId)
+        chatNetworkDataSource.setChatReadMarker(credentials, url, previousMessageId)
             .subscribeOn(Schedulers.io())
             .observeOn(AndroidSchedulers.mainThread())
             .subscribe(object : Observer<GenericOverall> {
@@ -449,7 +461,7 @@ class ChatViewModel @Inject constructor(
     }
 
     fun shareToNotes(credentials: String, url: String, message: String, displayName: String) {
-        chatRepository.shareToNotes(credentials, url, message, displayName)
+        chatNetworkDataSource.shareToNotes(credentials, url, message, displayName)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<GenericOverall> {
@@ -472,13 +484,13 @@ class ChatViewModel @Inject constructor(
     }
 
     fun checkForNoteToSelf(credentials: String, baseUrl: String, includeStatus: Boolean) {
-        chatRepository.checkForNoteToSelf(credentials, baseUrl, includeStatus).subscribeOn(Schedulers.io())
+        chatNetworkDataSource.checkForNoteToSelf(credentials, baseUrl, includeStatus).subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(CheckForNoteToSelfObserver())
     }
 
     fun shareLocationToNotes(credentials: String, url: String, objectType: String, objectId: String, metadata: String) {
-        chatRepository.shareLocationToNotes(credentials, url, objectType, objectId, metadata)
+        chatNetworkDataSource.shareLocationToNotes(credentials, url, objectType, objectId, metadata)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<GenericOverall> {
@@ -575,6 +587,7 @@ class ChatViewModel @Inject constructor(
             uploadFile(uri.toString(), room, displayName, metaData)
         }
     }
+
     fun stopAndDiscardAudioRecording() {
         stopAudioRecording()
         Log.d(TAG, "File discarded")
@@ -619,24 +632,38 @@ class ChatViewModel @Inject constructor(
         _getCapabilitiesViewState.value = GetCapabilitiesStartState
     }
 
-    inner class GetRoomObserver : Observer<ConversationModel> {
-        override fun onSubscribe(d: Disposable) {
-            // unused atm
-        }
-
-        override fun onNext(conversationModel: ConversationModel) {
-            _getRoomViewState.value = GetRoomSuccessState(conversationModel)
-        }
+    suspend fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow<ChatMessage> =
+        flow {
+            val bundle = Bundle()
+            bundle.putString(BundleKeys.KEY_CHAT_URL, url)
+            bundle.putString(
+                BundleKeys.KEY_CREDENTIALS,
+                userProvider.currentUser.blockingGet().getCredentials()
+            )
+            bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token!!)
 
-        override fun onError(e: Throwable) {
-            Log.e(TAG, "Error when fetching room")
-            _getRoomViewState.value = GetRoomErrorState
+            val message = chatRepository.getMessage(messageId, bundle)
+            emit(message.first())
         }
 
-        override fun onComplete() {
-            // unused atm
-        }
-    }
+// inner class GetRoomObserver : Observer<ConversationModel> {
+//     override fun onSubscribe(d: Disposable) {
+//         // unused atm
+//     }
+//
+//     override fun onNext(conversationModel: ConversationModel) {
+//         _getRoomViewState.value = GetRoomSuccessState(conversationModel)
+//     }
+//
+//     override fun onError(e: Throwable) {
+//         Log.e(TAG, "Error when fetching room")
+//         _getRoomViewState.value = GetRoomErrorState
+//     }
+//
+//     override fun onComplete() {
+//         // unused atm
+//     }
+// }
 
     inner class JoinRoomObserver : Observer<ConversationModel> {
         override fun onSubscribe(d: Disposable) {
@@ -704,7 +731,7 @@ class ChatViewModel @Inject constructor(
             rooms?.let {
                 try {
                     val noteToSelf = rooms.first {
-                        val model = ConversationModel.mapToConversationModel(it)
+                        val model = ConversationModel.mapToConversationModel(it, userProvider.currentUser.blockingGet())
                         ConversationUtils.isNoteToSelfConversation(model)
                     }
                     _getNoteToSelfAvaliability.value = NoteToSelfAvaliableState(noteToSelf.token!!)

+ 48 - 5
app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt

@@ -14,12 +14,13 @@ import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
-import com.nextcloud.talk.chat.data.ChatRepository
 import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager
 import com.nextcloud.talk.chat.data.io.AudioRecorderManager
 import com.nextcloud.talk.chat.data.io.MediaPlayerManager
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
 import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
 import com.nextcloud.talk.models.json.generic.GenericOverall
+import com.nextcloud.talk.utils.preferences.AppPreferences
 import com.stfalcon.chatkit.commons.models.IMessage
 import io.reactivex.Observer
 import io.reactivex.android.schedulers.AndroidSchedulers
@@ -28,10 +29,11 @@ import io.reactivex.schedulers.Schedulers
 import javax.inject.Inject
 
 class MessageInputViewModel @Inject constructor(
-    private val chatRepository: ChatRepository,
+    private val chatNetworkDataSource: ChatNetworkDataSource,
     private val audioRecorderManager: AudioRecorderManager,
     private val mediaPlayerManager: MediaPlayerManager,
-    private val audioFocusRequestManager: AudioFocusRequestManager
+    private val audioFocusRequestManager: AudioFocusRequestManager,
+    private val dataStore: AppPreferences
 ) : ViewModel(), DefaultLifecycleObserver {
     enum class LifeCycleFlag {
         PAUSED,
@@ -41,6 +43,16 @@ class MessageInputViewModel @Inject constructor(
     lateinit var currentLifeCycleFlag: LifeCycleFlag
     val disposableSet = mutableSetOf<Disposable>()
 
+    data class QueuedMessage(
+        val message: CharSequence? = null,
+        val displayName: String? = null,
+        val replyTo: Int? = null,
+        val sendWithoutNotification: Boolean? = null
+    )
+
+    private var isQueueing: Boolean = false
+    private val messageQueue: MutableList<QueuedMessage> = mutableListOf()
+
     override fun onResume(owner: LifecycleOwner) {
         super.onResume(owner)
         currentLifeCycleFlag = LifeCycleFlag.RESUMED
@@ -109,6 +121,7 @@ class MessageInputViewModel @Inject constructor(
 
     @Suppress("LongParameterList")
     fun sendChatMessage(
+        roomToken: String,
         credentials: String,
         url: String,
         message: CharSequence,
@@ -116,7 +129,13 @@ class MessageInputViewModel @Inject constructor(
         replyTo: Int,
         sendWithoutNotification: Boolean
     ) {
-        chatRepository.sendChatMessage(
+        if (isQueueing) {
+            messageQueue.add(QueuedMessage(message, displayName, replyTo, sendWithoutNotification))
+            dataStore.saveMessageQueue(roomToken, messageQueue)
+            return
+        }
+
+        chatNetworkDataSource.sendChatMessage(
             credentials,
             url,
             message,
@@ -145,7 +164,7 @@ class MessageInputViewModel @Inject constructor(
     }
 
     fun editChatMessage(credentials: String, url: String, text: String) {
-        chatRepository.editChatMessage(credentials, url, text)
+        chatNetworkDataSource.editChatMessage(credentials, url, text)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<ChatOverallSingleMessage> {
@@ -216,4 +235,28 @@ class MessageInputViewModel @Inject constructor(
     fun setRecordingTime(time: Long) {
         _getRecordingTime.postValue(time)
     }
+
+    fun sendAndEmptyMessageQueue(roomToken: String, credentials: String, url: String) {
+        if (isQueueing) return
+        messageQueue.clear()
+
+        val queue = dataStore.getMessageQueue(roomToken)
+        dataStore.saveMessageQueue(roomToken, null) // empties the queue
+        while (queue.size > 0) {
+            val msg = queue.removeFirst()
+            sendChatMessage(
+                roomToken,
+                credentials,
+                url,
+                msg.message!!,
+                msg.displayName!!,
+                msg.replyTo!!,
+                msg.sendWithoutNotification!!
+            )
+        }
+    }
+
+    fun switchToMessageQueue(shouldQueue: Boolean) {
+        isQueueing = shouldQueue
+    }
 }

+ 7 - 7
app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt

@@ -46,7 +46,7 @@ import com.nextcloud.talk.jobs.AddParticipantsToConversation
 import com.nextcloud.talk.models.RetrofitBucket
 import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
 import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
 import com.nextcloud.talk.models.json.participants.Participant
@@ -288,10 +288,10 @@ class ContactsActivity :
 
                 // if there are more participants to add, ask for roomName and add them one after another
             } else {
-                val roomType: Conversation.ConversationType = if (isPublicCall) {
-                    Conversation.ConversationType.ROOM_PUBLIC_CALL
+                val roomType: ConversationEnums.ConversationType = if (isPublicCall) {
+                    ConversationEnums.ConversationType.ROOM_PUBLIC_CALL
                 } else {
-                    Conversation.ConversationType.ROOM_GROUP_CALL
+                    ConversationEnums.ConversationType.ROOM_GROUP_CALL
                 }
                 val userIdsArray = ArrayList(selectedUserIds)
                 val groupIdsArray = ArrayList(selectedGroupIds)
@@ -338,7 +338,7 @@ class ContactsActivity :
                 override fun onNext(roomOverall: RoomOverall) {
                     val bundle = Bundle()
                     bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
-                    bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
+                    // bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
 
                     val chatIntent = Intent(context, ChatActivity::class.java)
                     chatIntent.putExtras(bundle)
@@ -415,7 +415,7 @@ class ContactsActivity :
             searchView!!.inputType = InputType.TYPE_TEXT_VARIATION_FILTER
             var imeOptions: Int = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
-                appPreferences?.isKeyboardIncognito == true
+                appPreferences.isKeyboardIncognito == true
             ) {
                 imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
             }
@@ -804,7 +804,7 @@ class ContactsActivity :
                 override fun onNext(roomOverall: RoomOverall) {
                     val bundle = Bundle()
                     bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
-                    bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
+                    // bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
 
                     val chatIntent = Intent(context, ChatActivity::class.java)
                     chatIntent.putExtras(bundle)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/contacts/ContactsActivityCompose.kt

@@ -220,7 +220,7 @@ fun ContactItemRow(contact: AutocompleteUser, contactsViewModel: ContactsViewMod
             val conversation = (roomUiState as RoomUiState.Success).conversation
             val bundle = Bundle()
             bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversation?.token)
-            bundle.putString(BundleKeys.KEY_ROOM_ID, conversation?.roomId)
+            // bundle.putString(BundleKeys.KEY_ROOM_ID, conversation?.roomId)
             val chatIntent = Intent(context, ChatActivity::class.java)
             chatIntent.putExtras(bundle)
             chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)

+ 2 - 2
app/src/main/java/com/nextcloud/talk/conversation/CreateConversationDialogFragment.kt

@@ -37,7 +37,7 @@ import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel
 import com.nextcloud.talk.databinding.DialogCreateConversationBinding
 import com.nextcloud.talk.jobs.AddParticipantsToConversation
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.bundle.BundleKeys
 import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
@@ -66,7 +66,7 @@ class CreateConversationDialogFragment : DialogFragment() {
 
     private var emojiPopup: EmojiPopup? = null
 
-    private var conversationType: Conversation.ConversationType? = null
+    private var conversationType: ConversationEnums.ConversationType? = null
     private var usersToInvite: ArrayList<String> = ArrayList()
     private var groupsToInvite: ArrayList<String> = ArrayList()
     private var emailsToInvite: ArrayList<String> = ArrayList()

+ 5 - 2
app/src/main/java/com/nextcloud/talk/conversation/repository/ConversationRepository.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.conversation.repository
 
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.generic.GenericOverall
 import io.reactivex.Observable
@@ -15,5 +15,8 @@ interface ConversationRepository {
 
     fun renameConversation(roomToken: String, roomNameNew: String): Observable<GenericOverall>
 
-    fun createConversation(roomName: String, conversationType: Conversation.ConversationType?): Observable<RoomOverall>
+    fun createConversation(
+        roomName: String,
+        conversationType: ConversationEnums.ConversationType?
+    ): Observable<RoomOverall>
 }

+ 22 - 21
app/src/main/java/com/nextcloud/talk/conversation/repository/ConversationRepositoryImpl.kt

@@ -9,7 +9,7 @@ package com.nextcloud.talk.conversation.repository
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.RetrofitBucket
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.utils.ApiUtils
@@ -43,29 +43,30 @@ class ConversationRepositoryImpl(private val ncApi: NcApi, currentUserProvider:
 
     override fun createConversation(
         roomName: String,
-        conversationType: Conversation.ConversationType?
+        conversationType: ConversationEnums.ConversationType?
     ): Observable<RoomOverall> {
         val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1))
 
-        val retrofitBucket: RetrofitBucket = if (conversationType == Conversation.ConversationType.ROOM_PUBLIC_CALL) {
-            ApiUtils.getRetrofitBucketForCreateRoom(
-                apiVersion,
-                currentUser.baseUrl!!,
-                ROOM_TYPE_PUBLIC,
-                null,
-                null,
-                roomName
-            )
-        } else {
-            ApiUtils.getRetrofitBucketForCreateRoom(
-                apiVersion,
-                currentUser.baseUrl!!,
-                ROOM_TYPE_GROUP,
-                null,
-                null,
-                roomName
-            )
-        }
+        val retrofitBucket: RetrofitBucket =
+            if (conversationType == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) {
+                ApiUtils.getRetrofitBucketForCreateRoom(
+                    apiVersion,
+                    currentUser.baseUrl!!,
+                    ROOM_TYPE_PUBLIC,
+                    null,
+                    null,
+                    roomName
+                )
+            } else {
+                ApiUtils.getRetrofitBucketForCreateRoom(
+                    apiVersion,
+                    currentUser.baseUrl!!,
+                    ROOM_TYPE_GROUP,
+                    null,
+                    null,
+                    roomName
+                )
+            }
         return ncApi.createRoom(credentials, retrofitBucket.url, retrofitBucket.queryMap)
             .subscribeOn(Schedulers.io())
             .observeOn(AndroidSchedulers.mainThread())

+ 2 - 2
app/src/main/java/com/nextcloud/talk/conversation/viewmodel/ConversationViewModel.kt

@@ -10,7 +10,7 @@ import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import com.nextcloud.talk.conversation.repository.ConversationRepository
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.conversations.RoomOverall
 import io.reactivex.Observer
 import io.reactivex.android.schedulers.AndroidSchedulers
@@ -40,7 +40,7 @@ class ConversationViewModel @Inject constructor(private val repository: Conversa
         disposable?.dispose()
     }
 
-    fun createConversation(roomName: String, conversationType: Conversation.ConversationType?) {
+    fun createConversation(roomName: String, conversationType: ConversationEnums.ConversationType?) {
         _viewState.value = CreatingState
 
         repository.createConversation(

+ 17 - 16
app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt

@@ -57,11 +57,9 @@ import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.jobs.DeleteConversationWorker
 import com.nextcloud.talk.jobs.LeaveConversationWorker
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.domain.ConversationType
-import com.nextcloud.talk.models.domain.LobbyState
-import com.nextcloud.talk.models.domain.NotificationLevel
 import com.nextcloud.talk.models.domain.converters.DomainEnumNotificationLevelConverter
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
 import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.models.json.participants.Participant
@@ -350,7 +348,7 @@ class ConversationInfoActivity :
             binding.webinarInfoView.webinarSettings.visibility = VISIBLE
 
             val isLobbyOpenToModeratorsOnly =
-                conversation!!.lobbyState == LobbyState.LOBBY_STATE_MODERATORS_ONLY
+                conversation!!.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY
             binding.webinarInfoView.lobbySwitch.isChecked = isLobbyOpenToModeratorsOnly
 
             reconfigureLobbyTimerView()
@@ -386,8 +384,8 @@ class ConversationInfoActivity :
     }
 
     private fun webinaryRoomType(conversation: ConversationModel): Boolean {
-        return conversation.type == ConversationType.ROOM_GROUP_CALL ||
-            conversation.type == ConversationType.ROOM_PUBLIC_CALL
+        return conversation.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL ||
+            conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL
     }
 
     private fun reconfigureLobbyTimerView(dateTime: Calendar? = null) {
@@ -402,9 +400,9 @@ class ConversationInfoActivity :
         }
 
         conversation!!.lobbyState = if (isChecked) {
-            LobbyState.LOBBY_STATE_MODERATORS_ONLY
+            ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY
         } else {
-            LobbyState.LOBBY_STATE_ALL_PARTICIPANTS
+            ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS
         }
 
         if (
@@ -760,13 +758,13 @@ class ConversationInfoActivity :
                 binding.deleteConversationAction.visibility = VISIBLE
             }
 
-            if (ConversationType.ROOM_SYSTEM == conversation!!.type) {
+            if (ConversationEnums.ConversationType.ROOM_SYSTEM == conversation!!.type) {
                 binding.notificationSettingsView.callNotificationsSwitch.visibility = GONE
             }
 
             binding.listBansButton.visibility =
                 if (ConversationUtils.canModerate(conversationCopy, spreedCapabilities) &&
-                    ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL != conversation!!.type
+                    ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL != conversation!!.type
                 ) {
                     VISIBLE
                 } else {
@@ -922,7 +920,7 @@ class ConversationInfoActivity :
                 binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.isEnabled = true
                 binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.alpha = 1.0f
 
-                if (conversation!!.notificationLevel != NotificationLevel.DEFAULT) {
+                if (conversation!!.notificationLevel != ConversationEnums.NotificationLevel.DEFAULT) {
                     val stringValue: String =
                         when (
                             DomainEnumNotificationLevelConverter()
@@ -952,7 +950,7 @@ class ConversationInfoActivity :
     }
 
     private fun setProperNotificationValue(conversation: ConversationModel?) {
-        if (conversation!!.type == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
+        if (conversation!!.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
             if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)) {
                 binding.notificationSettingsView.conversationInfoMessageNotificationsDropdown.setText(
                     resources.getString(R.string.nc_notify_me_always)
@@ -971,7 +969,10 @@ class ConversationInfoActivity :
 
     private fun loadConversationAvatar() {
         when (conversation!!.type) {
-            ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(conversation!!.name)) {
+            ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(
+                    conversation!!.name
+                )
+            ) {
                 conversation!!.name?.let {
                     binding.avatarImage.loadUserAvatar(
                         conversationUser,
@@ -982,7 +983,7 @@ class ConversationInfoActivity :
                 }
             }
 
-            ConversationType.ROOM_GROUP_CALL, ConversationType.ROOM_PUBLIC_CALL -> {
+            ConversationEnums.ConversationType.ROOM_GROUP_CALL, ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> {
                 binding.avatarImage.loadConversationAvatar(
                     conversationUser,
                     conversation!!,
@@ -991,11 +992,11 @@ class ConversationInfoActivity :
                 )
             }
 
-            ConversationType.ROOM_SYSTEM -> {
+            ConversationEnums.ConversationType.ROOM_SYSTEM -> {
                 binding.avatarImage.loadSystemAvatar()
             }
 
-            ConversationType.NOTE_TO_SELF -> {
+            ConversationEnums.ConversationType.NOTE_TO_SELF -> {
                 binding.avatarImage.loadNoteToSelfAvatar()
             }
 

+ 2 - 2
app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt

@@ -19,8 +19,8 @@ import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ActivityConversationInfoBinding
 import com.nextcloud.talk.databinding.DialogPasswordBinding
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.domain.ConversationType
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.repositories.conversations.ConversationsRepository
 import com.nextcloud.talk.utils.ConversationUtils
 import io.reactivex.Observer
@@ -47,7 +47,7 @@ class GuestAccessHelper(
             binding.guestAccessView.guestAccessSettings.visibility = View.GONE
         }
 
-        if (conversation.type == ConversationType.ROOM_PUBLIC_CALL) {
+        if (conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) {
             binding.guestAccessView.allowGuestsSwitch.isChecked = true
             showAllOptions()
             if (conversation.hasPassword) {

+ 7 - 7
app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt

@@ -12,7 +12,7 @@ import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
-import com.nextcloud.talk.chat.data.ChatRepository
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
@@ -26,7 +26,7 @@ import io.reactivex.schedulers.Schedulers
 import javax.inject.Inject
 
 class ConversationInfoViewModel @Inject constructor(
-    private val chatRepository: ChatRepository
+    private val chatNetworkDataSource: ChatNetworkDataSource
 ) : ViewModel() {
 
     object LifeCycleObserver : DefaultLifecycleObserver {
@@ -92,7 +92,7 @@ class ConversationInfoViewModel @Inject constructor(
 
     fun getRoom(user: User, token: String) {
         _viewState.value = GetRoomStartState
-        chatRepository.getRoom(user, token)
+        chatNetworkDataSource.getRoom(user, token)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(GetRoomObserver())
@@ -104,7 +104,7 @@ class ConversationInfoViewModel @Inject constructor(
         if (conversationModel.remoteServer.isNullOrEmpty()) {
             _getCapabilitiesViewState.value = GetCapabilitiesSuccessState(user.capabilities!!.spreedCapability!!)
         } else {
-            chatRepository.getCapabilities(user, token)
+            chatNetworkDataSource.getCapabilities(user, token)
                 .subscribeOn(Schedulers.io())
                 ?.observeOn(AndroidSchedulers.mainThread())
                 ?.subscribe(object : Observer<SpreedCapability> {
@@ -130,7 +130,7 @@ class ConversationInfoViewModel @Inject constructor(
 
     fun listBans(user: User, token: String) {
         val url = ApiUtils.getUrlForBans(user.baseUrl!!, token)
-        chatRepository.listBans(user.getCredentials(), url)
+        chatNetworkDataSource.listBans(user.getCredentials(), url)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<List<TalkBan>> {
@@ -154,7 +154,7 @@ class ConversationInfoViewModel @Inject constructor(
 
     fun banActor(user: User, token: String, actorType: String, actorId: String, internalNote: String) {
         val url = ApiUtils.getUrlForBans(user.baseUrl!!, token)
-        chatRepository.banActor(user.getCredentials(), url, actorType, actorId, internalNote)
+        chatNetworkDataSource.banActor(user.getCredentials(), url, actorType, actorId, internalNote)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<TalkBan> {
@@ -178,7 +178,7 @@ class ConversationInfoViewModel @Inject constructor(
 
     fun unbanActor(user: User, token: String, banId: Int) {
         val url = ApiUtils.getUrlForUnban(user.baseUrl!!, token, banId)
-        chatRepository.unbanActor(user.getCredentials(), url)
+        chatNetworkDataSource.unbanActor(user.getCredentials(), url)
             .subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<GenericOverall> {

+ 7 - 8
app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt

@@ -34,8 +34,8 @@ import com.nextcloud.talk.extensions.loadConversationAvatar
 import com.nextcloud.talk.extensions.loadSystemAvatar
 import com.nextcloud.talk.extensions.loadUserAvatar
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.domain.ConversationType
 import com.nextcloud.talk.models.json.capabilities.SpreedCapability
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.CapabilitiesUtil
@@ -126,10 +126,6 @@ class ConversationInfoEditActivity : BaseActivity() {
         initObservers()
     }
 
-    override fun onResume() {
-        super.onResume()
-    }
-
     private fun initObservers() {
         conversationInfoEditViewModel.viewState.observe(this) { state ->
             when (state) {
@@ -349,15 +345,18 @@ class ConversationInfoEditActivity : BaseActivity() {
         setupAvatarOptions()
 
         when (conversation!!.type) {
-            ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(conversation!!.name)) {
+            ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> if (!TextUtils.isEmpty(
+                    conversation!!.name
+                )
+            ) {
                 conversation!!.name?.let { binding.avatarImage.loadUserAvatar(conversationUser, it, true, false) }
             }
 
-            ConversationType.ROOM_GROUP_CALL, ConversationType.ROOM_PUBLIC_CALL -> {
+            ConversationEnums.ConversationType.ROOM_GROUP_CALL, ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> {
                 binding.avatarImage.loadConversationAvatar(conversationUser, conversation!!, false, viewThemeUtils)
             }
 
-            ConversationType.ROOM_SYSTEM -> {
+            ConversationEnums.ConversationType.ROOM_SYSTEM -> {
                 binding.avatarImage.loadSystemAvatar()
             }
 

+ 3 - 3
app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt

@@ -31,7 +31,7 @@ class ConversationInfoEditRepositoryImpl(private val ncApi: NcApi, currentUserPr
         builder.setType(MultipartBody.FORM)
         builder.addFormDataPart(
             "file",
-            file!!.name,
+            file.name,
             file.asRequestBody(Mimetype.IMAGE_PREFIX_GENERIC.toMediaTypeOrNull())
         )
         val filePart: MultipartBody.Part = MultipartBody.Part.createFormData(
@@ -44,13 +44,13 @@ class ConversationInfoEditRepositoryImpl(private val ncApi: NcApi, currentUserPr
             credentials,
             ApiUtils.getUrlForConversationAvatar(1, user.baseUrl!!, roomToken),
             filePart
-        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!) }
+        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
     }
 
     override fun deleteConversationAvatar(user: User, roomToken: String): Observable<ConversationModel> {
         return ncApi.deleteConversationAvatar(
             credentials,
             ApiUtils.getUrlForConversationAvatar(1, user.baseUrl!!, roomToken)
-        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!) }
+        ).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
     }
 }

+ 2 - 2
app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt

@@ -10,7 +10,7 @@ import android.util.Log
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
-import com.nextcloud.talk.chat.data.ChatRepository
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
 import com.nextcloud.talk.conversationinfoedit.data.ConversationInfoEditRepository
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
@@ -22,7 +22,7 @@ import java.io.File
 import javax.inject.Inject
 
 class ConversationInfoEditViewModel @Inject constructor(
-    private val repository: ChatRepository,
+    private val repository: ChatNetworkDataSource,
     private val conversationInfoEditRepository: ConversationInfoEditRepository
 ) : ViewModel() {
 

+ 148 - 174
app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt

@@ -45,6 +45,7 @@ import androidx.appcompat.widget.SearchView
 import androidx.core.view.MenuItemCompat
 import androidx.fragment.app.DialogFragment
 import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.RecyclerView
 import androidx.work.Data
 import androidx.work.OneTimeWorkRequest
@@ -80,6 +81,7 @@ import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
 import com.nextcloud.talk.chat.ChatActivity
 import com.nextcloud.talk.contacts.ContactsActivityCompose
 import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel
+import com.nextcloud.talk.data.network.NetworkMonitor
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ActivityConversationsBinding
 import com.nextcloud.talk.events.ConversationsListFetchDataEvent
@@ -91,8 +93,8 @@ import com.nextcloud.talk.jobs.DeleteConversationWorker
 import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
 import com.nextcloud.talk.messagesearch.MessageSearchHelper
 import com.nextcloud.talk.messagesearch.MessageSearchHelper.MessageSearchResults
-import com.nextcloud.talk.models.json.conversations.Conversation
-import com.nextcloud.talk.models.json.conversations.RoomsOverall
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
 import com.nextcloud.talk.settings.SettingsActivity
 import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment
@@ -105,8 +107,8 @@ import com.nextcloud.talk.utils.BrandingUtils
 import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
 import com.nextcloud.talk.utils.CapabilitiesUtil.isServerEOL
 import com.nextcloud.talk.utils.CapabilitiesUtil.isUnifiedSearchAvailable
-import com.nextcloud.talk.utils.CapabilitiesUtil.isUserStatusAvailable
 import com.nextcloud.talk.utils.ClosedInterfaceImpl
+import com.nextcloud.talk.utils.ConversationUtils
 import com.nextcloud.talk.utils.FileUtils
 import com.nextcloud.talk.utils.Mimetype
 import com.nextcloud.talk.utils.ParticipantPermissions
@@ -134,6 +136,9 @@ import io.reactivex.android.schedulers.AndroidSchedulers
 import io.reactivex.disposables.Disposable
 import io.reactivex.schedulers.Schedulers
 import io.reactivex.subjects.BehaviorSubject
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
 import org.apache.commons.lang3.builder.CompareToBuilder
 import org.greenrobot.eventbus.Subscribe
 import org.greenrobot.eventbus.ThreadMode
@@ -168,6 +173,9 @@ class ConversationsListActivity :
     @Inject
     lateinit var viewModelFactory: ViewModelProvider.Factory
 
+    @Inject
+    lateinit var networkMonitor: NetworkMonitor
+
     lateinit var conversationsListViewModel: ConversationsListViewModel
 
     override val appBarLayoutType: AppBarLayoutType
@@ -190,7 +198,7 @@ class ConversationsListActivity :
     private var isRefreshing = false
     private var showShareToScreen = false
     private var filesToShare: ArrayList<String>? = null
-    private var selectedConversation: Conversation? = null
+    private var selectedConversation: ConversationModel? = null
     private var textToPaste: String? = ""
     private var selectedMessageId: String? = null
     private var forwardMessage: Boolean = false
@@ -259,7 +267,7 @@ class ConversationsListActivity :
         if (adapter == null) {
             adapter = FlexibleAdapter(conversationItems, this, true)
         } else {
-            binding?.loadingContent?.visibility = View.GONE
+            binding.loadingContent?.visibility = View.GONE
         }
         adapter!!.addListener(this)
         prepareViews()
@@ -295,6 +303,12 @@ class ConversationsListActivity :
     }
 
     private fun initObservers() {
+        this.lifecycleScope.launch {
+            networkMonitor.isOnline.onEach { isOnline ->
+                showNetworkErrorDialog(!isOnline)
+            }.collect()
+        }
+
         conversationsListViewModel.getFederationInvitationsViewState.observe(this) { state ->
             when (state) {
                 is ConversationsListViewModel.GetFederationInvitationsStartState -> {
@@ -310,7 +324,9 @@ class ConversationsListActivity :
                 }
 
                 is ConversationsListViewModel.GetFederationInvitationsErrorState -> {
-                    Snackbar.make(binding.root, R.string.get_invitations_error, Snackbar.LENGTH_LONG).show()
+                    if (isNetworkAvailable(context)) {
+                        Snackbar.make(binding.root, R.string.get_invitations_error, Snackbar.LENGTH_LONG).show()
+                    }
                 }
 
                 else -> {}
@@ -334,6 +350,51 @@ class ConversationsListActivity :
                 else -> {}
             }
         }
+
+        conversationsListViewModel.getRoomsViewState.observe(this) { state ->
+            when (state) {
+                is ConversationsListViewModel.GetRoomsSuccessState -> {
+                    if (adapterWasNull) {
+                        adapterWasNull = false
+                        binding.loadingContent.visibility = View.GONE
+                    }
+                    initOverallLayout(state.listIsNotEmpty)
+                    binding.swipeRefreshLayoutView.isRefreshing = false
+                }
+
+                is ConversationsListViewModel.GetRoomsErrorState -> {
+                    Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show()
+                }
+
+                else -> {}
+            }
+        }
+
+        lifecycleScope.launch {
+            conversationsListViewModel.getRoomsFlow
+                .onEach { list ->
+                    // Update Conversations
+                    conversationItems.clear()
+                    for (conversation in list) {
+                        addToConversationItems(conversation)
+                    }
+                    sortConversations(conversationItems)
+                    sortConversations(conversationItemsWithHeader)
+
+                    // Filter Conversations
+                    if (!filterState.containsValue(true)) filterableConversationItems = conversationItems
+                    filterConversation()
+                    adapter!!.updateDataSet(filterableConversationItems, false)
+                    Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong())
+
+                    // Fetch Open Conversations
+                    val apiVersion = ApiUtils.getConversationApiVersion(
+                        currentUser!!,
+                        intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1)
+                    )
+                    fetchOpenConversations(apiVersion)
+                }.collect()
+        }
     }
 
     fun filterConversation() {
@@ -374,7 +435,7 @@ class ConversationsListActivity :
         updateFilterConversationButtonColor()
     }
 
-    private fun filter(conversation: Conversation): Boolean {
+    private fun filter(conversation: ConversationModel): Boolean {
         var result = true
         for ((k, v) in filterState) {
             if (v) {
@@ -383,8 +444,8 @@ class ConversationsListActivity :
                         (
                             result &&
                                 (
-                                    conversation.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
-                                        conversation.type == Conversation.ConversationType.FORMER_ONE_TO_ONE
+                                    conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
+                                        conversation.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE
                                     ) &&
                                 (conversation.unreadMessages > 0)
                             )
@@ -573,7 +634,7 @@ class ConversationsListActivity :
                     if (!filterState.containsValue(true)) filterableConversationItems = searchableConversationItems
                     adapter!!.updateDataSet(filterableConversationItems, false)
                     adapter!!.showAllHeaders()
-                    binding?.swipeRefreshLayoutView?.isEnabled = false
+                    binding.swipeRefreshLayoutView?.isEnabled = false
                     searchBehaviorSubject.onNext(true)
                     return true
                 }
@@ -586,10 +647,10 @@ class ConversationsListActivity :
                     if (searchHelper != null) {
                         // cancel any pending searches
                         searchHelper!!.cancelSearch()
-                        binding?.swipeRefreshLayoutView?.isRefreshing = false
+                        binding.swipeRefreshLayoutView?.isRefreshing = false
                         searchBehaviorSubject.onNext(false)
                     }
-                    binding?.swipeRefreshLayoutView?.isEnabled = true
+                    binding.swipeRefreshLayoutView?.isEnabled = true
                     searchView!!.onActionViewCollapsed()
 
                     binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator(
@@ -602,7 +663,7 @@ class ConversationsListActivity :
                         viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity)
                     }
 
-                    val layoutManager = binding?.recyclerView?.layoutManager as SmoothScrollLinearLayoutManager?
+                    val layoutManager = binding.recyclerView?.layoutManager as SmoothScrollLinearLayoutManager?
                     layoutManager?.scrollToPositionWithOffset(0, 0)
                     return true
                 }
@@ -681,71 +742,7 @@ class ConversationsListActivity :
     }
 
     fun fetchRooms() {
-        val includeStatus = isUserStatusAvailable(userManager.currentUser.blockingGet())
-
-        // checks internet connection before fetching rooms
-        if (isNetworkAvailable(context)) {
-            Log.d(TAG, "Internet connection available")
-            dispose(null)
-            isRefreshing = true
-            conversationItems = ArrayList()
-            conversationItemsWithHeader = ArrayList()
-            val apiVersion = ApiUtils.getConversationApiVersion(
-                currentUser!!,
-                intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1)
-            )
-            val startNanoTime = System.nanoTime()
-            Log.d(TAG, "fetchData - getRooms - calling: $startNanoTime")
-            roomsQueryDisposable = ncApi.getRooms(
-                credentials,
-                ApiUtils.getUrlForRooms(
-                    apiVersion,
-                    currentUser!!.baseUrl
-                ),
-                includeStatus
-            )
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe({ (ocs): RoomsOverall ->
-                    Log.d(TAG, "fetchData - getRooms - got response: $startNanoTime")
-
-                    // This is invoked asynchronously, when server returns a response the view might have been
-                    // unbound in the meantime. Check if the view is still there.
-                    // FIXME - does it make sense to update internal data structures even when view has been unbound?
-                    // if (view == null) {
-                    //     Log.d(TAG, "fetchData - getRooms - view is not bound: $startNanoTime")
-                    //     return@subscribe
-                    // }
-
-                    if (adapterWasNull) {
-                        adapterWasNull = false
-                        binding?.loadingContent?.visibility = View.GONE
-                    }
-                    initOverallLayout(ocs!!.data!!.isNotEmpty())
-                    for (conversation in ocs.data!!) {
-                        addToConversationItems(conversation)
-                    }
-                    sortConversations(conversationItems)
-                    sortConversations(conversationItemsWithHeader)
-                    if (!filterState.containsValue(true)) filterableConversationItems = conversationItems
-                    filterConversation()
-                    adapter!!.updateDataSet(filterableConversationItems, false)
-                    Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong())
-                    fetchOpenConversations(apiVersion)
-                    binding?.swipeRefreshLayoutView?.isRefreshing = false
-                }, { throwable: Throwable ->
-                    handleHttpExceptions(throwable)
-                    binding?.swipeRefreshLayoutView?.isRefreshing = false
-                    dispose(roomsQueryDisposable)
-                }) {
-                    dispose(roomsQueryDisposable)
-                    binding?.swipeRefreshLayoutView?.isRefreshing = false
-                    isRefreshing = false
-                }
-        } else {
-            Log.d(TAG, "No internet connection detected")
-            showNetworkErrorDialog()
-        }
+        conversationsListViewModel.getRooms()
     }
 
     private fun fetchPendingInvitations() {
@@ -760,31 +757,31 @@ class ConversationsListActivity :
 
     private fun initOverallLayout(isConversationListNotEmpty: Boolean) {
         if (isConversationListNotEmpty) {
-            if (binding?.emptyLayout?.visibility != View.GONE) {
-                binding?.emptyLayout?.visibility = View.GONE
+            if (binding.emptyLayout?.visibility != View.GONE) {
+                binding.emptyLayout?.visibility = View.GONE
             }
-            if (binding?.swipeRefreshLayoutView?.visibility != View.VISIBLE) {
-                binding?.swipeRefreshLayoutView?.visibility = View.VISIBLE
+            if (binding.swipeRefreshLayoutView?.visibility != View.VISIBLE) {
+                binding.swipeRefreshLayoutView?.visibility = View.VISIBLE
             }
         } else {
-            if (binding?.emptyLayout?.visibility != View.VISIBLE) {
-                binding?.emptyLayout?.visibility = View.VISIBLE
+            if (binding.emptyLayout?.visibility != View.VISIBLE) {
+                binding.emptyLayout?.visibility = View.VISIBLE
             }
-            if (binding?.swipeRefreshLayoutView?.visibility != View.GONE) {
-                binding?.swipeRefreshLayoutView?.visibility = View.GONE
+            if (binding.swipeRefreshLayoutView?.visibility != View.GONE) {
+                binding.swipeRefreshLayoutView?.visibility = View.GONE
             }
         }
     }
 
-    private fun addToConversationItems(conversation: Conversation) {
+    private fun addToConversationItems(conversation: ConversationModel) {
         if (intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) != null &&
-            intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) == conversation.roomId
+            intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) == conversation.token
         ) {
             return
         }
 
-        if (conversation.objectType == Conversation.ObjectType.ROOM &&
-            conversation.lobbyState == Conversation.LobbyState.LOBBY_STATE_MODERATORS_ONLY
+        if (conversation.objectType == ConversationEnums.ObjectType.ROOM &&
+            conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY
         ) {
             return
         }
@@ -851,31 +848,8 @@ class ConversationsListActivity :
         }
     }
 
-    private fun showNetworkErrorDialog() {
-        binding.floatingActionButton.let {
-            val dialogBuilder = MaterialAlertDialogBuilder(it.context)
-                .setIcon(
-                    viewThemeUtils.dialog.colorMaterialAlertDialogIcon(
-                        context,
-                        R.drawable.ic_baseline_error_outline_24dp
-                    )
-                )
-                .setTitle(R.string.nc_check_your_internet)
-                .setCancelable(false)
-                .setNegativeButton(R.string.close, null)
-                .setNeutralButton(R.string.nc_refresh) { _, _ ->
-                    fetchRooms()
-                    fetchPendingInvitations()
-                }
-
-            viewThemeUtils.dialog.colorMaterialAlertDialogBackground(it.context, dialogBuilder)
-            val dialog = dialogBuilder.show()
-            viewThemeUtils.platform.colorTextButtons(
-                dialog.getButton(AlertDialog.BUTTON_POSITIVE),
-                dialog.getButton(AlertDialog.BUTTON_NEGATIVE),
-                dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
-            )
-        }
+    private fun showNetworkErrorDialog(show: Boolean) {
+        binding.chatListConnectionLost.visibility = if (show) View.VISIBLE else View.GONE
     }
 
     @Suppress("ReturnCount")
@@ -909,35 +883,35 @@ class ConversationsListActivity :
             )
         ) {
             val openConversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
-            openConversationsQueryDisposable = ncApi.getOpenConversations(
-                credentials,
-                ApiUtils.getUrlForOpenConversations(apiVersion, currentUser!!.baseUrl!!)
-            )
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe({ (ocs): RoomsOverall ->
-                    for (conversation in ocs!!.data!!) {
-                        val headerTitle = resources!!.getString(R.string.openConversations)
-                        var genericTextHeaderItem: GenericTextHeaderItem
-                        if (!callHeaderItems.containsKey(headerTitle)) {
-                            genericTextHeaderItem = GenericTextHeaderItem(headerTitle, viewThemeUtils)
-                            callHeaderItems[headerTitle] = genericTextHeaderItem
-                        }
-                        val conversationItem = ConversationItem(
-                            conversation,
-                            currentUser!!,
-                            this,
-                            callHeaderItems[headerTitle],
-                            viewThemeUtils
-                        )
-                        openConversationItems.add(conversationItem)
-                    }
-                    searchableConversationItems.addAll(openConversationItems)
-                }, { throwable: Throwable ->
-                    Log.e(TAG, "fetchData - getRooms - ERROR", throwable)
-                    handleHttpExceptions(throwable)
-                    dispose(openConversationsQueryDisposable)
-                }) { dispose(openConversationsQueryDisposable) }
+            // openConversationsQueryDisposable = ncApi.getOpenConversations(
+            //     credentials,
+            //     ApiUtils.getUrlForOpenConversations(apiVersion, currentUser!!.baseUrl!!)
+            // )
+            //     .subscribeOn(Schedulers.io())
+            //     .observeOn(AndroidSchedulers.mainThread())
+            //     .subscribe({ (ocs): RoomsOverall ->
+            //         for (conversation in ocs!!.data!!) {
+            //             val headerTitle = resources!!.getString(R.string.openConversations)
+            //             var genericTextHeaderItem: GenericTextHeaderItem
+            //             if (!callHeaderItems.containsKey(headerTitle)) {
+            //                 genericTextHeaderItem = GenericTextHeaderItem(headerTitle, viewThemeUtils)
+            //                 callHeaderItems[headerTitle] = genericTextHeaderItem
+            //             }
+            //             val conversationItem = ConversationItem(
+            //                 conversation,
+            //                 currentUser!!,
+            //                 this,
+            //                 callHeaderItems[headerTitle],
+            //                 viewThemeUtils
+            //             )
+            //             openConversationItems.add(conversationItem)
+            //         }
+            //         searchableConversationItems.addAll(openConversationItems)
+            //     }, { throwable: Throwable ->
+            //         Log.e(TAG, "fetchData - getRooms - ERROR", throwable)
+            //         handleHttpExceptions(throwable)
+            //         dispose(openConversationsQueryDisposable)
+            //     }) { dispose(openConversationsQueryDisposable) }
         } else {
             Log.d(TAG, "no open conversations fetched because of missing capability")
         }
@@ -979,24 +953,24 @@ class ConversationsListActivity :
                 }
             }
         })
-        binding?.recyclerView?.setOnTouchListener { v: View, _: MotionEvent? ->
+        binding.recyclerView?.setOnTouchListener { v: View, _: MotionEvent? ->
             if (!isDestroyed) {
                 val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
                 imm.hideSoftInputFromWindow(v.windowToken, 0)
             }
             false
         }
-        binding?.swipeRefreshLayoutView?.setOnRefreshListener {
+        binding.swipeRefreshLayoutView?.setOnRefreshListener {
             fetchRooms()
             fetchPendingInvitations()
         }
-        binding?.swipeRefreshLayoutView?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) }
-        binding?.emptyLayout?.setOnClickListener { showNewConversationsScreen() }
-        binding?.floatingActionButton?.setOnClickListener {
+        binding.swipeRefreshLayoutView?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) }
+        binding.emptyLayout?.setOnClickListener { showNewConversationsScreen() }
+        binding.floatingActionButton?.setOnClickListener {
             run(context)
             showNewConversationsScreen()
         }
-        binding?.floatingActionButton?.let { viewThemeUtils.material.themeFAB(it) }
+        binding.floatingActionButton?.let { viewThemeUtils.material.themeFAB(it) }
 
         binding.switchAccountButton.setOnClickListener {
             if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) {
@@ -1015,13 +989,13 @@ class ConversationsListActivity :
             newFragment.show(supportFragmentManager, FilterConversationFragment.TAG)
         }
 
-        binding?.newMentionPopupBubble?.hide()
-        binding?.newMentionPopupBubble?.setPopupBubbleListener {
-            binding?.recyclerView?.smoothScrollToPosition(
+        binding.newMentionPopupBubble?.hide()
+        binding.newMentionPopupBubble?.setPopupBubbleListener {
+            binding.recyclerView?.smoothScrollToPosition(
                 nextUnreadConversationScrollPosition
             )
         }
-        binding?.newMentionPopupBubble?.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it) }
+        binding.newMentionPopupBubble?.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it) }
     }
 
     private fun hideLogoForBrandedClients() {
@@ -1041,17 +1015,17 @@ class ConversationsListActivity :
                 try {
                     val lastVisibleItem = layoutManager!!.findLastCompletelyVisibleItemPosition()
                     for (flexItem in conversationItems) {
-                        val conversation: Conversation = (flexItem as ConversationItem).model
+                        val conversation: ConversationModel = (flexItem as ConversationItem).model
                         val position = adapter!!.getGlobalPositionOf(flexItem)
                         if (hasUnreadItems(conversation) && position > lastVisibleItem) {
                             nextUnreadConversationScrollPosition = position
-                            if (!binding?.newMentionPopupBubble?.isShown!!) {
-                                binding?.newMentionPopupBubble?.show()
+                            if (!binding.newMentionPopupBubble?.isShown!!) {
+                                binding.newMentionPopupBubble?.show()
                             }
                             return@subscribe
                         }
                         nextUnreadConversationScrollPosition = 0
-                        binding?.newMentionPopupBubble?.hide()
+                        binding.newMentionPopupBubble?.hide()
                     }
                 } catch (e: NullPointerException) {
                     Log.d(
@@ -1066,10 +1040,10 @@ class ConversationsListActivity :
         }
     }
 
-    private fun hasUnreadItems(conversation: Conversation) =
+    private fun hasUnreadItems(conversation: ConversationModel) =
         conversation.unreadMention ||
             conversation.unreadMessages > 0 &&
-            conversation.type === Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+            conversation.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
 
     private fun showNewConversationsScreen() {
         val intent = Intent(context, ContactsActivityCompose::class.java)
@@ -1157,7 +1131,7 @@ class ConversationsListActivity :
 
     @SuppressLint("CheckResult") // handled by helper
     private fun startMessageSearch(search: String?) {
-        binding?.swipeRefreshLayoutView?.isRefreshing = true
+        binding.swipeRefreshLayoutView?.isRefreshing = true
         searchHelper?.startMessageSearch(search!!)
             ?.subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
@@ -1214,7 +1188,7 @@ class ConversationsListActivity :
     }
 
     @Suppress("Detekt.ComplexMethod")
-    private fun handleConversation(conversation: Conversation?) {
+    private fun handleConversation(conversation: ConversationModel?) {
         selectedConversation = conversation
         if (selectedConversation != null) {
             val hasChatPermission = ParticipantPermissions(
@@ -1244,19 +1218,19 @@ class ConversationsListActivity :
         }
     }
 
-    private fun shouldShowLobby(conversation: Conversation): Boolean {
+    private fun shouldShowLobby(conversation: ConversationModel): Boolean {
         val participantPermissions = ParticipantPermissions(
             currentUser!!.capabilities?.spreedCapability!!,
-            conversation
+            selectedConversation!!
         )
-        return conversation.lobbyState == Conversation.LobbyState.LOBBY_STATE_MODERATORS_ONLY &&
-            !conversation.canModerate(currentUser!!) &&
+        return conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY &&
+            !ConversationUtils.canModerate(conversation, currentUser!!.capabilities!!.spreedCapability!!) &&
             !participantPermissions.canIgnoreLobby()
     }
 
-    private fun isReadOnlyConversation(conversation: Conversation): Boolean {
+    private fun isReadOnlyConversation(conversation: ConversationModel): Boolean {
         return conversation.conversationReadOnlyState ===
-            Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY
+            ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY
     }
 
     private fun handleSharedData() {
@@ -1482,7 +1456,7 @@ class ConversationsListActivity :
 
         val bundle = Bundle()
         bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token)
-        bundle.putString(KEY_ROOM_ID, selectedConversation!!.roomId)
+        // bundle.putString(KEY_ROOM_ID, selectedConversation!!.roomId)
         bundle.putString(KEY_SHARED_TEXT, textToPaste)
         if (selectedMessageId != null) {
             bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId)
@@ -1519,7 +1493,7 @@ class ConversationsListActivity :
         }, BOTTOM_SHEET_DELAY)
     }
 
-    fun showDeleteConversationDialog(conversation: Conversation) {
+    fun showDeleteConversationDialog(conversation: ConversationModel) {
         binding.floatingActionButton.let {
             val dialogBuilder = MaterialAlertDialogBuilder(it.context)
                 .setIcon(
@@ -1751,7 +1725,7 @@ class ConversationsListActivity :
         }
     }
 
-    private fun deleteConversation(conversation: Conversation) {
+    private fun deleteConversation(conversation: ConversationModel) {
         val data = Data.Builder()
         data.putLong(
             KEY_INTERNAL_USER_ID,
@@ -1810,15 +1784,15 @@ class ConversationsListActivity :
                 }
                 // add unified search result at the end of the list
                 adapter!!.addItems(adapter!!.mainItemCount + adapter!!.scrollableHeaders.size, adapterItems)
-                binding?.recyclerView?.scrollToPosition(0)
+                binding.recyclerView?.scrollToPosition(0)
             }
         }
-        binding?.swipeRefreshLayoutView?.isRefreshing = false
+        binding.swipeRefreshLayoutView?.isRefreshing = false
     }
 
     private fun onMessageSearchError(throwable: Throwable) {
         handleHttpExceptions(throwable)
-        binding?.swipeRefreshLayoutView?.isRefreshing = false
+        binding.swipeRefreshLayoutView?.isRefreshing = false
         showErrorDialog()
     }
 

+ 0 - 9
app/src/main/java/com/nextcloud/talk/conversationlist/data/ConversationsListRepository.kt

@@ -1,9 +0,0 @@
-/*
- * Nextcloud Talk - Android Client
- *
- * SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-package com.nextcloud.talk.conversationlist.data
-
-interface ConversationsListRepository

+ 0 - 11
app/src/main/java/com/nextcloud/talk/conversationlist/data/ConversationsListRepositoryImpl.kt

@@ -1,11 +0,0 @@
-/*
- * Nextcloud Talk - Android Client
- *
- * SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-package com.nextcloud.talk.conversationlist.data
-
-import com.nextcloud.talk.api.NcApi
-
-class ConversationsListRepositoryImpl(private val ncApi: NcApi) : ConversationsListRepository

+ 39 - 0
app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt

@@ -0,0 +1,39 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.conversationlist.data
+
+import com.nextcloud.talk.models.domain.ConversationModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+
+interface OfflineConversationsRepository {
+
+    /**
+     * Stream of a list of rooms, for use in the conversation list.
+     */
+    val roomListFlow: Flow<List<ConversationModel>>
+
+    /**
+     * Stream of a single conversation, for use in each conversations settings.
+     */
+    val conversationFlow: Flow<ConversationModel>
+
+    /**
+     * Loads rooms from local storage. If the rooms are not found, then it
+     * synchronizes the database with the server, before retrying exactly once. Only
+     * emits to [roomListFlow] if the rooms list is not empty.
+     *
+     */
+    fun getRooms(): Job
+
+    /**
+     * Called once onStart to emit a conversation to [conversationFlow]
+     * to be handled asynchronously.
+     */
+    fun getConversationSettings(roomToken: String): Job
+}

+ 16 - 0
app/src/main/java/com/nextcloud/talk/conversationlist/data/network/ConversationsNetworkDataSource.kt

@@ -0,0 +1,16 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.conversationlist.data.network
+
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.conversations.Conversation
+import io.reactivex.Observable
+
+interface ConversationsNetworkDataSource {
+    fun getRooms(user: User, url: String, includeStatus: Boolean): Observable<List<Conversation>>
+}

+ 118 - 0
app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt

@@ -0,0 +1,118 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.conversationlist.data.network
+
+import android.util.Log
+import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository
+import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
+import com.nextcloud.talk.data.database.dao.ConversationsDao
+import com.nextcloud.talk.data.database.mappers.asEntity
+import com.nextcloud.talk.data.database.mappers.asModel
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import com.nextcloud.talk.data.network.NetworkMonitor
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class OfflineFirstConversationsRepository @Inject constructor(
+    private val dao: ConversationsDao,
+    private val network: ConversationsNetworkDataSource,
+    private val monitor: NetworkMonitor,
+    private val currentUserProviderNew: CurrentUserProviderNew
+) : OfflineConversationsRepository {
+    override val roomListFlow: Flow<List<ConversationModel>>
+        get() = _roomListFlow
+    private val _roomListFlow: MutableSharedFlow<List<ConversationModel>> = MutableSharedFlow()
+
+    override val conversationFlow: Flow<ConversationModel>
+        get() = _conversationFlow
+    private val _conversationFlow: MutableSharedFlow<ConversationModel> = MutableSharedFlow()
+
+    private val scope = CoroutineScope(Dispatchers.IO)
+    private var user: User = currentUserProviderNew.currentUser.blockingGet()
+
+    override fun getRooms(): Job =
+        scope.launch {
+            val resultsFromSync = sync()
+            if (!resultsFromSync.isNullOrEmpty()) {
+                val conversations = resultsFromSync.map(ConversationEntity::asModel)
+                _roomListFlow.emit(conversations)
+            } else {
+                val conversationsFromDb = getListOfConversations(user.id!!)
+                _roomListFlow.emit(conversationsFromDb)
+            }
+        }
+
+    override fun getConversationSettings(roomToken: String): Job =
+        scope.launch {
+            val id = user.id!!
+            val model = getConversation(id, roomToken)
+            model.let { _conversationFlow.emit(model) }
+        }
+
+    private suspend fun sync(): List<ConversationEntity>? {
+        var conversationsFromSync: List<ConversationEntity>? = null
+
+        if (!monitor.isOnline.first()) {
+            Log.d(OfflineFirstChatRepository.TAG, "Device is offline, can't load conversations from server")
+            return null
+        }
+
+        try {
+            val conversationsList = network.getRooms(user, user.baseUrl!!, false)
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .blockingSingle()
+
+            conversationsFromSync = conversationsList.map {
+                it.asEntity(user.id!!)
+            }
+
+            deleteLeftConversations(conversationsFromSync)
+            dao.upsertConversations(conversationsFromSync)
+        } catch (e: Exception) {
+            Log.e(TAG, "Something went wrong when fetching conversations", e)
+        }
+        return conversationsFromSync
+    }
+
+    private suspend fun deleteLeftConversations(conversationsFromSync: List<ConversationEntity>) {
+        val oldConversationsFromDb = dao.getConversationsForUser(user.id!!).first()
+
+        val conversationsToDelete = oldConversationsFromDb.filterNot { conversationsFromSync.contains(it) }
+        val conversationIdsToDelete = conversationsToDelete.map { it.internalId }
+
+        dao.deleteConversations(conversationIdsToDelete)
+    }
+
+    private suspend fun getListOfConversations(accountId: Long): List<ConversationModel> =
+        dao.getConversationsForUser(accountId).map {
+            it.map(ConversationEntity::asModel)
+        }.first()
+
+    private suspend fun getConversation(accountId: Long, token: String): ConversationModel {
+        val entity = dao.getConversationForUser(accountId, token).first()
+        return entity.asModel()
+    }
+
+    companion object {
+        val TAG = OfflineFirstConversationsRepository::class.simpleName
+    }
+}

+ 28 - 0
app/src/main/java/com/nextcloud/talk/conversationlist/data/network/RetrofitConversationsNetwork.kt

@@ -0,0 +1,28 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.conversationlist.data.network
+
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.utils.ApiUtils
+import io.reactivex.Observable
+
+class RetrofitConversationsNetwork(private val ncApi: NcApi) : ConversationsNetworkDataSource {
+    override fun getRooms(user: User, url: String, includeStatus: Boolean): Observable<List<Conversation>> {
+        val credentials: String = ApiUtils.getCredentials(user.username, user.token)!!
+        val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1))
+
+        return ncApi.getRooms(
+            credentials,
+            ApiUtils.getUrlForRooms(apiVersion, user.baseUrl!!),
+            includeStatus
+        ).map { it ->
+            it.ocs?.data?.map { it } ?: listOf()
+        }
+    }
+}

+ 26 - 5
app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt

@@ -10,7 +10,7 @@ import android.util.Log
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
-import com.nextcloud.talk.conversationlist.data.ConversationsListRepository
+import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
 import com.nextcloud.talk.invitation.data.InvitationsModel
 import com.nextcloud.talk.invitation.data.InvitationsRepository
 import com.nextcloud.talk.users.UserManager
@@ -18,21 +18,36 @@ import io.reactivex.Observer
 import io.reactivex.android.schedulers.AndroidSchedulers
 import io.reactivex.disposables.Disposable
 import io.reactivex.schedulers.Schedulers
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.onEach
 import javax.inject.Inject
 
 class ConversationsListViewModel @Inject constructor(
-    private val conversationsListRepository: ConversationsListRepository
+    private val repository: OfflineConversationsRepository,
+    var userManager: UserManager
 ) :
     ViewModel() {
 
     @Inject
     lateinit var invitationsRepository: InvitationsRepository
 
-    @Inject
-    lateinit var userManager: UserManager
-
     sealed interface ViewState
 
+    object GetRoomsStartState : ViewState
+    object GetRoomsErrorState : ViewState
+    open class GetRoomsSuccessState(val listIsNotEmpty: Boolean) : ViewState
+
+    private val _getRoomsViewState: MutableLiveData<ViewState> = MutableLiveData(GetRoomsStartState)
+    val getRoomsViewState: LiveData<ViewState>
+        get() = _getRoomsViewState
+
+    val getRoomsFlow = repository.roomListFlow
+        .onEach { list ->
+            _getRoomsViewState.value = GetRoomsSuccessState(list.isNotEmpty())
+        }.catch {
+            _getRoomsViewState.value = GetRoomsErrorState
+        }
+
     object GetFederationInvitationsStartState : ViewState
     object GetFederationInvitationsErrorState : ViewState
 
@@ -63,6 +78,12 @@ class ConversationsListViewModel @Inject constructor(
         }
     }
 
+    fun getRooms() {
+        val startNanoTime = System.nanoTime()
+        Log.d(TAG, "fetchData - getRooms - calling: $startNanoTime")
+        repository.getRooms()
+    }
+
     inner class FederatedInvitationsObserver : Observer<InvitationsModel> {
         override fun onSubscribe(d: Disposable) {
             // unused atm

+ 27 - 0
app/src/main/java/com/nextcloud/talk/dagger/modules/DaosModule.kt

@@ -0,0 +1,27 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.dagger.modules
+
+import com.nextcloud.talk.data.database.dao.ChatBlocksDao
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.dao.ConversationsDao
+import com.nextcloud.talk.data.source.local.TalkDatabase
+import dagger.Module
+import dagger.Provides
+
+@Module
+internal object DaosModule {
+    @Provides
+    fun providesConversationsDao(database: TalkDatabase): ConversationsDao = database.conversationsDao()
+
+    @Provides
+    fun providesChatDao(database: TalkDatabase): ChatMessagesDao = database.chatMessagesDao()
+
+    @Provides
+    fun providesChatBlocksDao(database: TalkDatabase): ChatBlocksDao = database.chatBlocksDao()
+}

+ 8 - 0
app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java

@@ -9,6 +9,8 @@ package com.nextcloud.talk.dagger.modules;
 
 import android.content.Context;
 
+import com.nextcloud.talk.data.network.NetworkMonitor;
+import com.nextcloud.talk.data.network.NetworkMonitorImpl;
 import com.nextcloud.talk.data.source.local.TalkDatabase;
 import com.nextcloud.talk.utils.preferences.AppPreferences;
 import com.nextcloud.talk.utils.preferences.AppPreferencesImpl;
@@ -44,4 +46,10 @@ public class DatabaseModule {
                                             @NonNull final AppPreferences appPreferences) {
         return TalkDatabase.getInstance(context, appPreferences);
     }
+
+    @Provides
+    @Singleton
+    public NetworkMonitor provideNetworkMonitor(@NonNull final Context poContext) {
+        return new NetworkMonitorImpl(poContext);
+    }
 }

+ 53 - 11
app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt

@@ -1,7 +1,7 @@
 /*
  * Nextcloud Talk - Android Client
  *
- * SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-FileCopyrightText: 2022-2024 Marcel Hibbe <dev@mhibbe.de>
  * SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
  * SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
  * SPDX-FileCopyrightText: 2022 Nextcloud GmbH
@@ -10,17 +10,25 @@
 package com.nextcloud.talk.dagger.modules
 
 import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.chat.data.ChatMessageRepository
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
+import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository
+import com.nextcloud.talk.chat.data.network.RetrofitChatNetwork
 import com.nextcloud.talk.api.NcApiCoroutines
-import com.nextcloud.talk.chat.data.ChatRepository
-import com.nextcloud.talk.chat.data.network.NetworkChatRepositoryImpl
 import com.nextcloud.talk.contacts.ContactsRepository
 import com.nextcloud.talk.contacts.ContactsRepositoryImpl
 import com.nextcloud.talk.conversation.repository.ConversationRepository
 import com.nextcloud.talk.conversation.repository.ConversationRepositoryImpl
 import com.nextcloud.talk.conversationinfoedit.data.ConversationInfoEditRepository
 import com.nextcloud.talk.conversationinfoedit.data.ConversationInfoEditRepositoryImpl
-import com.nextcloud.talk.conversationlist.data.ConversationsListRepository
-import com.nextcloud.talk.conversationlist.data.ConversationsListRepositoryImpl
+import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
+import com.nextcloud.talk.conversationlist.data.network.ConversationsNetworkDataSource
+import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository
+import com.nextcloud.talk.conversationlist.data.network.RetrofitConversationsNetwork
+import com.nextcloud.talk.data.database.dao.ChatBlocksDao
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.dao.ConversationsDao
+import com.nextcloud.talk.data.network.NetworkMonitor
 import com.nextcloud.talk.data.source.local.TalkDatabase
 import com.nextcloud.talk.data.storage.ArbitraryStoragesRepository
 import com.nextcloud.talk.data.storage.ArbitraryStoragesRepositoryImpl
@@ -51,6 +59,7 @@ import com.nextcloud.talk.translate.repositories.TranslateRepositoryImpl
 import com.nextcloud.talk.users.UserManager
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
+import com.nextcloud.talk.utils.preferences.AppPreferences
 import dagger.Module
 import dagger.Provides
 import okhttp3.OkHttpClient
@@ -97,8 +106,12 @@ class RepositoryModule {
     }
 
     @Provides
-    fun provideReactionsRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ReactionsRepository {
-        return ReactionsRepositoryImpl(ncApi, userProvider)
+    fun provideReactionsRepository(
+        ncApi: NcApi,
+        userProvider: CurrentUserProviderNew,
+        dao: ChatMessagesDao
+    ): ReactionsRepository {
+        return ReactionsRepositoryImpl(ncApi, userProvider, dao)
     }
 
     @Provides
@@ -128,13 +141,13 @@ class RepositoryModule {
     }
 
     @Provides
-    fun provideConversationsListRepository(ncApi: NcApi): ConversationsListRepository {
-        return ConversationsListRepositoryImpl(ncApi)
+    fun provideChatNetworkDataSource(ncApi: NcApi): ChatNetworkDataSource {
+        return RetrofitChatNetwork(ncApi)
     }
 
     @Provides
-    fun provideChatRepository(ncApi: NcApi): ChatRepository {
-        return NetworkChatRepositoryImpl(ncApi)
+    fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource {
+        return RetrofitConversationsNetwork(ncApi)
     }
 
     @Provides
@@ -155,6 +168,35 @@ class RepositoryModule {
         return InvitationsRepositoryImpl(ncApi)
     }
 
+    @Provides
+    fun provideOfflineFirstChatRepository(
+        chatMessagesDao: ChatMessagesDao,
+        chatBlocksDao: ChatBlocksDao,
+        dataSource: ChatNetworkDataSource,
+        appPreferences: AppPreferences,
+        networkMonitor: NetworkMonitor,
+        userProvider: CurrentUserProviderNew
+    ): ChatMessageRepository {
+        return OfflineFirstChatRepository(
+            chatMessagesDao,
+            chatBlocksDao,
+            dataSource,
+            appPreferences,
+            networkMonitor,
+            userProvider
+        )
+    }
+
+    @Provides
+    fun provideOfflineFirstConversationsRepository(
+        dao: ConversationsDao,
+        dataSource: ConversationsNetworkDataSource,
+        networkMonitor: NetworkMonitor,
+        currentUserProviderNew: CurrentUserProviderNew
+    ): OfflineConversationsRepository {
+        return OfflineFirstConversationsRepository(dao, dataSource, networkMonitor, currentUserProviderNew)
+    }
+
     @Provides
     fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository {
         return ContactsRepositoryImpl(ncApiCoroutines, userManager)

+ 101 - 0
app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt

@@ -0,0 +1,101 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.nextcloud.talk.data.database.model.ChatBlockEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ChatBlocksDao {
+    @Delete
+    fun deleteChatBlocks(blocks: List<ChatBlockEntity>)
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatBlocks
+        WHERE internalConversationId in (:internalConversationId)
+        ORDER BY newestMessageId ASC
+        """
+    )
+    fun getChatBlocks(internalConversationId: String): Flow<List<ChatBlockEntity>>
+
+    // @Query(
+    //     """
+    //     SELECT *
+    //     FROM ChatBlocks
+    //     WHERE internalConversationId in (:internalConversationId)
+    //     AND newestMessageId >= :messageId
+    //     ORDER BY newestMessageId ASC
+    //     """
+    // )
+    // fun getChatBlocksThatReachMessageId(
+    //     internalConversationId: String,
+    //     messageId: Long
+    // ):
+    //     Flow<List<ChatBlockEntity>>
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatBlocks
+        WHERE internalConversationId in (:internalConversationId)
+        AND oldestMessageId <= :messageId
+        AND newestMessageId >= :messageId
+        ORDER BY newestMessageId ASC
+        """
+    )
+    fun getChatBlocksContainingMessageId(internalConversationId: String, messageId: Long): Flow<List<ChatBlockEntity?>>
+
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatBlocks
+        WHERE internalConversationId = :internalConversationId
+        AND(
+            (oldestMessageId <= :oldestMessageId AND newestMessageId >= :oldestMessageId)
+            OR
+            (oldestMessageId <= :newestMessageId AND newestMessageId >= :newestMessageId)
+            OR
+            (oldestMessageId >= :oldestMessageId AND newestMessageId <= :newestMessageId)
+        )
+        ORDER BY newestMessageId ASC
+        """
+    )
+    fun getConnectedChatBlocks(
+        internalConversationId: String,
+        oldestMessageId: Long,
+        newestMessageId: Long
+    ): Flow<List<ChatBlockEntity>>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertChatBlock(chatBlock: ChatBlockEntity)
+
+    @Query(
+        """
+        DELETE FROM ChatBlocks
+        WHERE internalConversationId LIKE :pattern
+        """
+    )
+    fun clearChatBlocksForUser(pattern: String)
+
+    @Query(
+        """
+        DELETE FROM ChatBlocks
+        WHERE internalConversationId = :internalConversationId
+        AND oldestMessageId < :messageId
+        """
+    )
+    fun deleteChatBlocksOlderThan(internalConversationId: String, messageId: Long)
+}

+ 143 - 0
app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt

@@ -0,0 +1,143 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ChatMessagesDao {
+    @Query(
+        """
+        SELECT MAX(id) as max_items
+        FROM ChatMessages
+        WHERE internalConversationId = :internalConversationId
+        """
+    )
+    fun getNewestMessageId(internalConversationId: String): Long
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatMessages
+        WHERE internalConversationId = :internalConversationId
+        ORDER BY timestamp DESC, id DESC
+        """
+    )
+    fun getMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertChatMessage(chatMessage: ChatMessageEntity)
+
+    @Query(
+        """
+        SELECT * 
+        FROM ChatMessages
+        WHERE internalConversationId = :internalConversationId AND id = :messageId
+        """
+    )
+    fun getChatMessageForConversation(internalConversationId: String, messageId: Long): Flow<ChatMessageEntity>
+
+    @Query(
+        value = """
+            DELETE FROM ChatMessages
+            WHERE id in (:messageIds)
+        """
+    )
+    fun deleteChatMessages(messageIds: List<Int>)
+
+    @Update
+    fun updateChatMessage(message: ChatMessageEntity)
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatMessages
+        WHERE id in (:messageIds)
+        ORDER BY timestamp ASC, id ASC
+        """
+    )
+    fun getMessagesFromIds(messageIds: List<Long>): Flow<List<ChatMessageEntity>>
+
+    @Query(
+        """
+        SELECT * 
+        FROM ChatMessages 
+        WHERE internalConversationId = :internalConversationId AND id >= :messageId 
+        ORDER BY timestamp ASC, id ASC
+        """
+    )
+    fun getMessagesForConversationSince(internalConversationId: String, messageId: Long): Flow<List<ChatMessageEntity>>
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatMessages
+        WHERE internalConversationId = :internalConversationId 
+        AND id < :messageId
+        ORDER BY timestamp DESC, id DESC
+        LIMIT :limit
+        """
+    )
+    fun getMessagesForConversationBefore(
+        internalConversationId: String,
+        messageId: Long,
+        limit: Int
+    ): Flow<List<ChatMessageEntity>>
+
+    @Query(
+        """
+        SELECT *
+        FROM ChatMessages
+        WHERE internalConversationId = :internalConversationId 
+        AND id <= :messageId
+        ORDER BY timestamp DESC, id DESC
+        LIMIT :limit
+        """
+    )
+    fun getMessagesForConversationBeforeAndEqual(
+        internalConversationId: String,
+        messageId: Long,
+        limit: Int
+    ): Flow<List<ChatMessageEntity>>
+
+    @Query(
+        """
+        SELECT COUNT(*) 
+        FROM ChatMessages 
+        WHERE internalConversationId = :internalConversationId 
+        AND id BETWEEN :newestMessageId AND :oldestMessageId
+        """
+    )
+    fun getCountBetweenMessageIds(internalConversationId: String, oldestMessageId: Long, newestMessageId: Long): Int
+
+    @Query(
+        """
+        DELETE FROM chatmessages
+        WHERE internalId LIKE :pattern
+        """
+    )
+    fun clearAllMessagesForUser(pattern: String)
+
+    @Query(
+        """
+        DELETE FROM chatmessages
+        WHERE internalConversationId = :internalConversationId 
+        AND id < :messageId
+        """
+    )
+    fun deleteMessagesOlderThan(internalConversationId: String, messageId: Long)
+}

+ 49 - 0
app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt

@@ -0,0 +1,49 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Update
+import androidx.room.Upsert
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ConversationsDao {
+    @Query("SELECT * FROM Conversations where accountId = :accountId")
+    fun getConversationsForUser(accountId: Long): Flow<List<ConversationEntity>>
+
+    @Query("SELECT * FROM Conversations where accountId = :accountId AND token = :token")
+    fun getConversationForUser(accountId: Long, token: String): Flow<ConversationEntity>
+
+    @Upsert
+    fun upsertConversations(conversationEntities: List<ConversationEntity>)
+
+    /**
+     * Deletes rows in the db matching the specified [conversationIds]
+     */
+    @Query(
+        value = """
+            DELETE FROM conversations
+            WHERE internalId in (:conversationIds)
+        """
+    )
+    fun deleteConversations(conversationIds: List<String>)
+
+    @Update
+    fun updateConversation(conversationEntity: ConversationEntity)
+
+    @Query(
+        """
+        DELETE FROM Conversations
+        WHERE accountId = :accountId
+        """
+    )
+    fun clearAllConversationsForUser(accountId: Long)
+}

+ 93 - 0
app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt

@@ -0,0 +1,93 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.mappers
+
+import com.nextcloud.talk.models.json.chat.ChatMessageJson
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import kotlinx.coroutines.flow.first
+
+fun ChatMessageJson.asEntity(accountId: Long) =
+    ChatMessageEntity(
+        // accountId@token@messageId
+        internalId = "$accountId@$token@$id",
+        accountId = accountId,
+        id = id,
+        internalConversationId = "$accountId@$token",
+        message = message!!,
+        token = token!!,
+        actorType = actorType!!,
+        actorId = actorId!!,
+        actorDisplayName = actorDisplayName!!,
+        timestamp = timestamp,
+        messageParameters = messageParameters,
+        systemMessageType = systemMessageType!!,
+        replyable = replyable,
+        parentMessageId = parentMessage?.id,
+        messageType = messageType!!,
+        reactions = reactions,
+        reactionsSelf = reactionsSelf,
+        expirationTimestamp = expirationTimestamp,
+        renderMarkdown = renderMarkdown,
+        lastEditActorDisplayName = lastEditActorDisplayName,
+        lastEditActorId = lastEditActorId,
+        lastEditActorType = lastEditActorType,
+        lastEditTimestamp = lastEditTimestamp,
+        deleted = deleted
+    )
+
+fun ChatMessageEntity.asModel() =
+    ChatMessage(
+        jsonMessageId = id.toInt(),
+        message = message,
+        token = token,
+        actorType = actorType,
+        actorId = actorId,
+        actorDisplayName = actorDisplayName,
+        timestamp = timestamp,
+        messageParameters = messageParameters,
+        systemMessageType = systemMessageType,
+        replyable = replyable,
+        parentMessageId = parentMessageId,
+        messageType = messageType,
+        reactions = reactions,
+        reactionsSelf = reactionsSelf,
+        expirationTimestamp = expirationTimestamp,
+        renderMarkdown = renderMarkdown,
+        lastEditActorDisplayName = lastEditActorDisplayName,
+        lastEditActorId = lastEditActorId,
+        lastEditActorType = lastEditActorType,
+        lastEditTimestamp = lastEditTimestamp,
+        isDeleted = deleted
+    )
+
+fun ChatMessageJson.asModel() =
+    ChatMessage(
+        jsonMessageId = id.toInt(),
+        message = message,
+        token = token,
+        actorType = actorType,
+        actorId = actorId,
+        actorDisplayName = actorDisplayName,
+        timestamp = timestamp,
+        messageParameters = messageParameters,
+        systemMessageType = systemMessageType,
+        replyable = replyable,
+        parentMessageId = parentMessage?.id,
+        messageType = messageType,
+        reactions = reactions,
+        reactionsSelf = reactionsSelf,
+        expirationTimestamp = expirationTimestamp,
+        renderMarkdown = renderMarkdown,
+        lastEditActorDisplayName = lastEditActorDisplayName,
+        lastEditActorId = lastEditActorId,
+        lastEditActorType = lastEditActorType,
+        lastEditTimestamp = lastEditTimestamp,
+        isDeleted = deleted
+    )

+ 162 - 0
app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt

@@ -0,0 +1,162 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.mappers
+
+import com.bluelinelabs.logansquare.LoganSquare
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.models.json.chat.ChatMessageJson
+import com.nextcloud.talk.models.json.conversations.Conversation
+
+fun ConversationModel.asEntity() =
+    ConversationEntity(
+        internalId = internalId,
+        accountId = accountId,
+        token = token,
+        name = name,
+        displayName = displayName,
+        description = description,
+        type = type,
+        lastPing = lastPing,
+        participantType = participantType,
+        hasPassword = hasPassword,
+        sessionId = sessionId,
+        actorId = actorId,
+        actorType = actorType,
+        favorite = favorite,
+        lastActivity = lastActivity,
+        unreadMessages = unreadMessages,
+        unreadMention = unreadMention,
+        lastMessage = lastMessage?.let { LoganSquare.serialize(lastMessage) },
+        objectType = objectType,
+        notificationLevel = notificationLevel,
+        conversationReadOnlyState = conversationReadOnlyState,
+        lobbyState = lobbyState,
+        lobbyTimer = lobbyTimer,
+        lastReadMessage = lastReadMessage,
+        lastCommonReadMessage = lastCommonReadMessage,
+        hasCall = hasCall,
+        callFlag = callFlag,
+        canStartCall = canStartCall,
+        canLeaveConversation = canLeaveConversation,
+        canDeleteConversation = canDeleteConversation,
+        unreadMentionDirect = unreadMentionDirect,
+        notificationCalls = notificationCalls,
+        permissions = permissions,
+        messageExpiration = messageExpiration,
+        status = status,
+        statusIcon = statusIcon,
+        statusMessage = statusMessage,
+        statusClearAt = statusClearAt,
+        callRecording = callRecording,
+        avatarVersion = avatarVersion,
+        hasCustomAvatar = hasCustomAvatar,
+        callStartTime = callStartTime,
+        recordingConsentRequired = recordingConsentRequired,
+        remoteServer = remoteServer,
+        remoteToken = remoteToken
+    )
+
+fun ConversationEntity.asModel() =
+    ConversationModel(
+        internalId = internalId,
+        accountId = accountId,
+        token = token,
+        name = name,
+        displayName = displayName,
+        description = description,
+        type = type,
+        lastPing = lastPing,
+        participantType = participantType,
+        hasPassword = hasPassword,
+        sessionId = sessionId,
+        actorId = actorId,
+        actorType = actorType,
+        favorite = favorite,
+        lastActivity = lastActivity,
+        unreadMessages = unreadMessages,
+        unreadMention = unreadMention,
+        lastMessage = lastMessage?.let
+            { LoganSquare.parse(lastMessage, ChatMessageJson::class.java) },
+        objectType = objectType,
+        notificationLevel = notificationLevel,
+        conversationReadOnlyState = conversationReadOnlyState,
+        lobbyState = lobbyState,
+        lobbyTimer = lobbyTimer,
+        lastReadMessage = lastReadMessage,
+        lastCommonReadMessage = lastCommonReadMessage,
+        hasCall = hasCall,
+        callFlag = callFlag,
+        canStartCall = canStartCall,
+        canLeaveConversation = canLeaveConversation,
+        canDeleteConversation = canDeleteConversation,
+        unreadMentionDirect = unreadMentionDirect,
+        notificationCalls = notificationCalls,
+        permissions = permissions,
+        messageExpiration = messageExpiration,
+        status = status,
+        statusIcon = statusIcon,
+        statusMessage = statusMessage,
+        statusClearAt = statusClearAt,
+        callRecording = callRecording,
+        avatarVersion = avatarVersion,
+        hasCustomAvatar = hasCustomAvatar,
+        callStartTime = callStartTime,
+        recordingConsentRequired = recordingConsentRequired,
+        remoteServer = remoteServer,
+        remoteToken = remoteToken
+    )
+
+fun Conversation.asEntity(accountId: Long) =
+    ConversationEntity(
+        internalId = "$accountId@$token",
+        accountId = accountId,
+        token = token!!,
+        name = name!!,
+        displayName = displayName!!,
+        description = description!!,
+        type = type!!,
+        lastPing = lastPing,
+        participantType = participantType!!,
+        hasPassword = hasPassword,
+        sessionId = sessionId!!,
+        actorId = actorId!!,
+        actorType = actorType!!,
+        favorite = favorite,
+        lastActivity = lastActivity,
+        unreadMessages = unreadMessages,
+        unreadMention = unreadMention,
+        lastMessage = lastMessage?.let { LoganSquare.serialize(lastMessage) },
+        objectType = objectType!!,
+        notificationLevel = notificationLevel!!,
+        conversationReadOnlyState = conversationReadOnlyState!!,
+        lobbyState = lobbyState!!,
+        lobbyTimer = lobbyTimer!!,
+        lastReadMessage = lastReadMessage,
+        lastCommonReadMessage = lastCommonReadMessage,
+        hasCall = hasCall,
+        callFlag = callFlag,
+        canStartCall = canStartCall,
+        canLeaveConversation = canLeaveConversation!!,
+        canDeleteConversation = canDeleteConversation!!,
+        unreadMentionDirect = unreadMentionDirect!!,
+        notificationCalls = notificationCalls!!,
+        permissions = permissions,
+        messageExpiration = messageExpiration,
+        status = status,
+        statusIcon = statusIcon,
+        statusMessage = statusMessage,
+        statusClearAt = statusClearAt,
+        callRecording = callRecording,
+        avatarVersion = avatarVersion!!,
+        hasCustomAvatar = hasCustomAvatar!!,
+        callStartTime = callStartTime!!,
+        recordingConsentRequired = recordingConsentRequired,
+        remoteServer = remoteServer,
+        remoteToken = remoteToken
+    )

+ 41 - 0
app/src/main/java/com/nextcloud/talk/data/database/model/ChatBlockEntity.kt

@@ -0,0 +1,41 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "ChatBlocks",
+    foreignKeys = [
+        ForeignKey(
+            entity = ConversationEntity::class,
+            parentColumns = arrayOf("internalId"),
+            childColumns = arrayOf("internalConversationId"),
+            onDelete = ForeignKey.CASCADE,
+            onUpdate = ForeignKey.CASCADE
+        )
+    ],
+    indices = [
+        Index(value = ["internalConversationId"])
+    ]
+)
+data class ChatBlockEntity(
+    @PrimaryKey(autoGenerate = true)
+    @ColumnInfo(name = "id") var id: Long = 0,
+    // accountId@token
+    @ColumnInfo(name = "internalConversationId") var internalConversationId: String,
+    @ColumnInfo(name = "accountId") var accountId: Long? = null,
+    @ColumnInfo(name = "token") var token: String?,
+    @ColumnInfo(name = "oldestMessageId") var oldestMessageId: Long,
+    @ColumnInfo(name = "newestMessageId") var newestMessageId: Long,
+    @ColumnInfo(name = "hasHistory") var hasHistory: Boolean
+)

+ 69 - 0
app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt

@@ -0,0 +1,69 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.nextcloud.talk.chat.data.model.ChatMessage
+
+@Entity(
+    tableName = "ChatMessages",
+    foreignKeys = [
+        ForeignKey(
+            entity = ConversationEntity::class,
+            parentColumns = arrayOf("internalId"),
+            childColumns = arrayOf("internalConversationId"),
+            onDelete = ForeignKey.CASCADE,
+            onUpdate = ForeignKey.CASCADE
+        )
+    ],
+    indices = [
+        Index(value = ["internalId"], unique = true),
+        Index(value = ["internalConversationId"])
+    ]
+)
+data class ChatMessageEntity(
+    // MOST IMPORTANT ATTRIBUTES
+
+    @PrimaryKey
+    // accountId@roomtoken@messageId
+    @ColumnInfo(name = "internalId") var internalId: String,
+    @ColumnInfo(name = "accountId") var accountId: Long,
+    @ColumnInfo(name = "token") var token: String,
+    @ColumnInfo(name = "id") var id: Long = 0,
+    // accountId@roomtoken
+    @ColumnInfo(name = "internalConversationId") var internalConversationId: String,
+
+    @ColumnInfo(name = "actorDisplayName") var actorDisplayName: String,
+    @ColumnInfo(name = "message") var message: String,
+
+    // OTHER ATTRIBUTES IN ALPHABETICAL ORDER
+
+    @ColumnInfo(name = "actorId") var actorId: String,
+    @ColumnInfo(name = "actorType") var actorType: String,
+    @ColumnInfo(name = "deleted") var deleted: Boolean = false,
+    @ColumnInfo(name = "expirationTimestamp") var expirationTimestamp: Int = 0,
+    @ColumnInfo(name = "isReplyable") var replyable: Boolean = false,
+    @ColumnInfo(name = "lastEditActorDisplayName") var lastEditActorDisplayName: String? = null,
+    @ColumnInfo(name = "lastEditActorId") var lastEditActorId: String? = null,
+    @ColumnInfo(name = "lastEditActorType") var lastEditActorType: String? = null,
+    @ColumnInfo(name = "lastEditTimestamp") var lastEditTimestamp: Long? = 0,
+    @ColumnInfo(name = "markdown") var renderMarkdown: Boolean? = false,
+    @ColumnInfo(name = "messageParameters") var messageParameters: HashMap<String?, HashMap<String?, String?>>? = null,
+    @ColumnInfo(name = "messageType") var messageType: String,
+    @ColumnInfo(name = "parent") var parentMessageId: Long? = null,
+    @ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null,
+    @ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList<String>? = null,
+    @ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
+    @ColumnInfo(name = "timestamp") var timestamp: Long = 0,
+    // missing/not needed: referenceId
+    // missing/not needed: silent
+)

+ 111 - 0
app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt

@@ -0,0 +1,111 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.database.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.nextcloud.talk.data.user.model.UserEntity
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.models.json.participants.Participant
+
+@Entity(
+    tableName = "Conversations",
+    foreignKeys = [
+        ForeignKey(
+            entity = UserEntity::class,
+            parentColumns = arrayOf("id"),
+            childColumns = arrayOf("accountId"),
+            onDelete = ForeignKey.CASCADE,
+            onUpdate = ForeignKey.CASCADE
+        )
+    ],
+    indices = [
+        Index(value = ["accountId"])
+    ]
+)
+data class ConversationEntity(
+    // MOST IMPORTANT ATTRIBUTES
+
+    @PrimaryKey
+    @ColumnInfo(name = "internalId")
+    var internalId: String,
+
+    // Defines to which talk app account this conversation belongs to
+    @ColumnInfo(name = "accountId") var accountId: Long,
+
+    // We don't use token as primary key as we have to manage multiple talk app accounts on
+    // the phone, thus multiple accounts can have the same conversation in their list. That's why the servers
+    // conversation token is not suitable as primary key on the phone. Also the conversation attributes such as
+    // "unread message" etc only match a specific account.
+    // If multiple talk app accounts have the same conversation, it is stored as another dataset, which is
+    // exactly what we want for this case.
+    @ColumnInfo(name = "token") var token: String,
+
+    @ColumnInfo(name = "displayName") var displayName: String,
+
+    // OTHER ATTRIBUTES IN ALPHABETICAL ORDER
+    @ColumnInfo(name = "actorId") var actorId: String,
+    @ColumnInfo(name = "actorType") var actorType: String,
+    @ColumnInfo(name = "avatarVersion") var avatarVersion: String,
+    @ColumnInfo(name = "callFlag") var callFlag: Int = 0,
+    @ColumnInfo(name = "callRecording") var callRecording: Int = 0,
+    @ColumnInfo(name = "callStartTime") var callStartTime: Long = 0,
+    @ColumnInfo(name = "canDeleteConversation") var canDeleteConversation: Boolean,
+    @ColumnInfo(name = "canLeaveConversation") var canLeaveConversation: Boolean,
+    @ColumnInfo(name = "canStartCall") var canStartCall: Boolean = false,
+    @ColumnInfo(name = "description") var description: String,
+    @ColumnInfo(name = "hasCall") var hasCall: Boolean = false,
+    @ColumnInfo(name = "hasPassword") var hasPassword: Boolean = false,
+    @ColumnInfo(name = "isCustomAvatar") var hasCustomAvatar: Boolean,
+    @ColumnInfo(name = "isFavorite") var favorite: Boolean = false,
+    @ColumnInfo(name = "lastActivity") var lastActivity: Long = 0,
+    @ColumnInfo(name = "lastCommonReadMessage") var lastCommonReadMessage: Int = 0,
+    @ColumnInfo(name = "lastMessage") var lastMessage: String? = null,
+    @ColumnInfo(name = "lastPing") var lastPing: Long = 0,
+    @ColumnInfo(name = "lastReadMessage") var lastReadMessage: Int = 0,
+    @ColumnInfo(name = "lobbyState") var lobbyState: ConversationEnums.LobbyState,
+    @ColumnInfo(name = "lobbyTimer") var lobbyTimer: Long = 0,
+    @ColumnInfo(name = "messageExpiration") var messageExpiration: Int = 0,
+    @ColumnInfo(name = "name") var name: String,
+    @ColumnInfo(name = "notificationCalls") var notificationCalls: Int = 0,
+    @ColumnInfo(name = "notificationLevel") var notificationLevel: ConversationEnums.NotificationLevel,
+    @ColumnInfo(name = "objectType") var objectType: ConversationEnums.ObjectType,
+    @ColumnInfo(name = "participantType") var participantType: Participant.ParticipantType,
+    @ColumnInfo(name = "permissions") var permissions: Int = 0,
+    @ColumnInfo(name = "readOnly") var conversationReadOnlyState: ConversationEnums.ConversationReadOnlyState,
+    @ColumnInfo(name = "recordingConsent") var recordingConsentRequired: Int = 0,
+    @ColumnInfo(name = "remoteServer") var remoteServer: String? = null,
+    @ColumnInfo(name = "remoteToken") var remoteToken: String? = null,
+    @ColumnInfo(name = "sessionId") var sessionId: String,
+    @ColumnInfo(name = "status") var status: String? = null,
+    @ColumnInfo(name = "statusClearAt") var statusClearAt: Long? = 0,
+    @ColumnInfo(name = "statusIcon") var statusIcon: String? = null,
+    @ColumnInfo(name = "statusMessage") var statusMessage: String? = null,
+    @ColumnInfo(name = "type") var type: ConversationEnums.ConversationType,
+    @ColumnInfo(name = "unreadMention") var unreadMention: Boolean = false,
+    @ColumnInfo(name = "unreadMentionDirect") var unreadMentionDirect: Boolean,
+    @ColumnInfo(name = "unreadMessages") var unreadMessages: Int = 0,
+    // missing/not needed: attendeeId
+    // missing/not needed: attendeePin
+    // missing/not needed: attendeePermissions
+    // missing/not needed: callPermissions
+    // missing/not needed: defaultPermissions
+    // missing/not needed: participantInCall
+    // missing/not needed: participantFlags
+    // missing/not needed: listable
+    // missing/not needed: count
+    // missing/not needed: numGuests
+    // missing/not needed: sipEnabled
+    // missing/not needed: canEnableSIP
+    // missing/not needed: objectId
+    // missing/not needed: breakoutRoomMode
+    // missing/not needed: breakoutRoomStatus
+)

+ 17 - 0
app/src/main/java/com/nextcloud/talk/data/network/NetworkMonitor.kt

@@ -0,0 +1,17 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.network
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Utility for reporting app connectivity status.
+ */
+interface NetworkMonitor {
+    val isOnline: Flow<Boolean>
+}

+ 83 - 0
app/src/main/java/com/nextcloud/talk/data/network/NetworkMonitorImpl.kt

@@ -0,0 +1,83 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.NetworkRequest.Builder
+import androidx.core.content.getSystemService
+import androidx.core.os.trace
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flowOn
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NetworkMonitorImpl @Inject constructor(
+    private val context: Context
+) : NetworkMonitor {
+    override val isOnline: Flow<Boolean> = callbackFlow {
+        trace("NetworkMonitorImpl.callbackFlow") {
+            val connectivityManager = context.getSystemService<ConnectivityManager>()
+            if (connectivityManager == null) {
+                channel.trySend(false)
+                channel.close()
+                return@callbackFlow
+            }
+
+            /**
+             * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest],
+             * not just the active network. So we can simply track the presence (or absence) of such [Network].
+             */
+            val callback = object : ConnectivityManager.NetworkCallback() {
+
+                private val networks = mutableSetOf<Network>()
+
+                override fun onAvailable(network: Network) {
+                    networks += network
+                    channel.trySend(true)
+                }
+
+                override fun onLost(network: Network) {
+                    networks -= network
+                    channel.trySend(networks.isNotEmpty())
+                }
+            }
+
+            trace("NetworkMonitorImpl.registerNetworkCallback") {
+                val request = Builder()
+                    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                    .build()
+                connectivityManager.registerNetworkCallback(request, callback)
+            }
+
+            /**
+             * Sends the latest connectivity status to the underlying channel.
+             */
+            channel.trySend(connectivityManager.isCurrentlyConnected())
+
+            awaitClose {
+                connectivityManager.unregisterNetworkCallback(callback)
+            }
+        }
+    }
+        .flowOn(Dispatchers.IO)
+        .conflate()
+
+    private fun ConnectivityManager.isCurrentlyConnected() =
+        activeNetwork
+            ?.let(::getNetworkCapabilities)
+            ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false
+}

+ 124 - 0
app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt

@@ -33,6 +33,13 @@ object Migrations {
         }
     }
 
+    val MIGRATION_10_11 = object : Migration(10, 11) {
+        override fun migrate(db: SupportSQLiteDatabase) {
+            Log.i("Migrations", "Migrating 10 to 11")
+            migrateToOfflineSupport(db)
+        }
+    }
+
     fun migrateToRoom(db: SupportSQLiteDatabase) {
         db.execSQL(
             "CREATE TABLE User_new (" +
@@ -51,6 +58,7 @@ object Migrations {
                 "PRIMARY KEY(id)" +
                 ")"
         )
+
         db.execSQL(
             "CREATE TABLE ArbitraryStorage_new (" +
                 "accountIdentifier INTEGER NOT NULL, " +
@@ -110,4 +118,120 @@ object Migrations {
         // Change the table name to the correct one
         db.execSQL("ALTER TABLE ArbitraryStorage_dualPK RENAME TO ArbitraryStorage")
     }
+
+    fun migrateToOfflineSupport(db: SupportSQLiteDatabase) {
+        db.execSQL(
+            "CREATE TABLE IF NOT EXISTS Conversations (" +
+                "`internalId` TEXT NOT NULL, " +
+                "`accountId` INTEGER NOT NULL, " +
+                "`token` TEXT NOT NULL, " +
+                "`displayName` TEXT NOT NULL, " +
+                "`actorId` TEXT NOT NULL, " +
+                "`actorType` TEXT NOT NULL, " +
+                "`avatarVersion` TEXT NOT NULL, " +
+                "`callFlag` INTEGER NOT NULL, " +
+                "`callRecording` INTEGER NOT NULL, " +
+                "`callStartTime` INTEGER NOT NULL, " +
+                "`canDeleteConversation` INTEGER NOT NULL, " +
+                "`canLeaveConversation` INTEGER NOT NULL, " +
+                "`canStartCall` INTEGER NOT NULL, " +
+                "`description` TEXT NOT NULL, " +
+                "`hasCall` INTEGER NOT NULL, " +
+                "`hasPassword` INTEGER NOT NULL, " +
+                "`isCustomAvatar` INTEGER NOT NULL, " +
+                "`isFavorite` INTEGER NOT NULL, " +
+                "`lastActivity` INTEGER NOT NULL, " +
+                "`lastCommonReadMessage` INTEGER NOT NULL, " +
+                "`lastMessage` TEXT, " +
+                "`lastPing` INTEGER NOT NULL, " +
+                "`lastReadMessage` INTEGER NOT NULL, " +
+                "`lobbyState` TEXT NOT NULL, " +
+                "`lobbyTimer` INTEGER NOT NULL, " +
+                "`messageExpiration` INTEGER NOT NULL, " +
+                "`name` TEXT NOT NULL, " +
+                "`notificationCalls` INTEGER NOT NULL, " +
+                "`notificationLevel` TEXT NOT NULL, " +
+                "`objectType` TEXT NOT NULL, " +
+                "`participantType` TEXT NOT NULL, " +
+                "`permissions` INTEGER NOT NULL, " +
+                "`readOnly` TEXT NOT NULL, " +
+                "`recordingConsent` INTEGER NOT NULL, " +
+                "`remoteServer` TEXT, " +
+                "`remoteToken` TEXT, " +
+                "`sessionId` TEXT NOT NULL, " +
+                "`status` TEXT, " +
+                "`statusClearAt` INTEGER, " +
+                "`statusIcon` TEXT, " +
+                "`statusMessage` TEXT, " +
+                "`type` TEXT NOT NULL, " +
+                "`unreadMention` INTEGER NOT NULL, " +
+                "`unreadMentionDirect` INTEGER NOT NULL, " +
+                "`unreadMessages` INTEGER NOT NULL, " +
+                "PRIMARY KEY(`internalId`), " +
+                "FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) " +
+                "ON UPDATE CASCADE ON DELETE CASCADE " +
+                ")"
+        )
+
+        db.execSQL(
+            "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `Conversations` (`accountId`)"
+        )
+
+        db.execSQL(
+            "CREATE TABLE IF NOT EXISTS ChatMessages (" +
+                "`internalId` TEXT NOT NULL, " +
+                "`accountId` INTEGER NOT NULL, " +
+                "`token` TEXT NOT NULL, " +
+                "`id` INTEGER NOT NULL, " +
+                "`internalConversationId` TEXT NOT NULL, " +
+                "`actorDisplayName` TEXT NOT NULL, " +
+                "`message` TEXT NOT NULL, " +
+                "`actorId` TEXT NOT NULL, " +
+                "`actorType` TEXT NOT NULL, " +
+                "`deleted` INTEGER NOT NULL, " +
+                "`expirationTimestamp` INTEGER NOT NULL, " +
+                "`isReplyable` INTEGER NOT NULL, " +
+                "`lastEditActorDisplayName` TEXT, " +
+                "`lastEditActorId` TEXT, " +
+                "`lastEditActorType` TEXT, " +
+                "`lastEditTimestamp` INTEGER, " +
+                "`markdown` INTEGER, " +
+                "`messageParameters` TEXT, " +
+                "`messageType` TEXT NOT NULL, " +
+                "`parent` INTEGER, " +
+                "`reactions` TEXT, " +
+                "`reactionsSelf` TEXT, " +
+                "`systemMessage` TEXT NOT NULL, " +
+                "`timestamp` INTEGER NOT NULL, " +
+                "PRIMARY KEY(`internalId`), " +
+                "FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) " +
+                "ON UPDATE CASCADE ON DELETE CASCADE " +
+                ")"
+        )
+
+        db.execSQL(
+            "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `ChatMessages` (`internalId`)"
+        )
+
+        db.execSQL(
+            "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `ChatMessages` (`internalConversationId`)"
+        )
+
+        db.execSQL(
+            "CREATE TABLE IF NOT EXISTS ChatBlocks (" +
+                "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+                "`internalConversationId` TEXT NOT NULL, " +
+                "`accountId` INTEGER, `token` TEXT, " +
+                "`oldestMessageId` INTEGER NOT NULL, " +
+                "`newestMessageId` INTEGER NOT NULL, " +
+                "`hasHistory` INTEGER NOT NULL, " +
+                "FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) " +
+                "ON UPDATE CASCADE ON DELETE CASCADE " +
+                ")"
+        )
+
+        db.execSQL(
+            "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `ChatBlocks` (`internalConversationId`)"
+        )
+    }
 }

+ 31 - 7
app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt

@@ -1,7 +1,7 @@
 /*
  * Nextcloud Talk - Android Client
  *
- * SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-FileCopyrightText: 2023-2024 Marcel Hibbe <dev@mhibbe.de>
  * SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
  * SPDX-FileCopyrightText: 2017-2020 Mario Danic <mario@lovelyhq.com>
  * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,15 +10,24 @@ package com.nextcloud.talk.data.source.local
 
 import android.content.Context
 import android.util.Log
+import androidx.room.AutoMigration
 import androidx.room.Database
 import androidx.room.Room
 import androidx.room.RoomDatabase
 import androidx.room.TypeConverters
 import androidx.sqlite.db.SupportSQLiteDatabase
 import com.nextcloud.talk.R
+import com.nextcloud.talk.data.database.dao.ChatBlocksDao
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.dao.ConversationsDao
+import com.nextcloud.talk.data.database.model.ChatBlockEntity
+import com.nextcloud.talk.data.database.model.ChatMessageEntity
+import com.nextcloud.talk.data.database.model.ConversationEntity
+import com.nextcloud.talk.data.source.local.converters.ArrayListConverter
 import com.nextcloud.talk.data.source.local.converters.CapabilitiesConverter
 import com.nextcloud.talk.data.source.local.converters.ExternalSignalingServerConverter
 import com.nextcloud.talk.data.source.local.converters.HashMapHashMapConverter
+import com.nextcloud.talk.data.source.local.converters.LinkedHashMapConverter
 import com.nextcloud.talk.data.source.local.converters.PushConfigurationConverter
 import com.nextcloud.talk.data.source.local.converters.ServerVersionConverter
 import com.nextcloud.talk.data.source.local.converters.SignalingSettingsConverter
@@ -31,13 +40,18 @@ import net.sqlcipher.database.SQLiteDatabase
 import net.sqlcipher.database.SQLiteDatabaseHook
 import net.sqlcipher.database.SupportFactory
 import java.util.Locale
-import androidx.room.AutoMigration
 
 @Database(
-    entities = [UserEntity::class, ArbitraryStorageEntity::class],
-    version = 10,
+    entities = [
+        UserEntity::class,
+        ArbitraryStorageEntity::class,
+        ConversationEntity::class,
+        ChatMessageEntity::class,
+        ChatBlockEntity::class
+    ],
+    version = 11,
     autoMigrations = [
-        AutoMigration(from = 9, to = 10)
+        AutoMigration(from = 9, to = 10),
     ],
     exportSchema = true
 )
@@ -47,11 +61,16 @@ import androidx.room.AutoMigration
     ServerVersionConverter::class,
     ExternalSignalingServerConverter::class,
     SignalingSettingsConverter::class,
-    HashMapHashMapConverter::class
+    HashMapHashMapConverter::class,
+    LinkedHashMapConverter::class,
+    ArrayListConverter::class
 )
 abstract class TalkDatabase : RoomDatabase() {
 
     abstract fun usersDao(): UsersDao
+    abstract fun conversationsDao(): ConversationsDao
+    abstract fun chatMessagesDao(): ChatMessagesDao
+    abstract fun chatBlocksDao(): ChatBlocksDao
     abstract fun arbitraryStoragesDao(): ArbitraryStoragesDao
 
     companion object {
@@ -90,7 +109,12 @@ abstract class TalkDatabase : RoomDatabase() {
                 .databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName)
                 // comment out openHelperFactory to view the database entries in Android Studio for debugging
                 .openHelperFactory(factory)
-                .addMigrations(Migrations.MIGRATION_6_8, Migrations.MIGRATION_7_8, Migrations.MIGRATION_8_9)
+                .addMigrations(
+                    Migrations.MIGRATION_6_8,
+                    Migrations.MIGRATION_7_8,
+                    Migrations.MIGRATION_8_9,
+                    Migrations.MIGRATION_10_11
+                )
                 .allowMainThreadQueries()
                 .addCallback(
                     object : RoomDatabase.Callback() {

+ 38 - 0
app/src/main/java/com/nextcloud/talk/data/source/local/converters/ArrayListConverter.kt

@@ -0,0 +1,38 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.source.local.converters
+
+import android.util.Log
+import androidx.room.TypeConverter
+import com.bluelinelabs.logansquare.LoganSquare
+
+class ArrayListConverter {
+
+    @TypeConverter
+    fun arrayListToString(list: ArrayList<String>?): String? {
+        return if (list == null) {
+            null
+        } else {
+            return try {
+                LoganSquare.serialize(list)
+            } catch (e: Exception) {
+                Log.e("ArrayListConverter", "Error parsing array list $list to String $e")
+                ""
+            }
+        }
+    }
+
+    @TypeConverter
+    fun stringToArrayList(value: String?): ArrayList<String>? {
+        if (value.isNullOrEmpty()) {
+            return null
+        }
+
+        return LoganSquare.parseList(value, List::class.java) as ArrayList<String>?
+    }
+}

+ 3 - 3
app/src/main/java/com/nextcloud/talk/data/source/local/converters/HashMapHashMapConverter.kt

@@ -12,7 +12,7 @@ import com.bluelinelabs.logansquare.LoganSquare
 
 class HashMapHashMapConverter {
     @TypeConverter
-    fun fromDoubleHashMapToString(map: HashMap<String, HashMap<String, String>>?): String? {
+    fun fromDoubleHashMapToString(map: HashMap<String?, HashMap<String?, String?>>?): String? {
         return if (map == null) {
             LoganSquare.serialize(hashMapOf<String, HashMap<String, String>>())
         } else {
@@ -21,11 +21,11 @@ class HashMapHashMapConverter {
     }
 
     @TypeConverter
-    fun fromStringToDoubleHashMap(value: String?): HashMap<String, HashMap<String, String>>? {
+    fun fromStringToDoubleHashMap(value: String?): HashMap<String?, HashMap<String?, String?>>? {
         if (value.isNullOrEmpty()) {
             return hashMapOf()
         }
 
-        return LoganSquare.parseMap(value, HashMap::class.java) as HashMap<String, HashMap<String, String>>?
+        return LoganSquare.parseMap(value, HashMap::class.java) as HashMap<String?, HashMap<String?, String?>>?
     }
 }

+ 59 - 0
app/src/main/java/com/nextcloud/talk/data/source/local/converters/LinkedHashMapConverter.kt

@@ -0,0 +1,59 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.source.local.converters
+
+import android.util.Log
+import androidx.room.TypeConverter
+import com.fasterxml.jackson.core.JsonFactory
+import java.io.IOException
+
+class LinkedHashMapConverter {
+
+    private val converter = LinkedHashMapStringIntConverter()
+    private val jsonFactory = JsonFactory()
+
+    @TypeConverter
+    fun stringToLinkedHashMap(value: String?): LinkedHashMap<String, Int> {
+        if (value.isNullOrEmpty() || value == "{}") {
+            return linkedMapOf()
+        }
+        // "{"👍":1,"👎":1,"😃":1,"😯":1}" // pretend this is value
+        return try {
+            val map = linkedMapOf<String, Int>()
+            val trimmed = value.replace("{", "").replace("}", "")
+            // "👍":1,"👎":1,"😃":1,"😯":1
+            val mapList = trimmed.split(",")
+            // ["👍":1]["👎":1]["😃":1]["😯":1]
+            for (mapStr in mapList) {
+                val emojiMapList = mapStr.split(":")
+                val emoji = emojiMapList[0].replace("\"", "") // removes double quotes
+                val count = emojiMapList[1].toInt()
+                map[emoji] = count
+            }
+            // [👍:1],[👎:1],[😃:1],[😯:1]
+            return map
+        } catch (e: IOException) {
+            Log.e("LinkedHashMapConverter", "Error parsing string: $value to linkedHashMap $e")
+            linkedMapOf()
+        }
+    }
+
+    @TypeConverter
+    fun linkedHashMapToString(map: LinkedHashMap<String, Int>?): String {
+        return try {
+            val stringWriter = java.io.StringWriter()
+            jsonFactory.createGenerator(stringWriter).use { generator ->
+                converter.serialize(map ?: linkedMapOf(), null, false, generator)
+            }
+            stringWriter.toString()
+        } catch (e: IOException) {
+            // e.printStackTrace()
+            ""
+        }
+    }
+}

+ 50 - 0
app/src/main/java/com/nextcloud/talk/data/source/local/converters/LinkedHashMapStringIntConverter.kt

@@ -0,0 +1,50 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.data.source.local.converters
+
+import com.bluelinelabs.logansquare.typeconverters.TypeConverter
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.JsonGenerator
+import java.io.IOException
+
+class LinkedHashMapStringIntConverter : TypeConverter<LinkedHashMap<String, Int>> {
+
+    @Throws(IOException::class)
+    override fun parse(jsonParser: JsonParser?): LinkedHashMap<String, Int> {
+        val map: LinkedHashMap<String, Int> = linkedMapOf()
+        jsonParser?.apply {
+            while (nextToken() != null) {
+                val key = text
+                nextToken()
+                val value = intValue
+                map[key] = value
+            }
+        }
+        return map
+    }
+
+    @Throws(IOException::class)
+    override fun serialize(
+        `object`: LinkedHashMap<String, Int>?,
+        fieldName: String?,
+        writeFieldNameForObject: Boolean,
+        jsonGenerator: JsonGenerator?
+    ) {
+        jsonGenerator?.apply {
+            if (fieldName != null) {
+                writeFieldName(fieldName)
+            }
+            writeStartObject()
+            `object`?.forEach { (key, value) ->
+                writeFieldName(key)
+                writeNumber(value)
+            }
+            writeEndObject()
+        }
+    }
+}

+ 7 - 7
app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt

@@ -29,9 +29,9 @@ import coil.transform.RoundedCornersTransformation
 import com.nextcloud.talk.R
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.domain.ConversationType
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.ui.theme.ViewThemeUtils
 import com.nextcloud.talk.utils.ApiUtils
 import com.nextcloud.talk.utils.DisplayUtils
@@ -49,7 +49,7 @@ fun ImageView.loadConversationAvatar(
 ): io.reactivex.disposables.Disposable {
     return loadConversationAvatar(
         user,
-        ConversationModel.mapToConversationModel(conversation),
+        ConversationModel.mapToConversationModel(conversation, user),
         ignoreCache,
         viewThemeUtils
     )
@@ -72,10 +72,10 @@ fun ImageView.loadConversationAvatar(
 
     if (conversation.avatarVersion.isNullOrEmpty() && viewThemeUtils != null) {
         when (conversation.type) {
-            ConversationType.ROOM_GROUP_CALL ->
+            ConversationEnums.ConversationType.ROOM_GROUP_CALL ->
                 return loadDefaultGroupCallAvatar(viewThemeUtils)
 
-            ConversationType.ROOM_PUBLIC_CALL ->
+            ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
                 return loadDefaultPublicCallAvatar(viewThemeUtils)
 
             else -> {}
@@ -86,10 +86,10 @@ fun ImageView.loadConversationAvatar(
     // when no own images are set. (although these default avatars can not be themed for the android app..)
     val errorPlaceholder =
         when (conversation.type) {
-            ConversationType.ROOM_GROUP_CALL ->
+            ConversationEnums.ConversationType.ROOM_GROUP_CALL ->
                 ContextCompat.getDrawable(context, R.drawable.ic_circular_group)
 
-            ConversationType.ROOM_PUBLIC_CALL ->
+            ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
                 ContextCompat.getDrawable(context, R.drawable.ic_circular_link)
 
             else -> ContextCompat.getDrawable(context, R.drawable.account_circle_96dp)

+ 13 - 8
app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java

@@ -16,6 +16,9 @@ import com.nextcloud.talk.R;
 import com.nextcloud.talk.api.NcApi;
 import com.nextcloud.talk.application.NextcloudTalkApplication;
 import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager;
+import com.nextcloud.talk.data.database.dao.ChatBlocksDao;
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao;
+import com.nextcloud.talk.data.database.dao.ConversationsDao;
 import com.nextcloud.talk.data.user.model.User;
 import com.nextcloud.talk.models.json.generic.GenericMeta;
 import com.nextcloud.talk.models.json.generic.GenericOverall;
@@ -46,17 +49,19 @@ import retrofit2.Retrofit;
 public class AccountRemovalWorker extends Worker {
     public static final String TAG = "AccountRemovalWorker";
 
-    @Inject
-    UserManager userManager;
+    @Inject UserManager userManager;
 
-    @Inject
-    ArbitraryStorageManager arbitraryStorageManager;
+    @Inject ArbitraryStorageManager arbitraryStorageManager;
 
-    @Inject
-    Retrofit retrofit;
+    @Inject Retrofit retrofit;
 
-    @Inject
-    OkHttpClient okHttpClient;
+    @Inject OkHttpClient okHttpClient;
+
+    @Inject ChatMessagesDao chatMessagesDao;
+
+    @Inject ConversationsDao conversationsDao;
+
+    @Inject ChatBlocksDao chatBlocksDao;
 
     NcApi ncApi;
 

+ 5 - 5
app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt

@@ -49,11 +49,11 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
 import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
 import com.nextcloud.talk.callnotification.CallNotificationActivity
-import com.nextcloud.talk.chat.data.ChatRepository
+import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
 import com.nextcloud.talk.models.SignatureVerification
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.domain.ConversationType
 import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 import com.nextcloud.talk.models.json.notifications.NotificationOverall
 import com.nextcloud.talk.models.json.participants.Participant
 import com.nextcloud.talk.models.json.participants.ParticipantsOverall
@@ -125,7 +125,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
     @Inject
     var retrofit: Retrofit? = null
 
-    var chatRepository: ChatRepository? = null
+    var chatNetworkDataSource: ChatNetworkDataSource? = null
         @Inject set
 
     @Inject
@@ -231,7 +231,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
             bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
             bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true)
 
-            val isOneToOneCall = conversation.type === ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+            val isOneToOneCall = conversation.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
 
             bundle.putBoolean(KEY_ROOM_ONE_TO_ONE, isOneToOneCall) // ggf change in Activity? not necessary????
             bundle.putString(BundleKeys.KEY_CONVERSATION_NAME, conversation.name)
@@ -300,7 +300,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
             checkIfCallIsActive(signatureVerification, conversation)
         }
 
-        chatRepository?.getRoom(userBeingCalled, roomToken = pushMessage.id!!)
+        chatNetworkDataSource?.getRoom(userBeingCalled, roomToken = pushMessage.id!!)
             ?.subscribeOn(Schedulers.io())
             ?.observeOn(AndroidSchedulers.mainThread())
             ?.subscribe(object : Observer<ConversationModel> {

+ 108 - 96
app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt

@@ -7,40 +7,46 @@
  */
 package com.nextcloud.talk.models.domain
 
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.json.chat.ChatMessageJson
 import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.models.json.participants.Participant
 
 class ConversationModel(
-    var roomId: String? = null,
-    var token: String? = null,
-    var name: String? = null,
-    var displayName: String? = null,
-    var description: String? = null,
-    var type: ConversationType? = null,
+    var internalId: String,
+    var accountId: Long,
+    // var roomId: String? = null,
+    var token: String,
+    var name: String,
+    var displayName: String,
+    var description: String,
+    var type: ConversationEnums.ConversationType,
     var lastPing: Long = 0,
-    var participantType: ParticipantType? = null,
+    var participantType: Participant.ParticipantType,
     var hasPassword: Boolean = false,
-    var sessionId: String? = null,
-    var actorId: String? = null,
-    var actorType: String? = null,
-    var password: String? = null,
+    var sessionId: String,
+    var actorId: String,
+    var actorType: String,
     var favorite: Boolean = false,
     var lastActivity: Long = 0,
     var unreadMessages: Int = 0,
     var unreadMention: Boolean = false,
-    // var lastMessage: .....? = null,
-    var objectType: ObjectType? = null,
-    var notificationLevel: NotificationLevel? = null,
-    var conversationReadOnlyState: ConversationReadOnlyState? = null,
-    var lobbyState: LobbyState? = null,
-    var lobbyTimer: Long? = null,
+    var lastMessage: ChatMessageJson? = null,
+    var objectType: ConversationEnums.ObjectType,
+    var notificationLevel: ConversationEnums.NotificationLevel,
+    var conversationReadOnlyState: ConversationEnums.ConversationReadOnlyState,
+    var lobbyState: ConversationEnums.LobbyState,
+    var lobbyTimer: Long,
     var lastReadMessage: Int = 0,
+    var lastCommonReadMessage: Int = 0,
     var hasCall: Boolean = false,
     var callFlag: Int = 0,
     var canStartCall: Boolean = false,
-    var canLeaveConversation: Boolean? = null,
-    var canDeleteConversation: Boolean? = null,
-    var unreadMentionDirect: Boolean? = null,
-    var notificationCalls: Int? = null,
+    var canLeaveConversation: Boolean,
+    var canDeleteConversation: Boolean,
+    var unreadMentionDirect: Boolean,
+    var notificationCalls: Int,
     var permissions: Int = 0,
     var messageExpiration: Int = 0,
     var status: String? = null,
@@ -48,56 +54,62 @@ class ConversationModel(
     var statusMessage: String? = null,
     var statusClearAt: Long? = 0,
     var callRecording: Int = 0,
-    var avatarVersion: String? = null,
-    var hasCustomAvatar: Boolean? = null,
-    var callStartTime: Long? = null,
+    var avatarVersion: String,
+    var hasCustomAvatar: Boolean,
+    var callStartTime: Long,
     var recordingConsentRequired: Int = 0,
     var remoteServer: String? = null,
-    var remoteToken: String? = null
+    var remoteToken: String? = null,
+
+    // attributes that don't come from API. This should be changed?!
+    var password: String? = null,
 ) {
 
     companion object {
-        fun mapToConversationModel(conversation: Conversation): ConversationModel {
+        fun mapToConversationModel(conversation: Conversation, user: User): ConversationModel {
             return ConversationModel(
-                roomId = conversation.roomId,
-                token = conversation.token,
-                name = conversation.name,
-                displayName = conversation.displayName,
-                description = conversation.description,
-                type = conversation.type?.let { ConversationType.valueOf(it.name) },
+                internalId = user.id!!.toString() + "@" + conversation.token,
+                accountId = user.id!!,
+                // roomId = conversation.roomId,
+                token = conversation.token!!,
+                name = conversation.name!!,
+                displayName = conversation.displayName!!,
+                description = conversation.description!!,
+                type = conversation.type.let { ConversationEnums.ConversationType.valueOf(it!!.name) },
                 lastPing = conversation.lastPing,
-                participantType = conversation.participantType?.let { ParticipantType.valueOf(it.name) },
+                participantType = conversation.participantType.let { Participant.ParticipantType.valueOf(it!!.name) },
                 hasPassword = conversation.hasPassword,
-                sessionId = conversation.sessionId,
-                actorId = conversation.actorId,
-                actorType = conversation.actorType,
+                sessionId = conversation.sessionId!!,
+                actorId = conversation.actorId!!,
+                actorType = conversation.actorType!!,
                 password = conversation.password,
                 favorite = conversation.favorite,
                 lastActivity = conversation.lastActivity,
                 unreadMessages = conversation.unreadMessages,
                 unreadMention = conversation.unreadMention,
-                // lastMessage = conversation.lastMessage,     to do...
-                objectType = conversation.objectType?.let { ObjectType.valueOf(it.name) },
-                notificationLevel = conversation.notificationLevel?.let {
-                    NotificationLevel.valueOf(
-                        it.name
+                lastMessage = conversation.lastMessage,
+                objectType = conversation.objectType.let { ConversationEnums.ObjectType.valueOf(it!!.name) },
+                notificationLevel = conversation.notificationLevel.let {
+                    ConversationEnums.NotificationLevel.valueOf(
+                        it!!.name
                     )
                 },
-                conversationReadOnlyState = conversation.conversationReadOnlyState?.let {
-                    ConversationReadOnlyState.valueOf(
-                        it.name
+                conversationReadOnlyState = conversation.conversationReadOnlyState.let {
+                    ConversationEnums.ConversationReadOnlyState.valueOf(
+                        it!!.name
                     )
                 },
-                lobbyState = conversation.lobbyState?.let { LobbyState.valueOf(it.name) },
-                lobbyTimer = conversation.lobbyTimer,
+                lobbyState = conversation.lobbyState.let { ConversationEnums.LobbyState.valueOf(it!!.name) },
+                lobbyTimer = conversation.lobbyTimer!!,
                 lastReadMessage = conversation.lastReadMessage,
+                lastCommonReadMessage = conversation.lastCommonReadMessage,
                 hasCall = conversation.hasCall,
                 callFlag = conversation.callFlag,
                 canStartCall = conversation.canStartCall,
-                canLeaveConversation = conversation.canLeaveConversation,
-                canDeleteConversation = conversation.canDeleteConversation,
-                unreadMentionDirect = conversation.unreadMentionDirect,
-                notificationCalls = conversation.notificationCalls,
+                canLeaveConversation = conversation.canLeaveConversation!!,
+                canDeleteConversation = conversation.canDeleteConversation!!,
+                unreadMentionDirect = conversation.unreadMentionDirect!!,
+                notificationCalls = conversation.notificationCalls!!,
                 permissions = conversation.permissions,
                 messageExpiration = conversation.messageExpiration,
                 status = conversation.status,
@@ -105,9 +117,9 @@ class ConversationModel(
                 statusMessage = conversation.statusMessage,
                 statusClearAt = conversation.statusClearAt,
                 callRecording = conversation.callRecording,
-                avatarVersion = conversation.avatarVersion,
-                hasCustomAvatar = conversation.hasCustomAvatar,
-                callStartTime = conversation.callStartTime,
+                avatarVersion = conversation.avatarVersion!!,
+                hasCustomAvatar = conversation.hasCustomAvatar!!,
+                callStartTime = conversation.callStartTime!!,
                 recordingConsentRequired = conversation.recordingConsentRequired,
                 remoteServer = conversation.remoteServer,
                 remoteToken = conversation.remoteToken
@@ -116,46 +128,46 @@ class ConversationModel(
     }
 }
 
-enum class ConversationType {
-    DUMMY,
-    ROOM_TYPE_ONE_TO_ONE_CALL,
-    ROOM_GROUP_CALL,
-    ROOM_PUBLIC_CALL,
-    ROOM_SYSTEM,
-    FORMER_ONE_TO_ONE,
-    NOTE_TO_SELF
-}
-
-enum class ParticipantType {
-    DUMMY,
-    OWNER,
-    MODERATOR,
-    USER,
-    GUEST,
-    USER_FOLLOWING_LINK,
-    GUEST_MODERATOR
-}
-
-enum class ObjectType {
-    DEFAULT,
-    SHARE_PASSWORD,
-    FILE,
-    ROOM
-}
-
-enum class NotificationLevel {
-    DEFAULT,
-    ALWAYS,
-    MENTION,
-    NEVER
-}
-
-enum class ConversationReadOnlyState {
-    CONVERSATION_READ_WRITE,
-    CONVERSATION_READ_ONLY
-}
-
-enum class LobbyState {
-    LOBBY_STATE_ALL_PARTICIPANTS,
-    LOBBY_STATE_MODERATORS_ONLY
-}
+// enum class ConversationType {
+//     DUMMY,
+//     ROOM_TYPE_ONE_TO_ONE_CALL,
+//     ROOM_GROUP_CALL,
+//     ROOM_PUBLIC_CALL,
+//     ROOM_SYSTEM,
+//     FORMER_ONE_TO_ONE,
+//     NOTE_TO_SELF
+// }
+//
+// enum class ParticipantType {
+//     DUMMY,
+//     OWNER,
+//     MODERATOR,
+//     USER,
+//     GUEST,
+//     USER_FOLLOWING_LINK,
+//     GUEST_MODERATOR
+// }
+//
+// enum class ObjectType {
+//     DEFAULT,
+//     SHARE_PASSWORD,
+//     FILE,
+//     ROOM
+// }
+//
+// enum class NotificationLevel {
+//     DEFAULT,
+//     ALWAYS,
+//     MENTION,
+//     NEVER
+// }
+//
+// enum class ConversationReadOnlyState {
+//     CONVERSATION_READ_WRITE,
+//     CONVERSATION_READ_ONLY
+// }
+//
+// enum class LobbyState {
+//     LOBBY_STATE_ALL_PARTICIPANTS,
+//     LOBBY_STATE_MODERATORS_ONLY
+// }

+ 1 - 1
app/src/main/java/com/nextcloud/talk/models/domain/ReactionAddedModel.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.models.domain
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 data class ReactionAddedModel(
     var chatMessage: ChatMessage,

+ 1 - 1
app/src/main/java/com/nextcloud/talk/models/domain/ReactionDeletedModel.kt

@@ -6,7 +6,7 @@
  */
 package com.nextcloud.talk.models.domain
 
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage
 
 data class ReactionDeletedModel(
     var chatMessage: ChatMessage,

+ 13 - 13
app/src/main/java/com/nextcloud/talk/models/domain/converters/DomainEnumNotificationLevelConverter.kt

@@ -9,25 +9,25 @@
 package com.nextcloud.talk.models.domain.converters
 
 import com.bluelinelabs.logansquare.typeconverters.IntBasedTypeConverter
-import com.nextcloud.talk.models.domain.NotificationLevel
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 
-class DomainEnumNotificationLevelConverter : IntBasedTypeConverter<NotificationLevel>() {
-    override fun getFromInt(i: Int): NotificationLevel {
+class DomainEnumNotificationLevelConverter : IntBasedTypeConverter<ConversationEnums.NotificationLevel>() {
+    override fun getFromInt(i: Int): ConversationEnums.NotificationLevel {
         return when (i) {
-            DEFAULT -> NotificationLevel.DEFAULT
-            ALWAYS -> NotificationLevel.ALWAYS
-            MENTION -> NotificationLevel.MENTION
-            NEVER -> NotificationLevel.NEVER
-            else -> NotificationLevel.DEFAULT
+            DEFAULT -> ConversationEnums.NotificationLevel.DEFAULT
+            ALWAYS -> ConversationEnums.NotificationLevel.ALWAYS
+            MENTION -> ConversationEnums.NotificationLevel.MENTION
+            NEVER -> ConversationEnums.NotificationLevel.NEVER
+            else -> ConversationEnums.NotificationLevel.DEFAULT
         }
     }
 
-    override fun convertToInt(`object`: NotificationLevel): Int {
+    override fun convertToInt(`object`: ConversationEnums.NotificationLevel): Int {
         return when (`object`) {
-            NotificationLevel.DEFAULT -> DEFAULT
-            NotificationLevel.ALWAYS -> ALWAYS
-            NotificationLevel.MENTION -> MENTION
-            NotificationLevel.NEVER -> NEVER
+            ConversationEnums.NotificationLevel.DEFAULT -> DEFAULT
+            ConversationEnums.NotificationLevel.ALWAYS -> ALWAYS
+            ConversationEnums.NotificationLevel.MENTION -> MENTION
+            ConversationEnums.NotificationLevel.NEVER -> NEVER
             else -> DEFAULT
         }
     }

+ 46 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt

@@ -0,0 +1,46 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.models.json.chat
+
+import android.os.Parcelable
+import com.bluelinelabs.logansquare.annotation.JsonField
+import com.bluelinelabs.logansquare.annotation.JsonObject
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType
+import com.nextcloud.talk.models.json.converters.EnumSystemMessageTypeConverter
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+@JsonObject
+data class ChatMessageJson(
+    @JsonField(name = ["id"]) var id: Long = 0,
+    @JsonField(name = ["token"]) var token: String? = null,
+    @JsonField(name = ["actorType"]) var actorType: String? = null,
+    @JsonField(name = ["actorId"]) var actorId: String? = null,
+    @JsonField(name = ["actorDisplayName"]) var actorDisplayName: String? = null,
+    @JsonField(name = ["timestamp"]) var timestamp: Long = 0,
+    @JsonField(name = ["message"]) var message: String? = null,
+
+    @JsonField(name = ["messageParameters"])
+    var messageParameters: HashMap<String?, HashMap<String?, String?>>? = null,
+
+    @JsonField(name = ["systemMessage"], typeConverter = EnumSystemMessageTypeConverter::class)
+    var systemMessageType: SystemMessageType? = null,
+
+    @JsonField(name = ["isReplyable"]) var replyable: Boolean = false,
+    @JsonField(name = ["parent"]) var parentMessage: ChatMessageJson? = null,
+    @JsonField(name = ["messageType"]) var messageType: String? = null,
+    @JsonField(name = ["reactions"]) var reactions: LinkedHashMap<String, Int>? = null,
+    @JsonField(name = ["reactionsSelf"]) var reactionsSelf: ArrayList<String>? = null,
+    @JsonField(name = ["expirationTimestamp"]) var expirationTimestamp: Int = 0,
+    @JsonField(name = ["markdown"]) var renderMarkdown: Boolean? = null,
+    @JsonField(name = ["lastEditActorDisplayName"]) var lastEditActorDisplayName: String? = null,
+    @JsonField(name = ["lastEditActorId"]) var lastEditActorId: String? = null,
+    @JsonField(name = ["lastEditActorType"]) var lastEditActorType: String? = null,
+    @JsonField(name = ["lastEditTimestamp"]) var lastEditTimestamp: Long? = 0,
+    @JsonField(name = ["deleted"]) var deleted: Boolean = false,
+) : Parcelable

+ 1 - 1
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.kt

@@ -19,7 +19,7 @@ data class ChatOCS(
     @JsonField(name = ["meta"])
     var meta: GenericMeta?,
     @JsonField(name = ["data"])
-    var data: List<ChatMessage>? = null
+    var data: List<ChatMessageJson>? = null
 ) : Parcelable {
     // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
     constructor() : this(null, null)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCSSingleMessage.kt

@@ -19,7 +19,7 @@ data class ChatOCSSingleMessage(
     @JsonField(name = ["meta"])
     var meta: GenericMeta?,
     @JsonField(name = ["data"])
-    var data: ChatMessage? = null
+    var data: ChatMessageJson? = null
 ) : Parcelable {
     // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
     constructor() : this(null, null)

+ 1 - 2
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.kt

@@ -10,14 +10,13 @@ package com.nextcloud.talk.models.json.chat
 import android.os.Parcelable
 import com.bluelinelabs.logansquare.annotation.JsonField
 import com.bluelinelabs.logansquare.annotation.JsonObject
-import java.util.HashMap
 import kotlinx.parcelize.Parcelize
 
 @Parcelize
 @JsonObject
 data class ChatShareOCS(
     @JsonField(name = ["data"])
-    var data: HashMap<String, ChatMessage>? = null
+    var data: HashMap<String, ChatMessageJson>? = null
 ) : Parcelable {
     // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
     constructor() : this(null)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatUtils.kt

@@ -33,7 +33,7 @@ class ChatUtils {
                         resultMessage?.replace("{$key}", "@" + individualHashMap["name"])
                     } else if (type == "geo-location") {
                         individualHashMap["name"]
-                    } else if (individualHashMap?.containsKey("link") == true) {
+                    } else if (individualHashMap.containsKey("link") == true) {
                         if (type == "file") {
                             resultMessage?.replace("{$key}", individualHashMap["name"].toString())
                         } else {

+ 25 - 55
app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt

@@ -14,7 +14,7 @@ import com.bluelinelabs.logansquare.annotation.JsonField
 import com.bluelinelabs.logansquare.annotation.JsonObject
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.models.domain.ConversationModel
-import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.models.json.chat.ChatMessageJson
 import com.nextcloud.talk.models.json.converters.ConversationObjectTypeConverter
 import com.nextcloud.talk.models.json.converters.EnumLobbyStateConverter
 import com.nextcloud.talk.models.json.converters.EnumNotificationLevelConverter
@@ -28,8 +28,8 @@ import kotlinx.parcelize.Parcelize
 @Parcelize
 @JsonObject
 data class Conversation(
-    @JsonField(name = ["id"])
-    var roomId: String? = null,
+    // @JsonField(name = ["id"])
+    // var roomId: String? = null,
     @JsonField(name = ["token"])
     var token: String? = null,
     @JsonField(name = ["name"])
@@ -39,7 +39,7 @@ data class Conversation(
     @JsonField(name = ["description"])
     var description: String? = null,
     @JsonField(name = ["type"], typeConverter = EnumRoomTypeConverter::class)
-    var type: ConversationType? = null,
+    var type: ConversationEnums.ConversationType? = null,
     @JsonField(name = ["lastPing"])
     var lastPing: Long = 0,
     @JsonField(name = ["participantType"], typeConverter = EnumParticipantTypeConverter::class)
@@ -68,19 +68,19 @@ data class Conversation(
     var unreadMention: Boolean = false,
 
     @JsonField(name = ["lastMessage"])
-    var lastMessage: ChatMessage? = null,
+    var lastMessage: ChatMessageJson? = null,
 
     @JsonField(name = ["objectType"], typeConverter = ConversationObjectTypeConverter::class)
-    var objectType: ObjectType? = null,
+    var objectType: ConversationEnums.ObjectType? = null,
 
     @JsonField(name = ["notificationLevel"], typeConverter = EnumNotificationLevelConverter::class)
-    var notificationLevel: NotificationLevel? = null,
+    var notificationLevel: ConversationEnums.NotificationLevel? = null,
 
     @JsonField(name = ["readOnly"], typeConverter = EnumReadOnlyConversationConverter::class)
-    var conversationReadOnlyState: ConversationReadOnlyState? = null,
+    var conversationReadOnlyState: ConversationEnums.ConversationReadOnlyState? = null,
 
     @JsonField(name = ["lobbyState"], typeConverter = EnumLobbyStateConverter::class)
-    var lobbyState: LobbyState? = null,
+    var lobbyState: ConversationEnums.LobbyState? = null,
 
     @JsonField(name = ["lobbyTimer"])
     var lobbyTimer: Long? = null,
@@ -88,6 +88,9 @@ data class Conversation(
     @JsonField(name = ["lastReadMessage"])
     var lastReadMessage: Int = 0,
 
+    @JsonField(name = ["lastCommonReadMessage"])
+    var lastCommonReadMessage: Int = 0,
+
     @JsonField(name = ["hasCall"])
     var hasCall: Boolean = false,
 
@@ -149,15 +152,12 @@ data class Conversation(
     var remoteServer: String? = null,
 
     @JsonField(name = ["remoteToken"])
-    var remoteToken: String? = null
+    var remoteToken: String? = null,
 
 ) : Parcelable {
-    // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
-    constructor() : this(null, null)
-
     @Deprecated("Use ConversationUtil")
     val isPublic: Boolean
-        get() = ConversationType.ROOM_PUBLIC_CALL == type
+        get() = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL == type
 
     @Deprecated("Use ConversationUtil")
     val isGuest: Boolean
@@ -175,22 +175,27 @@ data class Conversation(
     fun canModerate(conversationUser: User): Boolean {
         return isParticipantOwnerOrModerator &&
             !ConversationUtils.isLockedOneToOne(
-                ConversationModel.mapToConversationModel(this),
+                ConversationModel.mapToConversationModel(this, conversationUser),
                 conversationUser.capabilities?.spreedCapability!!
             ) &&
-            type != ConversationType.FORMER_ONE_TO_ONE &&
-            !ConversationUtils.isNoteToSelfConversation(ConversationModel.mapToConversationModel(this))
+            type != ConversationEnums.ConversationType.FORMER_ONE_TO_ONE &&
+            !ConversationUtils.isNoteToSelfConversation(
+                ConversationModel.mapToConversationModel(this, conversationUser)
+            )
     }
 
     @Deprecated("Use ConversationUtil")
     fun isLobbyViewApplicable(conversationUser: User): Boolean {
         return !canModerate(conversationUser) &&
-            (type == ConversationType.ROOM_GROUP_CALL || type == ConversationType.ROOM_PUBLIC_CALL)
+            (
+                type == ConversationEnums.ConversationType.ROOM_GROUP_CALL ||
+                    type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL
+                )
     }
 
     @Deprecated("Use ConversationUtil")
     fun isNameEditable(conversationUser: User): Boolean {
-        return canModerate(conversationUser) && ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL != type
+        return canModerate(conversationUser) && ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL != type
     }
 
     @Deprecated("Use ConversationUtil")
@@ -216,41 +221,6 @@ data class Conversation(
 
     @Deprecated("Use ConversationUtil")
     fun isNoteToSelfConversation(): Boolean {
-        return type == ConversationType.NOTE_TO_SELF
-    }
-
-    enum class NotificationLevel {
-        DEFAULT,
-        ALWAYS,
-        MENTION,
-        NEVER
-    }
-
-    enum class LobbyState {
-        LOBBY_STATE_ALL_PARTICIPANTS,
-        LOBBY_STATE_MODERATORS_ONLY
-    }
-
-    enum class ConversationReadOnlyState {
-        CONVERSATION_READ_WRITE,
-        CONVERSATION_READ_ONLY
-    }
-
-    @Parcelize
-    enum class ConversationType : Parcelable {
-        DUMMY,
-        ROOM_TYPE_ONE_TO_ONE_CALL,
-        ROOM_GROUP_CALL,
-        ROOM_PUBLIC_CALL,
-        ROOM_SYSTEM,
-        FORMER_ONE_TO_ONE,
-        NOTE_TO_SELF
-    }
-
-    enum class ObjectType {
-        DEFAULT,
-        SHARE_PASSWORD,
-        FILE,
-        ROOM
+        return type == ConversationEnums.ConversationType.NOTE_TO_SELF
     }
 }

+ 48 - 0
app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt

@@ -0,0 +1,48 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.models.json.conversations
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+class ConversationEnums {
+    enum class NotificationLevel {
+        DEFAULT,
+        ALWAYS,
+        MENTION,
+        NEVER
+    }
+
+    enum class LobbyState {
+        LOBBY_STATE_ALL_PARTICIPANTS,
+        LOBBY_STATE_MODERATORS_ONLY
+    }
+
+    enum class ConversationReadOnlyState {
+        CONVERSATION_READ_WRITE,
+        CONVERSATION_READ_ONLY
+    }
+
+    @Parcelize
+    enum class ConversationType : Parcelable {
+        DUMMY,
+        ROOM_TYPE_ONE_TO_ONE_CALL,
+        ROOM_GROUP_CALL,
+        ROOM_PUBLIC_CALL,
+        ROOM_SYSTEM,
+        FORMER_ONE_TO_ONE,
+        NOTE_TO_SELF
+    }
+
+    enum class ObjectType {
+        DEFAULT,
+        SHARE_PASSWORD,
+        FILE,
+        ROOM
+    }
+}

+ 11 - 11
app/src/main/java/com/nextcloud/talk/models/json/converters/ConversationObjectTypeConverter.kt

@@ -7,27 +7,27 @@
 package com.nextcloud.talk.models.json.converters
 
 import com.bluelinelabs.logansquare.typeconverters.StringBasedTypeConverter
-import com.nextcloud.talk.models.json.conversations.Conversation
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
 
-class ConversationObjectTypeConverter : StringBasedTypeConverter<Conversation.ObjectType>() {
-    override fun getFromString(string: String?): Conversation.ObjectType {
+class ConversationObjectTypeConverter : StringBasedTypeConverter<ConversationEnums.ObjectType>() {
+    override fun getFromString(string: String?): ConversationEnums.ObjectType {
         return when (string) {
-            "share:password" -> Conversation.ObjectType.SHARE_PASSWORD
-            "room" -> Conversation.ObjectType.ROOM
-            "file" -> Conversation.ObjectType.FILE
-            else -> Conversation.ObjectType.DEFAULT
+            "share:password" -> ConversationEnums.ObjectType.SHARE_PASSWORD
+            "room" -> ConversationEnums.ObjectType.ROOM
+            "file" -> ConversationEnums.ObjectType.FILE
+            else -> ConversationEnums.ObjectType.DEFAULT
         }
     }
 
-    override fun convertToString(`object`: Conversation.ObjectType?): String {
+    override fun convertToString(`object`: ConversationEnums.ObjectType?): String {
         if (`object` == null) {
             return ""
         }
 
         return when (`object`) {
-            Conversation.ObjectType.SHARE_PASSWORD -> "share:password"
-            Conversation.ObjectType.ROOM -> "room"
-            Conversation.ObjectType.FILE -> "file"
+            ConversationEnums.ObjectType.SHARE_PASSWORD -> "share:password"
+            ConversationEnums.ObjectType.ROOM -> "room"
+            ConversationEnums.ObjectType.FILE -> "file"
             else -> ""
         }
     }

+ 7 - 6
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumLobbyStateConverter.java

@@ -8,22 +8,23 @@ package com.nextcloud.talk.models.json.converters;
 
 import com.bluelinelabs.logansquare.typeconverters.IntBasedTypeConverter;
 import com.nextcloud.talk.models.json.conversations.Conversation;
+import com.nextcloud.talk.models.json.conversations.ConversationEnums;
 
-public class EnumLobbyStateConverter extends IntBasedTypeConverter<Conversation.LobbyState> {
+public class EnumLobbyStateConverter extends IntBasedTypeConverter<ConversationEnums.LobbyState> {
     @Override
-    public Conversation.LobbyState getFromInt(int i) {
+    public ConversationEnums.LobbyState getFromInt(int i) {
         switch (i) {
             case 0:
-                return Conversation.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS;
+                return ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS;
             case 1:
-                return Conversation.LobbyState.LOBBY_STATE_MODERATORS_ONLY;
+                return ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY;
             default:
-                return Conversation.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS;
+                return ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS;
         }
     }
 
     @Override
-    public int convertToInt(Conversation.LobbyState object) {
+    public int convertToInt(ConversationEnums.LobbyState object) {
         switch (object) {
             case LOBBY_STATE_ALL_PARTICIPANTS:
                 return 0;

+ 9 - 8
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumNotificationLevelConverter.java

@@ -8,26 +8,27 @@ package com.nextcloud.talk.models.json.converters;
 
 import com.bluelinelabs.logansquare.typeconverters.IntBasedTypeConverter;
 import com.nextcloud.talk.models.json.conversations.Conversation;
+import com.nextcloud.talk.models.json.conversations.ConversationEnums;
 
-public class EnumNotificationLevelConverter extends IntBasedTypeConverter<Conversation.NotificationLevel> {
+public class EnumNotificationLevelConverter extends IntBasedTypeConverter<ConversationEnums.NotificationLevel> {
     @Override
-    public Conversation.NotificationLevel getFromInt(int i) {
+    public ConversationEnums.NotificationLevel getFromInt(int i) {
         switch (i) {
             case 0:
-                return Conversation.NotificationLevel.DEFAULT;
+                return ConversationEnums.NotificationLevel.DEFAULT;
             case 1:
-                return Conversation.NotificationLevel.ALWAYS;
+                return ConversationEnums.NotificationLevel.ALWAYS;
             case 2:
-                return Conversation.NotificationLevel.MENTION;
+                return ConversationEnums.NotificationLevel.MENTION;
             case 3:
-                return Conversation.NotificationLevel.NEVER;
+                return ConversationEnums.NotificationLevel.NEVER;
             default:
-                return Conversation.NotificationLevel.DEFAULT;
+                return ConversationEnums.NotificationLevel.DEFAULT;
         }
     }
 
     @Override
-    public int convertToInt(Conversation.NotificationLevel object) {
+    public int convertToInt(ConversationEnums.NotificationLevel object) {
         switch (object) {
             case DEFAULT:
                 return 0;

+ 7 - 6
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumReadOnlyConversationConverter.java

@@ -8,22 +8,23 @@ package com.nextcloud.talk.models.json.converters;
 
 import com.bluelinelabs.logansquare.typeconverters.IntBasedTypeConverter;
 import com.nextcloud.talk.models.json.conversations.Conversation;
+import com.nextcloud.talk.models.json.conversations.ConversationEnums;
 
-public class EnumReadOnlyConversationConverter extends IntBasedTypeConverter<Conversation.ConversationReadOnlyState> {
+public class EnumReadOnlyConversationConverter extends IntBasedTypeConverter<ConversationEnums.ConversationReadOnlyState> {
     @Override
-    public Conversation.ConversationReadOnlyState getFromInt(int i) {
+    public ConversationEnums.ConversationReadOnlyState getFromInt(int i) {
         switch (i) {
             case 0:
-                return Conversation.ConversationReadOnlyState.CONVERSATION_READ_WRITE;
+                return ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE;
             case 1:
-                return Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY;
+                return ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY;
             default:
-                return Conversation.ConversationReadOnlyState.CONVERSATION_READ_WRITE;
+                return ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE;
         }
     }
 
     @Override
-    public int convertToInt(Conversation.ConversationReadOnlyState object) {
+    public int convertToInt(ConversationEnums.ConversationReadOnlyState object) {
         switch (object) {
             case CONVERSATION_READ_WRITE:
                 return 0;

+ 11 - 11
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumRoomTypeConverter.java

@@ -7,31 +7,31 @@
 package com.nextcloud.talk.models.json.converters;
 
 import com.bluelinelabs.logansquare.typeconverters.IntBasedTypeConverter;
-import com.nextcloud.talk.models.json.conversations.Conversation;
+import com.nextcloud.talk.models.json.conversations.ConversationEnums;
 
-public class EnumRoomTypeConverter extends IntBasedTypeConverter<Conversation.ConversationType> {
+public class EnumRoomTypeConverter extends IntBasedTypeConverter<ConversationEnums.ConversationType> {
     @Override
-    public Conversation.ConversationType getFromInt(int i) {
+    public ConversationEnums.ConversationType getFromInt(int i) {
         switch (i) {
             case 1:
-                return Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL;
+                return ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL;
             case 2:
-                return Conversation.ConversationType.ROOM_GROUP_CALL;
+                return ConversationEnums.ConversationType.ROOM_GROUP_CALL;
             case 3:
-                return Conversation.ConversationType.ROOM_PUBLIC_CALL;
+                return ConversationEnums.ConversationType.ROOM_PUBLIC_CALL;
             case 4:
-                return Conversation.ConversationType.ROOM_SYSTEM;
+                return ConversationEnums.ConversationType.ROOM_SYSTEM;
             case 5:
-                return Conversation.ConversationType.FORMER_ONE_TO_ONE;
+                return ConversationEnums.ConversationType.FORMER_ONE_TO_ONE;
             case 6:
-                return Conversation.ConversationType.NOTE_TO_SELF;
+                return ConversationEnums.ConversationType.NOTE_TO_SELF;
             default:
-                return Conversation.ConversationType.DUMMY;
+                return ConversationEnums.ConversationType.DUMMY;
         }
     }
 
     @Override
-    public int convertToInt(Conversation.ConversationType object) {
+    public int convertToInt(ConversationEnums.ConversationType object) {
         switch (object) {
             case DUMMY:
                 return 0;

+ 60 - 60
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt

@@ -9,66 +9,66 @@
 package com.nextcloud.talk.models.json.converters
 
 import com.bluelinelabs.logansquare.typeconverters.StringBasedTypeConverter
-import com.nextcloud.talk.models.json.chat.ChatMessage
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STARTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STOPPED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AVATAR_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AVATAR_SET
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STARTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STOPPED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_ENDED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_JOINED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_LEFT
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_MISSED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_STARTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_TRIED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CIRCLE_ADDED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CIRCLE_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CLEARED_CHAT
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CONVERSATION_CREATED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CONVERSATION_RENAMED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.DESCRIPTION_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.DESCRIPTION_SET
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.DUMMY
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.FILE_SHARED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GROUP_ADDED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GROUP_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GUESTS_ALLOWED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GUESTS_DISALLOWED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GUEST_MODERATOR_DEMOTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.GUEST_MODERATOR_PROMOTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LISTABLE_ALL
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LISTABLE_NONE
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LISTABLE_USERS
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LOBBY_NONE
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LOBBY_NON_MODERATORS
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.LOBBY_OPEN_TO_EVERYONE
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_ADDED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_DISABLED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_EDITED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_ENABLED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MESSAGE_DELETED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_DISABLED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_ENABLED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MODERATOR_DEMOTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.MODERATOR_PROMOTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.OBJECT_SHARED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.PASSWORD_REMOVED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.PASSWORD_SET
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.POLL_CLOSED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.POLL_VOTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.REACTION
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.REACTION_DELETED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.REACTION_REVOKED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.READ_ONLY
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.READ_ONLY_OFF
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.RECORDING_FAILED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.RECORDING_STARTED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.RECORDING_STOPPED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.USER_ADDED
-import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.USER_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.AUDIO_RECORDING_STARTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.AUDIO_RECORDING_STOPPED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.AVATAR_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.AVATAR_SET
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STARTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STOPPED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_ENDED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_JOINED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_LEFT
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_MISSED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_STARTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CALL_TRIED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CIRCLE_ADDED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CIRCLE_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CLEARED_CHAT
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CONVERSATION_CREATED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.CONVERSATION_RENAMED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.DESCRIPTION_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.DESCRIPTION_SET
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.DUMMY
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.FILE_SHARED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GROUP_ADDED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GROUP_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GUESTS_ALLOWED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GUESTS_DISALLOWED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GUEST_MODERATOR_DEMOTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.GUEST_MODERATOR_PROMOTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LISTABLE_ALL
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LISTABLE_NONE
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LISTABLE_USERS
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LOBBY_NONE
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LOBBY_NON_MODERATORS
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.LOBBY_OPEN_TO_EVERYONE
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_ADDED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_DISABLED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_EDITED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_ENABLED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MATTERBRIDGE_CONFIG_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_DELETED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_DISABLED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MESSAGE_EXPIRATION_ENABLED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MODERATOR_DEMOTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.MODERATOR_PROMOTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.OBJECT_SHARED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PASSWORD_REMOVED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PASSWORD_SET
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.POLL_CLOSED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.POLL_VOTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.REACTION
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.REACTION_DELETED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.REACTION_REVOKED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.READ_ONLY
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.READ_ONLY_OFF
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_FAILED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_STARTED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDING_STOPPED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_ADDED
+import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_REMOVED
 
 /*
 * see https://nextcloud-talk.readthedocs.io/en/latest/chat/#system-messages

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