PushNotificationHelper.kt 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. /* Copyright 2022 Tusky contributors
  2. *
  3. * This file is a part of Tusky.
  4. *
  5. * This program is free software; you can redistribute it and/or modify it under the terms of the
  6. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  7. * License, or (at your option) any later version.
  8. *
  9. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  10. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  11. * Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  14. * see <http://www.gnu.org/licenses>. */
  15. @file:JvmName("PushNotificationHelper")
  16. package com.keylesspalace.tusky.components.notifications
  17. import android.app.NotificationManager
  18. import android.content.Context
  19. import android.os.Build
  20. import android.util.Log
  21. import android.view.View
  22. import androidx.appcompat.app.AlertDialog
  23. import androidx.preference.PreferenceManager
  24. import at.connyduck.calladapter.networkresult.onFailure
  25. import at.connyduck.calladapter.networkresult.onSuccess
  26. import com.google.android.material.snackbar.Snackbar
  27. import com.keylesspalace.tusky.R
  28. import com.keylesspalace.tusky.components.login.LoginActivity
  29. import com.keylesspalace.tusky.db.AccountEntity
  30. import com.keylesspalace.tusky.db.AccountManager
  31. import com.keylesspalace.tusky.entity.Notification
  32. import com.keylesspalace.tusky.network.MastodonApi
  33. import com.keylesspalace.tusky.util.CryptoUtil
  34. import kotlinx.coroutines.Dispatchers
  35. import kotlinx.coroutines.withContext
  36. import org.unifiedpush.android.connector.UnifiedPush
  37. private const val TAG = "PushNotificationHelper"
  38. private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
  39. private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
  40. accountManager.accounts.any(::accountNeedsMigration)
  41. private fun accountNeedsMigration(account: AccountEntity): Boolean =
  42. !account.oauthScopes.contains("push")
  43. fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
  44. accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
  45. fun showMigrationNoticeIfNecessary(
  46. context: Context,
  47. parent: View,
  48. anchorView: View?,
  49. accountManager: AccountManager
  50. ) {
  51. // No point showing anything if we cannot enable it
  52. if (!isUnifiedPushAvailable(context)) return
  53. if (!anyAccountNeedsMigration(accountManager)) return
  54. val pm = PreferenceManager.getDefaultSharedPreferences(context)
  55. if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
  56. Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
  57. .setAnchorView(anchorView)
  58. .setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
  59. .show()
  60. }
  61. private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
  62. AlertDialog.Builder(context).apply {
  63. if (currentAccountNeedsMigration(accountManager)) {
  64. setMessage(R.string.dialog_push_notification_migration)
  65. setPositiveButton(R.string.title_migration_relogin) { _, _ ->
  66. context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION))
  67. }
  68. } else {
  69. setMessage(R.string.dialog_push_notification_migration_other_accounts)
  70. }
  71. setNegativeButton(R.string.action_dismiss) { dialog, _ ->
  72. val pm = PreferenceManager.getDefaultSharedPreferences(context)
  73. pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
  74. dialog.dismiss()
  75. }
  76. show()
  77. }
  78. }
  79. private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
  80. if (isUnifiedPushNotificationEnabledForAccount(account)) {
  81. // Already registered, update the subscription to match notification settings
  82. updateUnifiedPushSubscription(context, api, accountManager, account)
  83. } else {
  84. UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
  85. }
  86. }
  87. fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
  88. if (!isUnifiedPushNotificationEnabledForAccount(account)) {
  89. // Not registered
  90. return
  91. }
  92. UnifiedPush.unregisterApp(context, account.id.toString())
  93. }
  94. fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
  95. account.unifiedPushUrl.isNotEmpty()
  96. private fun isUnifiedPushAvailable(context: Context): Boolean =
  97. UnifiedPush.getDistributors(context).isNotEmpty()
  98. fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
  99. isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
  100. suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) {
  101. if (!canEnablePushNotifications(context, accountManager)) {
  102. // No UP distributors
  103. NotificationHelper.enablePullNotifications(context)
  104. return
  105. }
  106. val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
  107. accountManager.accounts.forEach {
  108. val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
  109. nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
  110. val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
  111. if (shouldEnable) {
  112. enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
  113. } else {
  114. disableUnifiedPushNotificationsForAccount(context, it)
  115. }
  116. }
  117. }
  118. private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
  119. accountManager.accounts.forEach {
  120. disableUnifiedPushNotificationsForAccount(context, it)
  121. }
  122. }
  123. fun disableAllNotifications(context: Context, accountManager: AccountManager) {
  124. disablePushNotifications(context, accountManager)
  125. NotificationHelper.disablePullNotifications(context)
  126. }
  127. private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
  128. buildMap {
  129. Notification.Type.visibleTypes.forEach {
  130. put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
  131. }
  132. }
  133. // Called by UnifiedPush callback
  134. suspend fun registerUnifiedPushEndpoint(
  135. context: Context,
  136. api: MastodonApi,
  137. accountManager: AccountManager,
  138. account: AccountEntity,
  139. endpoint: String
  140. ) = withContext(Dispatchers.IO) {
  141. // Generate a prime256v1 key pair for WebPush
  142. // Decryption is unimplemented for now, since Mastodon uses an old WebPush
  143. // standard which does not send needed information for decryption in the payload
  144. // This makes it not directly compatible with UnifiedPush
  145. // As of now, we use it purely as a way to trigger a pull
  146. val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
  147. val auth = CryptoUtil.secureRandomBytesEncoded(16)
  148. api.subscribePushNotifications(
  149. "Bearer ${account.accessToken}", account.domain,
  150. endpoint, keyPair.pubkey, auth,
  151. buildSubscriptionData(context, account)
  152. ).onFailure { throwable ->
  153. Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
  154. disableUnifiedPushNotificationsForAccount(context, account)
  155. }.onSuccess {
  156. Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
  157. account.pushPubKey = keyPair.pubkey
  158. account.pushPrivKey = keyPair.privKey
  159. account.pushAuth = auth
  160. account.pushServerKey = it.serverKey
  161. account.unifiedPushUrl = endpoint
  162. accountManager.saveAccount(account)
  163. }
  164. }
  165. // Synchronize the enabled / disabled state of notifications with server-side subscription
  166. suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
  167. withContext(Dispatchers.IO) {
  168. api.updatePushNotificationSubscription(
  169. "Bearer ${account.accessToken}", account.domain,
  170. buildSubscriptionData(context, account)
  171. ).onSuccess {
  172. Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
  173. account.pushServerKey = it.serverKey
  174. accountManager.saveAccount(account)
  175. }
  176. }
  177. }
  178. suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
  179. withContext(Dispatchers.IO) {
  180. api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
  181. .onFailure { throwable ->
  182. Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
  183. }
  184. .onSuccess {
  185. Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
  186. // Clear the URL in database
  187. account.unifiedPushUrl = ""
  188. account.pushServerKey = ""
  189. account.pushAuth = ""
  190. account.pushPrivKey = ""
  191. account.pushPubKey = ""
  192. accountManager.saveAccount(account)
  193. }
  194. }
  195. }