talk-android icon indicating copy to clipboard operation
talk-android copied to clipboard

Offline support

Open mahibi opened this issue 1 year ago • 4 comments

WIP!

Now in Android Documentation for the App's Architecture

NC Offline DB

🚧 TODO

  • [x] Implement Network Monitor
  • [x] Declare Synchronizer (it's implemented in each repository, using the various DAO's)
  • [x] Implement ChatMessageEntity
  • [x] Implement ConversationEntity
  • [x] Abstract away ChatMessage from ChatMessageEntity and ChatMessageJSON
    • [x] Implement Mappers
    • [x] Implement data classes
  • [x] Abstract away Conversation from ConversationEntity and ConversationJSON
    • [x] Implement Mappers
    • [x] Implement data classes
  • [x] Implement ChatDao
  • [x] Implement ConversationDao
  • [x] Implement local version storage using dataStore
  • [x] Implement OfflineFirstChatRepository
    • [x] Model Flows
    • [x] Sync function
    • [x] Get Chat Messages
    • [x] implement on demand synchronization functions
  • [x] Implement OfflineFirstConversationRepository
    • [x] Model Flows
    • [x] Sync function
  • [ ] Research Synchronization strategies for settings as they don't require on demand Synchronization as Chatting does.
  • [ ] Database testing
  • [ ] ...

🏁 Checklist

  • [ ] ⛑️ Tests (unit and/or integration) are included or not needed
  • [ ] 🔖 Capability is checked or not needed
  • [ ] 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • [ ] 📅 Milestone is set
  • [ ] 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

mahibi avatar Jun 04 '24 14:06 mahibi

I'm very interested in this function. Can I help you testing the new feature using the Nextcloud Talk QA App?

niclasheinz avatar Jul 01 '24 19:07 niclasheinz

I'm very interested in this function. Can I help you testing the new feature using the Nextcloud Talk QA App?

Thanks that would be great, bit of warning though, you might have to login again, and as of now offline conversations are still unimplemented, and offline writing to the server is unavailable. There also might be issues with editing/deleting messages, along with processing read status, and a lot of other bugs as this is a pretty significant (but nevertheless needed) refactoring. Better late than never 😄

rapterjet2004 avatar Jul 02 '24 16:07 rapterjet2004

I'm very interested in this function. Can I help you testing the new feature using the Nextcloud Talk QA App?

Thanks that would be great, bit of warning though, you might have to login again, and as of now offline conversations are still unimplemented, and offline writing to the server is unavailable. There also might be issues with editing/deleting messages, along with processing read status, and a lot of other bugs as this is a pretty significant (but nevertheless needed) refactoring. Better late than never 😄

Can you inform me, if I can test the offline features to give you a feedback of it? You can also contact me to test some other new features if needed 😀.

niclasheinz avatar Jul 02 '24 23:07 niclasheinz

@mahibi These are functions that are involved in the on demand synchronization strategy used in ChatActivity. These are loadMoreMessages and initMessagePolling. Both launched in separate threads, they write to messageFlow asynchronously. Found in GetCapabilitiesInitalLoad

chatViewModel.loadMoreMessages(
                        beforeMessageId = -1, // gets history of conversation
                        withCredentials = credentials!!,
                        withUrl = urlForChatting,
                        roomToken = currentConversation!!.token!!,
                        withMessageLimit = MESSAGE_PULL_LIMIT
                    )

chatViewModel.initMessagePolling(
                        withCredentials = credentials!!,
                        withUrl = urlForChatting,
                        roomToken = currentConversation!!.token!!
                    )


loadMoreMessages

is defined in the OfflineFirstChatRepository. It

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.

NC loadMoreMessage

override fun loadMoreMessages(
        beforeMessageId: Long,
        roomToken: String,
        withMessageLimit: Int,
        withNetworkParams: Bundle
    ): Job =
        scope.launch {
            // Here it sets the fieldmap to retrieve past messages, and mark them as read given the roomToken
            val fieldMap = getFieldMap(
                roomToken,
                lookIntoFuture = false,
                setReadMarker = true
            )
            // Here it sets params to be used in the modelFetcher function, used in the synchronizer. 
            withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
            withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)

            var attempts = 0
            do {
                attempts++
                val maxAttemptsAreNotReached = (attempts < 2)

                // Used to offset the query, as the lastKnownMessage gets updated in the sync function.
                val lastKnown = datastore.getLastKnownId(roomToken, 0)
                val id = if (beforeMessageId > 0) beforeMessageId else (lastKnown.toLong() + 1) // so it includes lastKnown
              
                // Gets <= 100 messages before the last known message in the conversation
                val list = getMessagesBefore(
                    id,
                    roomToken,
                    withMessageLimit
                )
 
                // Only emits to the flow if the list is not empty. Emits with false, so that the rest of the app understands that this 
                // data is from the past and is meant to be handled in that way
                if (list.isNotEmpty()) {
                    val pair = Pair(false, list)
                    _messageFlow.emit(pair)
                    break
                } else if (maxAttemptsAreNotReached) [email protected](withNetworkParams)
            } while (maxAttemptsAreNotReached)
        }

loadMoreMessages is also called in the onLoadMore callback. Here, the offset is the last chat message's id in the adapter, rather than the last known message

override fun onLoadMore(page: Int, totalItemsCount: Int) {
        val calculatedPage = totalItemsCount / PAGE_SIZE
        if (calculatedPage > 0) {
            val id = (adapter?.items?.last {
                it.item is ChatMessage
            }?.item as ChatMessage).jsonMessageId

            val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken)

            chatViewModel.loadMoreMessages(
                beforeMessageId = id.toLong(),
                withUrl = urlForChatting,
                withCredentials = credentials!!,
                withMessageLimit = MESSAGE_PULL_LIMIT,
                roomToken = currentConversation!!.token!!
            )
        }
    }

Unlike initMessagePolling, loadMoreMessages is not in an infinite while loop. So further calls to loadMoreMessages will require a UI trigger event.

rapterjet2004 avatar Jul 05 '24 15:07 rapterjet2004

InitMessagePolling

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.

NC InitMessagePolling


override fun initMessagePolling(roomToken: String, withNetworkParams: Bundle): Job =
        scope.launch {
            monitor.isOnline.onEach { online ->
                var fieldMap = getFieldMap(roomToken, lookIntoFuture = true, setReadMarker = true)
                while (!itIsPaused) {
                    if (!online) Thread.sleep(500)

                    // sync database with server ( This is a long blocking call b/c long polling is set )
                    withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
                    withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
                    [email protected](withNetworkParams)

                    // get new messages, if not empty -> emit to flow with APPEND
                    var list = getMessagesFrom(newMessageIds)
                    newMessageIds = listOf() // Clear it after use to prevent duplicates

                    // Process read status if not null
                    val lastKnown = datastore.getLastKnownId(roomToken, 0)
                    list = list.map { chatMessage ->
                        chatMessage.readStatus = if (chatMessage.jsonMessageId <= lastKnown) {
                            ReadStatus.READ
                        } else {
                            ReadStatus.SENT
                        }

                        return@map chatMessage
                    }

                    if (list.isNotEmpty()) {
                        val pair = Pair(true, list)
                        _messageFlow.emit(pair)
                    }
                    // update field map vars for next cycle
                    fieldMap = getFieldMap(roomToken, lookIntoFuture = true, setReadMarker = true)
                }
            }
                .flowOn(Dispatchers.IO).collect()
        }

This is a lifecycle aware infinite loop. It should terminate on lifecycle change, and retry every 500 ms on connection loss. I might change that later though.

rapterjet2004 avatar Jul 05 '24 16:07 rapterjet2004


update1: it's necessary to also check if the loaded 100 messages itself contains gaps.... update2: the concept draft won't be exactly implemented like this. @Ivansss introduced the similar concept from iOS talk app which uses chat-blocks (see https://github.com/nextcloud/talk-ios/blob/master/NextcloudTalk/NCChatBlock.h). Most probably we will adapt this...


@rapterjet2004 thanks for the summary. As far as i see this implementation does not respect gaps and it is all dependend on a full history? We have to deal with incomplete chat history as loading everything is too much for server and takes too long. We should begin with to load the last 100 messages and lookintofuture and from there on handle the history. Just loading the last 100 offline messages from DB could display gaps in the chat if it was not opened for a longer time..

I just wrote some concept for this, but take it as a draft for now / let me know if it makes sense for you.

wenn chat is opened:

read last 100 messages from DB (show them, even if they are older) read last 100 messages from server (regardless if offline messages are available) set lastKnownOfflineMessageId to newest message attention: when loading just the last 100 messages from server, this can introduce gaps if chat was not opened for a long time!

start polling with lastKnownOfflineMessageId

At this point, there are no problems until the user does not scroll back beyond the 100 messages.

load message history

Whenever scrolling back via onLoadMore, we have to check for gaps when loading offline messages. For that we introduce the hasNoGapUntilMessageId attribute for each message. For every message it is saved until which other message there is known to be no gap between them.

We want to load the 100 message before this messageX. When scrolling back, this check is made: Fetch next 100 offline messages from DB (but do not show them). For the newest messageY from these messages:

if 
	messageY.hasNoGapUntilMessageId < messageX.messageId
then 		// a gap is detected
	load messages from server, insert into DB, show messages in UI
	for the new loaded messages, set hasNoGapUntilMessageId to messageX.hasNoGapUntilMessageId

else if 
	messageY.hasNoGapUntilMessageId >= messageX.messageId
then 		// no gap is detected
	load messages from DB, show messages in UI (no need to make server request)

(Note that the message id's are NOT continuous! Only higher or lower is important.)

Example for a gap:

Lets say we have a messageX with id 400 and it's hasNoGapUntilMessageId is 450. We are scrolling back so we want the previous 100 message before message with id 400.

The previous 100 messages are fetched from DB. It's newest messageY with id 320 has also hasNoGapUntilMessageId=320

Because 320 < 400 --> There is a gap between message 320 and 400 (so if ids were continuous there would be a gap of 80) messages 300 to 399 are loaded from the server, insert into DB and shown in UI for all new loaded messages beginning with message 300, hasNoGapUntilMessageId is set to 450 as we can prove now that there are no gaps between message 300 and message 450.

Example for no gap:

Lets say we have a messageX with id 400 and it's hasNoGapUntilMessageId is 450. We are scrolling back so we want the previous 100 message before message with id 400.

The previous 100 messages are fetched from DB. It's newest messageY with id 220 has hasNoGapUntilMessageId=400

Because 400 >= 400 --> There is no gap messages are taken from DB, and shown in UI for all loaded messages beginning with message 120, hasNoGapUntilMessageId is set to 450 as we can prove now that there are no gaps between message 120 and message 450.

mahibi avatar Jul 08 '24 13:07 mahibi

With the release of 09.07.2024 at 17:49 there are two bugs:

  • the chat list is not loaded
  • The users and groups are displayed under the pen -> When I click on a user, the app crashes

niclasheinz avatar Jul 09 '24 16:07 niclasheinz

With the release of 09.07.2024 at 17:49 there are two bugs:

* the chat list is not loaded

* The users and groups are displayed under the pen
  -> When I click on a user, the app crashes

Thanks for testing @niclasheinz . Appreciate that :+1: This branch is under heavy construction so there will be bugs all around for now. Once the branch is tagged as "to review" any testing is highly welcomed. This will take weeks though.

mahibi avatar Jul 10 '24 07:07 mahibi

Testing

~~Duplication bug occurs when asking for chat permissions~~

issue-3952-dup-bug-permission.webm

Entering a empty conversation triggers a crash

FATAL EXCEPTION: DefaultDispatcher-worker-3
                                                                                                    Process: com.nextcloud.talk2, PID: 30798
                                                                                                    android.database.sqlite.SQLiteConstraintException: FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)
                                                                                                    	at android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
                                                                                                    	at android.database.sqlite.SQLiteConnection.executeForLastInsertedRowId(SQLiteConnection.java:961)
                                                                                                    	at android.database.sqlite.SQLiteSession.executeForLastInsertedRowId(SQLiteSession.java:790)
                                                                                                    	at android.database.sqlite.SQLiteStatement.executeInsert(SQLiteStatement.java:89)
                                                                                                    	at androidx.sqlite.db.framework.FrameworkSQLiteStatement.executeInsert(FrameworkSQLiteStatement.kt:42)
                                                                                                    	at androidx.room.EntityInsertionAdapter.insert(EntityInsertionAdapter.kt:51)
                                                                                                    	at com.nextcloud.talk.data.database.dao.ChatMessagesDao_Impl$6.call(ChatMessagesDao_Impl.java:345)
                                                                                                    	at com.nextcloud.talk.data.database.dao.ChatMessagesDao_Impl$6.call(ChatMessagesDao_Impl.java:339)
                                                                                                    	at androidx.room.CoroutinesRoom$Companion$execute$2.invokeSuspend(CoroutinesRoom.kt:64)
                                                                                                    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
                                                                                                    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
                                                                                                    	at androidx.room.TransactionExecutor.execute$lambda$1$lambda$0(TransactionExecutor.kt:36)
                                                                                                    	at androidx.room.TransactionExecutor.$r8$lambda$FZWr2PGmP3sgXLCiri-DCcePXSs(Unknown Source:0)
                                                                                                    	at androidx.room.TransactionExecutor$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
                                                                                                    	at java.lang.Thread.run(Thread.java:1012)
                                                                                                    	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@cfb291, Dispatchers.IO]
FATAL EXCEPTION: main
                                                                                                    Process: com.nextcloud.talk2, PID: 31065
                                                                                                    java.lang.IllegalStateException: java.lang.IllegalStateException: Not initialized yet
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity.setActionBarTitle(ChatActivity.kt:2293)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity.initObservers$lambda$13(ChatActivity.kt:596)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity.$r8$lambda$QKH5JCFLmCzRMlSJ-EV-m4IW5ig(Unknown Source:0)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity$$ExternalSyntheticLambda38.invoke(D8$$SyntheticClass:0)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity$sam$androidx_lifecycle_Observer$0.onChanged(Unknown Source:2)
                                                                                                    	at androidx.lifecycle.LiveData.considerNotify(LiveData.java:133)
                                                                                                    	at androidx.lifecycle.LiveData.dispatchingValue(LiveData.java:151)
                                                                                                    	at androidx.lifecycle.LiveData.setValue(LiveData.java:309)
                                                                                                    	at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
                                                                                                    	at com.nextcloud.talk.chat.viewmodels.ChatViewModel.getCapabilities(ChatViewModel.kt:246)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity$initObservers$1$1.invokeSuspend(ChatActivity.kt:544)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity$initObservers$1$1.invoke(Unknown Source:8)
                                                                                                    	at com.nextcloud.talk.chat.ChatActivity$initObservers$1$1.invoke(Unknown Source:4)
                                                                                                    	at kotlinx.coroutines.flow.FlowKt__TransformKt$onEach$$inlined$unsafeTransform$1$2.emit(Emitters.kt:219)
                                                                                                    	at kotlinx.coroutines.flow.FlowKt__ErrorsKt$catchImpl$2.emit(Errors.kt:154)
                                                                                                    	at kotlinx.coroutines.flow.FlowKt__TransformKt$onEach$$inlined$unsafeTransform$1$2.emit(Emitters.kt:220)
                                                                                                    	at kotlinx.coroutines.flow.SharedFlowImpl.collect$suspendImpl(SharedFlow.kt:392)
                                                                                                    	at kotlinx.coroutines.flow.SharedFlowImpl$collect$1.invokeSuspend(Unknown Source:15)
                                                                                                    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
                                                                                                    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
                                                                                                    	at android.os.Handler.handleCallback(Handler.java:958)
                                                                                                    	at android.os.Handler.dispatchMessage(Handler.java:99)
                                                                                                    	at android.os.Looper.loopOnce(Looper.java:205)
                                                                                                    	at android.os.Looper.loop(Looper.java:294)
                                                                                                    	at android.app.ActivityThread.main(ActivityThread.java:8177)
                                                                                                    	at java.lang.reflect.Method.invoke(Native Method)
                                                                                                    	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)
                                                                                                    	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)
                                                                                                    	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@9f7be80, Dispatchers.Main.immediate]

issue-3952-crash-on-empty-conversation.webm

~~Duplication Bug when reentering conversation~~

issue-3952-dup-bug-reentering.webm

rapterjet2004 avatar Aug 09 '24 15:08 rapterjet2004

Testing

Duplicate accounts?

Screenshot 2024-08-09 at 10 32 04 AM

Duplicate conversations on search (refreshing the adapter corrects it)

Screenshot 2024-08-09 at 10 33 20 AM

Reaction from user don't show up as being from current user

issue-3952-reactions-removal-bug.webm

rapterjet2004 avatar Aug 09 '24 15:08 rapterjet2004

Codacy

Lint

TypemasterPR
Warnings9087
Errors129132

SpotBugs

CategoryBaseNew
Bad practice66
Correctness1111
Dodgy code7878
Internationalization33
Malicious code vulnerability33
Performance66
Security11
Total108108

Lint increased!

github-actions[bot] avatar Aug 12 '24 14:08 github-actions[bot]

APK file: https://www.kaminsky.me/nc-dev/android-artifacts/3952-talk.apk

qrcode

To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud Talk app.

github-actions[bot] avatar Aug 12 '24 15:08 github-actions[bot]

Hello @mahibi and @rapterjet2004

That's great :tada: . It works well. It would be nice if I could switch offline support on and off for some chats. It would also be great if I could compose and send a message (text, image, audio, poll, location, file or contact) offline and when I am back online this message is sent automatically.

But otherwise really good work. Really great.

Best regards, Niclas

niclasheinz avatar Aug 12 '24 15:08 niclasheinz

Great!!!!

I've tested the offline support with my account on nextcloud server version v28.0.4 and works fine, but my account at nextcloud server v25.0.12 can't show any "room" after upgrade the client to Android Nextcloud Talk v20.0.0-RC1.

I've uninstalled completly and reinstaled and configured accounts, with the same result. I supose that this version of Android Nextcloud Talk don't have support to older Spreed(talk) servers like v15.0.8. The client Android Nextcloud Talk v19.1.0-RC1 works fine with v28.0.4 and v25.0.12 of nextcloud server.

Thanks for all @mahibi & CO!!!

migulen avatar Aug 13 '24 08:08 migulen