NotificationFetcher.kt 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. package com.keylesspalace.tusky.components.notifications
  2. import android.app.NotificationManager
  3. import android.content.Context
  4. import android.util.Log
  5. import androidx.annotation.WorkerThread
  6. import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
  7. import com.keylesspalace.tusky.db.AccountEntity
  8. import com.keylesspalace.tusky.db.AccountManager
  9. import com.keylesspalace.tusky.entity.Marker
  10. import com.keylesspalace.tusky.entity.Notification
  11. import com.keylesspalace.tusky.network.MastodonApi
  12. import com.keylesspalace.tusky.util.isLessThan
  13. import kotlinx.coroutines.delay
  14. import javax.inject.Inject
  15. import kotlin.math.min
  16. import kotlin.time.Duration.Companion.milliseconds
  17. /**
  18. * Fetch Mastodon notifications and show Android notifications, with summaries, for them.
  19. *
  20. * Should only be called by a worker thread.
  21. *
  22. * @see NotificationWorker
  23. * @see <a href="https://developer.android.com/guide/background/persistent/threading/worker">Background worker</a>
  24. */
  25. @WorkerThread
  26. class NotificationFetcher @Inject constructor(
  27. private val mastodonApi: MastodonApi,
  28. private val accountManager: AccountManager,
  29. private val context: Context
  30. ) {
  31. suspend fun fetchAndShow() {
  32. for (account in accountManager.getAllAccountsOrderedByActive()) {
  33. if (account.notificationsEnabled) {
  34. try {
  35. val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
  36. // Create sorted list of new notifications
  37. val notifications = fetchNewNotifications(account)
  38. .filter { filterNotification(notificationManager, account, it) }
  39. .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
  40. .toMutableList()
  41. // There's a maximum limit on the number of notifications an Android app
  42. // can display. If the total number of notifications (current notifications,
  43. // plus new ones) exceeds this then some newer notifications will be dropped.
  44. //
  45. // Err on the side of removing *older* notifications to make room for newer
  46. // notifications.
  47. val currentAndroidNotifications = notificationManager.activeNotifications
  48. .sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first
  49. // Check to see if any notifications need to be removed
  50. val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS
  51. if (toRemove > 0) {
  52. // Prefer to cancel old notifications first
  53. currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size))
  54. .forEach { notificationManager.cancel(it.tag, it.id) }
  55. // Still got notifications to remove? Trim the list of new notifications,
  56. // starting with the oldest.
  57. while (notifications.size > MAX_NOTIFICATIONS) {
  58. notifications.removeAt(0)
  59. }
  60. }
  61. // Make and send the new notifications
  62. // TODO: Use the batch notification API available in NotificationManagerCompat
  63. // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
  64. // when it is released.
  65. notifications.forEachIndexed { index, notification ->
  66. val androidNotification = NotificationHelper.make(
  67. context,
  68. notificationManager,
  69. notification,
  70. account,
  71. index == 0
  72. )
  73. notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
  74. // Android will rate limit / drop notifications if they're posted too
  75. // quickly. There is no indication to the user that this happened.
  76. // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
  77. delay(1000.milliseconds)
  78. }
  79. NotificationHelper.updateSummaryNotifications(
  80. context,
  81. notificationManager,
  82. account
  83. )
  84. accountManager.saveAccount(account)
  85. } catch (e: Exception) {
  86. Log.e(TAG, "Error while fetching notifications", e)
  87. }
  88. }
  89. }
  90. }
  91. /**
  92. * Fetch new Mastodon Notifications and update the marker position.
  93. *
  94. * Here, "new" means "notifications with IDs newer than notifications the user has already
  95. * seen."
  96. *
  97. * The "water mark" for Mastodon Notification IDs are stored in three places.
  98. *
  99. * - acccount.lastNotificationId -- the ID of the top-most notification when the user last
  100. * left the Notifications tab.
  101. * - The Mastodon "marker" API -- the ID of the most recent notification fetched here.
  102. * - account.notificationMarkerId -- local version of the value from the Mastodon marker
  103. * API, in case the Mastodon server does not implement that API.
  104. *
  105. * The user may have refreshed the "Notifications" tab and seen notifications newer than the
  106. * ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater
  107. * than the marker.
  108. */
  109. private suspend fun fetchNewNotifications(account: AccountEntity): List<Notification> {
  110. val authHeader = String.format("Bearer %s", account.accessToken)
  111. // Figure out where to read from. Choose the most recent notification ID from:
  112. //
  113. // - The Mastodon marker API (if the server supports it)
  114. // - account.notificationMarkerId
  115. // - account.lastNotificationId
  116. Log.d(TAG, "getting notification marker for ${account.fullName}")
  117. val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
  118. val localMarkerId = account.notificationMarkerId
  119. val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId
  120. val readingPosition = account.lastNotificationId
  121. var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition
  122. Log.d(TAG, " remoteMarkerId: $remoteMarkerId")
  123. Log.d(TAG, " localMarkerId: $localMarkerId")
  124. Log.d(TAG, " readingPosition: $readingPosition")
  125. Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
  126. // Fetch all outstanding notifications
  127. val notifications = buildList {
  128. while (minId != null) {
  129. val response = mastodonApi.notificationsWithAuth(
  130. authHeader,
  131. account.domain,
  132. minId = minId
  133. )
  134. if (!response.isSuccessful) break
  135. // Notifications are returned in the page in order, newest first,
  136. // (https://github.com/mastodon/documentation/issues/1226), insert the
  137. // new page at the head of the list.
  138. response.body()?.let { addAll(0, it) }
  139. // Get the previous page, which will be chronologically newer
  140. // notifications. If it doesn't exist this is null and the loop
  141. // will exit.
  142. val links = Links.from(response.headers()["link"])
  143. minId = links.prev
  144. }
  145. }
  146. // Save the newest notification ID in the marker.
  147. notifications.firstOrNull()?.let {
  148. val newMarkerId = notifications.first().id
  149. Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId")
  150. mastodonApi.updateMarkersWithAuth(
  151. auth = authHeader,
  152. domain = account.domain,
  153. notificationsLastReadId = newMarkerId
  154. )
  155. account.notificationMarkerId = newMarkerId
  156. accountManager.saveAccount(account)
  157. }
  158. return notifications
  159. }
  160. private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
  161. return try {
  162. val allMarkers = mastodonApi.markersWithAuth(
  163. authHeader,
  164. account.domain,
  165. listOf("notifications")
  166. )
  167. val notificationMarker = allMarkers["notifications"]
  168. Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker")
  169. notificationMarker
  170. } catch (e: Exception) {
  171. Log.e(TAG, "Failed to fetch marker", e)
  172. null
  173. }
  174. }
  175. companion object {
  176. private const val TAG = "NotificationFetcher"
  177. // There's a system limit on the maximum number of notifications an app
  178. // can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately
  179. // that's not available to client code or via the NotificationManager API.
  180. // The current value in the Android source code is 50, set 40 here to both
  181. // be conservative, and allow some headroom for summary notifications.
  182. private const val MAX_NOTIFICATIONS = 40
  183. }
  184. }