Skip to content

Commit dcc3a79

Browse files
fix(chat): tell the user when a reminder or a share to notes failed
A reminder the server refused was only logged, and the chat says "Message sent" the moment a share to notes starts, before the answer arrives - so a share that failed was reported as a success. Both now show what happened. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent dd6ad07 commit dcc3a79

7 files changed

Lines changed: 174 additions & 49 deletions

File tree

‎app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,13 +641,15 @@ interface NcApiCoroutines {
641641
@Field("timestamp") timestamp: Int
642642
): ReminderOverall
643643

644+
@Suppress("LongParameterList")
644645
@FormUrlEncoded
645646
@POST
646647
suspend fun sendLocation(
647648
@Header("Authorization") authorization: String,
648649
@Url url: String,
649650
@Field("objectType") objectType: String,
650651
@Field("objectId") objectId: String,
651-
@Field("metaData") metaData: String
652+
@Field("metaData") metaData: String,
653+
@Field("referenceId") referenceId: String
652654
): GenericOverall
653655
}

‎app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,12 @@ class ChatActivity :
15321532
}
15331533
}
15341534

1535+
lifecycleScope.launch {
1536+
chatViewModel.actionFailures.collect { message ->
1537+
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
1538+
}
1539+
}
1540+
15351541
lifecycleScope.launch {
15361542
chatViewModel.noMoreSearchResults.collect {
15371543
val inSearchMode = chatViewModel.chatMode.value == ChatViewModel.ChatMode.SEARCH_MODE

‎app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ package com.nextcloud.talk.chat.data.network
99
import com.nextcloud.talk.data.user.model.User
1010
import com.nextcloud.talk.models.domain.ConversationModel
1111
import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto
12-
import com.nextcloud.talk.models.json.chat.ChatMessageJson
12+
import com.nextcloud.talk.models.json.chat.ChatMessageDto
1313
import com.nextcloud.talk.models.json.chat.ChatOverall
1414
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
1515
import com.nextcloud.talk.models.json.conversations.RoomOverall
@@ -36,21 +36,26 @@ interface ChatNetworkDataSource {
3636

3737
suspend fun getReminder(user: User, roomToken: String, messageId: String, apiVersion: Int): ReminderDto
3838
suspend fun deleteReminder(user: User, roomToken: String, messageId: String, apiVersion: Int): GenericOverall
39+
40+
@Suppress("LongParameterList")
3941
suspend fun shareToNotes(
4042
credentials: String,
4143
url: String,
4244
message: String,
43-
displayName: String
45+
displayName: String,
46+
referenceId: String
4447
): ChatOverallSingleMessage
4548

4649
suspend fun checkForNoteToSelf(credentials: String, url: String): RoomOverall
4750

51+
@Suppress("LongParameterList")
4852
suspend fun shareLocationToNotes(
4953
credentials: String,
5054
url: String,
5155
objectType: String,
5256
objectId: String,
53-
metadata: String
57+
metadata: String,
58+
referenceId: String
5459
): GenericOverall
5560

5661
suspend fun leaveRoom(credentials: String, url: String): GenericOverall
@@ -84,7 +89,7 @@ interface ChatNetworkDataSource {
8489
messageId: String,
8590
limit: Int,
8691
threadId: Int?
87-
): List<ChatMessageJson>
92+
): List<ChatMessageDto>
8893
suspend fun getOpenGraph(credentials: String, baseUrl: String, extractedLinkToPreview: String): ReferenceDto?
8994
suspend fun unbindRoom(credentials: String, baseUrl: String, roomToken: String): GenericOverall
9095

‎app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import com.nextcloud.talk.api.NcApiCoroutines
1111
import com.nextcloud.talk.data.user.model.User
1212
import com.nextcloud.talk.models.domain.ConversationModel
1313
import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto
14-
import com.nextcloud.talk.models.json.chat.ChatMessageJson
14+
import com.nextcloud.talk.models.json.chat.ChatMessageDto
1515
import com.nextcloud.talk.models.json.chat.ChatOverall
1616
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
1717
import com.nextcloud.talk.models.json.conversations.RoomOverall
@@ -21,7 +21,6 @@ import com.nextcloud.talk.models.json.reminder.ReminderDto
2121
import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEventsOverall
2222
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
2323
import com.nextcloud.talk.utils.ApiUtils
24-
import com.nextcloud.talk.utils.message.SendMessageUtils
2524
import io.reactivex.Observable
2625
import retrofit2.Response
2726

@@ -98,18 +97,10 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
9897
credentials: String,
9998
url: String,
10099
message: String,
101-
displayName: String
100+
displayName: String,
101+
referenceId: String
102102
): ChatOverallSingleMessage =
103-
ncApiCoroutines.sendChatMessage(
104-
credentials,
105-
url,
106-
message,
107-
displayName,
108-
0,
109-
false,
110-
SendMessageUtils().generateReferenceId(),
111-
null
112-
)
103+
ncApiCoroutines.sendChatMessage(credentials, url, message, displayName, 0, false, referenceId, null)
113104

114105
override suspend fun checkForNoteToSelf(credentials: String, url: String): RoomOverall =
115106
ncApiCoroutines.getNoteToSelfRoom(credentials, url)
@@ -119,8 +110,9 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
119110
url: String,
120111
objectType: String,
121112
objectId: String,
122-
metadata: String
123-
): GenericOverall = ncApiCoroutines.sendLocation(credentials, url, objectType, objectId, metadata)
113+
metadata: String,
114+
referenceId: String
115+
): GenericOverall = ncApiCoroutines.sendLocation(credentials, url, objectType, objectId, metadata, referenceId)
124116

125117
override suspend fun leaveRoom(credentials: String, url: String): GenericOverall =
126118
ncApiCoroutines.leaveRoom(credentials, url)
@@ -196,7 +188,7 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
196188
messageId: String,
197189
limit: Int,
198190
threadId: Int?
199-
): List<ChatMessageJson> {
191+
): List<ChatMessageDto> {
200192
val url = ApiUtils.getUrlForChatMessageContext(baseUrl, token, messageId)
201193
return ncApiCoroutines.getContextOfChatMessage(credentials, url, limit, threadId).ocs?.data ?: listOf()
202194
}

‎app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt‎

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import android.net.Uri
1212
import android.os.Bundle
1313
import android.provider.OpenableColumns
1414
import android.util.Log
15+
import androidx.annotation.StringRes
16+
import androidx.annotation.VisibleForTesting
1517
import androidx.lifecycle.DefaultLifecycleObserver
1618
import androidx.lifecycle.LifecycleOwner
1719
import androidx.lifecycle.LiveData
@@ -56,7 +58,7 @@ import com.nextcloud.talk.models.MessageDraft
5658
import com.nextcloud.talk.models.domain.ConversationModel
5759
import com.nextcloud.talk.models.domain.SearchMessageEntry
5860
import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto
59-
import com.nextcloud.talk.models.json.chat.ChatMessageJson
61+
import com.nextcloud.talk.models.json.chat.ChatMessageDto
6062
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
6163
import com.nextcloud.talk.models.json.conversations.ConversationEnums
6264
import com.nextcloud.talk.models.json.conversations.RoomOverall
@@ -81,6 +83,7 @@ import com.nextcloud.talk.utils.SpreedFeatures
8183
import com.nextcloud.talk.utils.UserIdUtils
8284
import com.nextcloud.talk.utils.throttleLatest
8385
import com.nextcloud.talk.utils.bundle.BundleKeys
86+
import com.nextcloud.talk.utils.isTransientFailure
8487
import com.nextcloud.talk.utils.message.SendMessageUtils
8588
import com.nextcloud.talk.utils.message.groupHashOf
8689
import com.nextcloud.talk.utils.preferences.AppPreferences
@@ -497,7 +500,7 @@ class ChatViewModel @AssistedInject constructor(
497500
mediaPlayerManager.handleOnStop()
498501
}
499502

500-
fun onSignalingChatMessageReceived(chatMessages: List<ChatMessageJson>) {
503+
fun onSignalingChatMessageReceived(chatMessages: List<ChatMessageDto>) {
501504
viewModelScope.launch {
502505
chatRepository.onSignalingChatMessageReceived(chatMessages)
503506
}
@@ -655,6 +658,10 @@ class ChatViewModel @AssistedInject constructor(
655658
private val _reactionFailures = MutableSharedFlow<ReactionOperation>(extraBufferCapacity = 1)
656659
val reactionFailures: SharedFlow<ReactionOperation> = _reactionFailures
657660

661+
/** A reminder or a share to notes that the server refused, for the chat to report. */
662+
private val _actionFailures = MutableSharedFlow<Int>(extraBufferCapacity = 1)
663+
val actionFailures: SharedFlow<Int> = _actionFailures
664+
658665
private val reactionLocks = mutableMapOf<Int, Mutex>()
659666

660667
@Volatile private var firstUnreadMessageId: Int? = null
@@ -1859,15 +1866,9 @@ class ChatViewModel @AssistedInject constructor(
18591866
?.subscribe(JoinRoomObserver())
18601867
}
18611868

1862-
@Suppress("Detekt.TooGenericExceptionCaught")
18631869
fun setReminder(user: User, roomToken: String, messageId: String, timestamp: Int, chatApiVersion: Int) {
1864-
viewModelScope.launch {
1865-
try {
1866-
chatNetworkDataSource.setReminder(user, roomToken, messageId, timestamp, chatApiVersion)
1867-
Log.d(TAG, "reminder set successfully")
1868-
} catch (e: Exception) {
1869-
Log.e(TAG, "Error when sending reminder", e)
1870-
}
1870+
requestWithFeedback("set the reminder", R.string.nc_common_error_sorry) {
1871+
chatNetworkDataSource.setReminder(user, roomToken, messageId, timestamp, chatApiVersion)
18711872
}
18721873
}
18731874

@@ -1887,15 +1888,10 @@ class ChatViewModel @AssistedInject constructor(
18871888
_getReminderExistState.value = GetReminderStateSet
18881889
}
18891890

1890-
@Suppress("Detekt.TooGenericExceptionCaught")
18911891
fun deleteReminder(user: User, roomToken: String, messageId: String, chatApiVersion: Int) {
1892-
viewModelScope.launch {
1893-
try {
1894-
chatNetworkDataSource.deleteReminder(user, roomToken, messageId, chatApiVersion)
1895-
_getReminderExistState.value = GetReminderStartState
1896-
} catch (e: Exception) {
1897-
Log.d(TAG, "Error when deleting reminder", e)
1898-
}
1892+
requestWithFeedback("delete the reminder", R.string.nc_common_error_sorry) {
1893+
chatNetworkDataSource.deleteReminder(user, roomToken, messageId, chatApiVersion)
1894+
_getReminderExistState.value = GetReminderStartState
18991895
}
19001896
}
19011897

@@ -2153,14 +2149,9 @@ class ChatViewModel @AssistedInject constructor(
21532149
)
21542150
}
21552151

2156-
@Suppress("Detekt.TooGenericExceptionCaught")
21572152
fun shareToNotes(credentials: String, url: String, message: String, displayName: String) {
2158-
viewModelScope.launch {
2159-
try {
2160-
chatNetworkDataSource.shareToNotes(credentials, url, message, displayName)
2161-
} catch (e: Exception) {
2162-
Log.d(TAG, "Error when sharing to notes", e)
2163-
}
2153+
shareToNotes("share the message to notes", credentials, url) { referenceId ->
2154+
chatNetworkDataSource.shareToNotes(credentials, url, message, displayName, referenceId)
21642155
}
21652156
}
21662157

@@ -2183,13 +2174,97 @@ class ChatViewModel @AssistedInject constructor(
21832174
null
21842175
}
21852176

2186-
@Suppress("Detekt.TooGenericExceptionCaught")
21872177
fun shareLocationToNotes(credentials: String, url: String, objectType: String, objectId: String, metadata: String) {
2178+
shareToNotes("share the location to notes", credentials, url) { referenceId ->
2179+
chatNetworkDataSource.shareLocationToNotes(credentials, url, objectType, objectId, metadata, referenceId)
2180+
}
2181+
}
2182+
2183+
/**
2184+
* Shares to the note to self and, when the share failed for a transient reason, asks the note to
2185+
* self whether the message arrived anyway before sending it a second time. The server stores the
2186+
* reference id of a message without ever looking at it again, so it deduplicates nothing: a blind
2187+
* second attempt after a timeout the server did accept posts the note twice.
2188+
*/
2189+
private fun shareToNotes(
2190+
description: String,
2191+
credentials: String,
2192+
notesUrl: String,
2193+
share: suspend (String) -> Unit
2194+
) {
2195+
val referenceId = SendMessageUtils().generateReferenceId()
2196+
2197+
viewModelScope.launch {
2198+
val failure = attemptShare(share, referenceId) ?: return@launch
2199+
2200+
if (!isTransientFailure(failure)) {
2201+
reportShareFailure(description, failure)
2202+
return@launch
2203+
}
2204+
2205+
if (!alreadyShared(credentials, notesUrl, referenceId)) {
2206+
attemptShare(share, referenceId)?.let { reportShareFailure(description, it) }
2207+
}
2208+
}
2209+
}
2210+
2211+
/** Runs the share once and reports what went wrong, or null when it worked. */
2212+
@Suppress("Detekt.TooGenericExceptionCaught")
2213+
private suspend fun attemptShare(share: suspend (String) -> Unit, referenceId: String): Exception? =
2214+
try {
2215+
share(referenceId)
2216+
null
2217+
} catch (e: CancellationException) {
2218+
throw e
2219+
} catch (e: Exception) {
2220+
e
2221+
}
2222+
2223+
/**
2224+
* Whether the note to self already holds the message of this share. A lookup that cannot be
2225+
* answered counts as "it might", because posting a second copy is worse than not retrying.
2226+
*/
2227+
@Suppress("Detekt.TooGenericExceptionCaught")
2228+
private suspend fun alreadyShared(credentials: String, notesUrl: String, referenceId: String): Boolean =
2229+
try {
2230+
val fieldMap = hashMapOf(
2231+
"lookIntoFuture" to 0,
2232+
"includeLastKnown" to 1,
2233+
"setReadMarker" to 0,
2234+
"markNotificationsAsRead" to 0,
2235+
"timeout" to 0,
2236+
"limit" to SHARE_LOOKUP_LIMIT
2237+
)
2238+
val recent = chatNetworkDataSource.pullChatMessages(credentials, notesUrl, fieldMap)
2239+
holdsShare(recent.body()?.ocs?.data, referenceId)
2240+
} catch (e: CancellationException) {
2241+
throw e
2242+
} catch (e: Exception) {
2243+
logger.w(TAG, "Could not check whether the share arrived, so it is not sent again", e)
2244+
true
2245+
}
2246+
2247+
private fun reportShareFailure(description: String, throwable: Throwable) {
2248+
logger.e(TAG, "Failed to $description", throwable)
2249+
_actionFailures.tryEmit(R.string.nc_message_not_added_to_notes)
2250+
}
2251+
2252+
/**
2253+
* Runs an action whose only visible outcome is whether it worked. The reminder dialog is already
2254+
* dismissed and the chat has already said the message was sent by the time the answer arrives, so
2255+
* a failure that is not reported here is never reported at all.
2256+
*
2257+
* Deliberately without a retry: sharing to notes posts a message with a reference id generated per
2258+
* call, so a second attempt after a timeout the server did accept would post it twice.
2259+
*/
2260+
@Suppress("Detekt.TooGenericExceptionCaught")
2261+
private fun requestWithFeedback(description: String, @StringRes failureMessage: Int, request: suspend () -> Unit) {
21882262
viewModelScope.launch {
21892263
try {
2190-
chatNetworkDataSource.shareLocationToNotes(credentials, url, objectType, objectId, metadata)
2264+
request()
21912265
} catch (e: Exception) {
2192-
Log.e(TAG, "Error when sharing location to notes", e)
2266+
logger.e(TAG, "Failed to $description", e)
2267+
_actionFailures.tryEmit(failureMessage)
21932268
}
21942269
}
21952270
}
@@ -2645,6 +2720,18 @@ class ChatViewModel @AssistedInject constructor(
26452720
companion object {
26462721
private val TAG = ChatViewModel::class.java.simpleName
26472722

2723+
/** Enough of the note to self to cover what a share of ours could have landed behind. */
2724+
private const val SHARE_LOOKUP_LIMIT = 20
2725+
2726+
/**
2727+
* Whether [messages] already hold the share that was sent with [referenceId]. Messages the
2728+
* server did not return - because the lookup failed or answered "not modified" - are read as
2729+
* "it may well be there", since sending a second copy is worse than not sending one at all.
2730+
*/
2731+
@VisibleForTesting
2732+
fun holdsShare(messages: List<ChatMessageDto>?, referenceId: String): Boolean =
2733+
messages?.any { it.referenceId == referenceId } ?: true
2734+
26482735
/**
26492736
* Ids of the messages a delete of [messageId] applies to: every already-uploaded message of the
26502737
* media group the message is shown in, or just the message itself.

‎app/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ How to translate with transifex:
559559
<string name="nc_scheduled_thread">Scheduled thread</string>
560560
<string name="nc_reply_to_thread">Reply to thread: %1$s</string>
561561
<string name="nc_message_added_to_notes">Message added to notes</string>
562+
<string name="nc_message_not_added_to_notes">Could not add the message to notes</string>
562563
<string name="nc_message_failed">Failed to send message</string>
563564
<string name="nc_message_failed_to_send">Failed to send message:</string>
564565
<string name="nc_add_attachment">Add attachment</string>

0 commit comments

Comments
 (0)