@@ -12,6 +12,8 @@ import android.net.Uri
1212import android.os.Bundle
1313import android.provider.OpenableColumns
1414import android.util.Log
15+ import androidx.annotation.StringRes
16+ import androidx.annotation.VisibleForTesting
1517import androidx.lifecycle.DefaultLifecycleObserver
1618import androidx.lifecycle.LifecycleOwner
1719import androidx.lifecycle.LiveData
@@ -56,7 +58,7 @@ import com.nextcloud.talk.models.MessageDraft
5658import com.nextcloud.talk.models.domain.ConversationModel
5759import com.nextcloud.talk.models.domain.SearchMessageEntry
5860import 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
6062import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
6163import com.nextcloud.talk.models.json.conversations.ConversationEnums
6264import com.nextcloud.talk.models.json.conversations.RoomOverall
@@ -81,6 +83,7 @@ import com.nextcloud.talk.utils.SpreedFeatures
8183import com.nextcloud.talk.utils.UserIdUtils
8284import com.nextcloud.talk.utils.throttleLatest
8385import com.nextcloud.talk.utils.bundle.BundleKeys
86+ import com.nextcloud.talk.utils.isTransientFailure
8487import com.nextcloud.talk.utils.message.SendMessageUtils
8588import com.nextcloud.talk.utils.message.groupHashOf
8689import 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.
0 commit comments