NotificationsPagingSource.kt 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. /*
  2. * Copyright 2023 Tusky Contributors
  3. *
  4. * This file is a part of Tusky.
  5. *
  6. * This program is free software; you can redistribute it and/or modify it under the terms of the
  7. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  11. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  12. * Public License for more details.
  13. *
  14. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  15. * see <http://www.gnu.org/licenses>.
  16. */
  17. package com.keylesspalace.tusky.components.notifications
  18. import android.util.Log
  19. import androidx.paging.PagingSource
  20. import androidx.paging.PagingState
  21. import com.keylesspalace.tusky.entity.Notification
  22. import com.keylesspalace.tusky.network.MastodonApi
  23. import com.keylesspalace.tusky.util.HttpHeaderLink
  24. import kotlinx.coroutines.async
  25. import kotlinx.coroutines.coroutineScope
  26. import okhttp3.Headers
  27. import retrofit2.Response
  28. import javax.inject.Inject
  29. /** Models next/prev links from the "Links" header in an API response */
  30. data class Links(val next: String?, val prev: String?)
  31. /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
  32. class NotificationsPagingSource @Inject constructor(
  33. private val mastodonApi: MastodonApi,
  34. private val notificationFilter: Set<Notification.Type>
  35. ) : PagingSource<String, Notification>() {
  36. override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
  37. Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
  38. try {
  39. val response = when (params) {
  40. is LoadParams.Refresh -> {
  41. getInitialPage(params)
  42. }
  43. is LoadParams.Append -> mastodonApi.notifications(
  44. maxId = params.key,
  45. limit = params.loadSize,
  46. excludes = notificationFilter
  47. )
  48. is LoadParams.Prepend -> mastodonApi.notifications(
  49. minId = params.key,
  50. limit = params.loadSize,
  51. excludes = notificationFilter
  52. )
  53. }
  54. if (!response.isSuccessful) {
  55. return LoadResult.Error(Throwable(response.errorBody().toString()))
  56. }
  57. val links = getPageLinks(response.headers()["link"])
  58. return LoadResult.Page(
  59. data = response.body()!!,
  60. nextKey = links.next,
  61. prevKey = links.prev
  62. )
  63. } catch (e: Exception) {
  64. return LoadResult.Error(e)
  65. }
  66. }
  67. /**
  68. * Fetch the initial page of notifications, using params.key as the ID of the initial
  69. * notification to fetch.
  70. *
  71. * - If there is no key, a page of the most recent notifications is returned
  72. * - If the notification exists, and is not filtered, a page of notifications is returned
  73. * - If the notification does not exist, or is filtered, the page of notifications immediately
  74. * before is returned
  75. * - If there is no page of notifications immediately before then the page immediately after
  76. * is returned
  77. */
  78. private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
  79. // If the key is null this is straightforward, just return the most recent notifications.
  80. val key = params.key
  81. ?: return@coroutineScope mastodonApi.notifications(
  82. limit = params.loadSize,
  83. excludes = notificationFilter
  84. )
  85. // It's important to return *something* from this state. If an empty page is returned
  86. // (even with next/prev links) Pager3 assumes there is no more data to load and stops.
  87. //
  88. // In addition, the Mastodon API does not let you fetch a page that contains a given key.
  89. // You can fetch the page immediately before the key, or the page immediately after, but
  90. // you can not fetch the page itself.
  91. // First, try and get the notification itself, and the notifications immediately before
  92. // it. This is so that a full page of results can be returned. Returning just the
  93. // single notification means the displayed list can jump around a bit as more data is
  94. // loaded.
  95. //
  96. // Make both requests, and wait for the first to complete.
  97. val deferredNotification = async { mastodonApi.notification(id = key) }
  98. val deferredNotificationPage = async {
  99. mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
  100. }
  101. val notification = deferredNotification.await()
  102. if (notification.isSuccessful) {
  103. // If this was successful we must still check that the user is not filtering this type
  104. // of notification, as fetching a single notification ignores filters. Returning this
  105. // notification if the user is filtering the type is wrong.
  106. notification.body()?.let { body ->
  107. if (!notificationFilter.contains(body.type)) {
  108. // Notification is *not* filtered. We can return this, but need the next page of
  109. // notifications as well
  110. // Collect all notifications in to this list
  111. val notifications = mutableListOf(body)
  112. val notificationPage = deferredNotificationPage.await()
  113. if (notificationPage.isSuccessful) {
  114. notificationPage.body()?.let {
  115. notifications.addAll(it)
  116. }
  117. }
  118. // "notifications" now contains at least one notification we can return, and
  119. // hopefully a full page.
  120. // Build correct max_id and min_id links for the response. The "min_id" to use
  121. // when fetching the next page is the same as "key". The "max_id" is the ID of
  122. // the oldest notification in the list.
  123. val maxId = notifications.last().id
  124. val headers = Headers.Builder()
  125. .add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
  126. .build()
  127. return@coroutineScope Response.success(notifications, headers)
  128. }
  129. }
  130. }
  131. // The user's last read notification was missing or is filtered. Use the page of
  132. // notifications chronologically older than their desired notification.
  133. deferredNotificationPage.await().apply {
  134. if (this.isSuccessful) return@coroutineScope this
  135. }
  136. // There were no notifications older than the user's desired notification. Return the page
  137. // of notifications immediately newer than their desired notification.
  138. return@coroutineScope mastodonApi.notifications(
  139. minId = key,
  140. limit = params.loadSize,
  141. excludes = notificationFilter
  142. )
  143. }
  144. private fun getPageLinks(linkHeader: String?): Links {
  145. val links = HttpHeaderLink.parse(linkHeader)
  146. return Links(
  147. next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
  148. "max_id"
  149. ),
  150. prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
  151. "min_id"
  152. )
  153. )
  154. }
  155. override fun getRefreshKey(state: PagingState<String, Notification>): String? {
  156. return state.anchorPosition?.let { anchorPosition ->
  157. val anchorPage = state.closestPageToPosition(anchorPosition)
  158. anchorPage?.prevKey ?: anchorPage?.nextKey
  159. }
  160. }
  161. companion object {
  162. private const val TAG = "NotificationsPagingSource"
  163. }
  164. }