This commit is contained in:
UlrichKu 2024-04-26 14:49:53 +02:00 committed by GitHub
commit aa8d74a200
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1456 additions and 311 deletions

File diff suppressed because it is too large Load Diff

View File

@ -81,9 +81,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
@ -175,6 +173,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@ApplicationScope
lateinit var externalScope: CoroutineScope
@Inject
lateinit var pushNotificationManager: PushNotificationManager
private val binding by viewBinding(ActivityMainBinding::inflate)
private lateinit var header: AccountHeaderView
@ -1050,19 +1051,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
this
)
// Setup push notifications
showMigrationNoticeIfNecessary(
this,
binding.mainCoordinatorLayout,
binding.composeButton,
accountManager
)
// Setup notifications
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
pushNotificationManager.showMigrationNoticeIfNecessary(binding.mainCoordinatorLayout, binding.composeButton)
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
if (pushNotificationManager.canEnablePushNotifications()) {
pushNotificationManager.enablePushNotifications()
} else {
NotificationHelper.enablePullNotifications(this@MainActivity)
}
}
} else {
disableAllNotifications(this, accountManager)
NotificationHelper.disablePullNotifications(this)
lifecycleScope.launch {
pushNotificationManager.disableAllNotifications()
}
}
updateProfiles()

View File

@ -51,8 +51,12 @@ class NotificationFetcher @Inject constructor(
private val context: Context,
private val eventHub: EventHub
) {
suspend fun fetchAndShow() {
suspend fun fetchAndShow(accountId: Long?) {
for (account in accountManager.getAllAccountsOrderedByActive()) {
if (accountId != null && account.id != accountId) {
continue
}
if (account.notificationsEnabled) {
try {
val notificationManager = context.getSystemService(

View File

@ -1,262 +0,0 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@file:JvmName("PushNotificationHelper")
package com.keylesspalace.tusky.components.notifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.CryptoUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
private const val TAG = "PushNotificationHelper"
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.accounts.any(::accountNeedsMigration)
private fun accountNeedsMigration(account: AccountEntity): Boolean =
!account.oauthScopes.contains("push")
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(
context: Context,
parent: View,
anchorView: View?,
accountManager: AccountManager
) {
// No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable(context)) return
if (!anyAccountNeedsMigration(accountManager)) return
val pm = PreferenceManager.getDefaultSharedPreferences(context)
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(anchorView)
.setAction(
R.string.action_details
) { showMigrationExplanationDialog(context, accountManager) }
.show()
}
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
AlertDialog.Builder(context).apply {
if (currentAccountNeedsMigration(accountManager)) {
setMessage(R.string.dialog_push_notification_migration)
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
context.startActivity(
LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
)
}
} else {
setMessage(R.string.dialog_push_notification_migration_other_accounts)
}
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
val pm = PreferenceManager.getDefaultSharedPreferences(context)
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
dialog.dismiss()
}
show()
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(context, api, accountManager, account)
} else {
UnifiedPush.registerAppWithDialog(
context,
account.id.toString(),
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
)
}
}
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
// Not registered
return
}
UnifiedPush.unregisterApp(context, account.id.toString())
}
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
account.unifiedPushUrl.isNotEmpty()
private fun isUnifiedPushAvailable(context: Context): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
suspend fun enablePushNotificationsWithFallback(
context: Context,
api: MastodonApi,
accountManager: AccountManager
) {
if (!canEnablePushNotifications(context, accountManager)) {
// No UP distributors
NotificationHelper.enablePullNotifications(context)
return
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
accountManager.accounts.forEach {
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
if (shouldEnable) {
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
} else {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
}
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
accountManager.accounts.forEach {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
disablePushNotifications(context, accountManager)
NotificationHelper.disablePullNotifications(context)
}
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
buildMap {
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
Notification.Type.visibleTypes.forEach {
put(
"data[alerts][${it.presentation}]",
NotificationHelper.filterNotification(notificationManager, account, it)
)
}
}
// Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity,
endpoint: String
) = withContext(Dispatchers.IO) {
// Generate a prime256v1 key pair for WebPush
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
// standard which does not send needed information for decryption in the payload
// This makes it not directly compatible with UnifiedPush
// As of now, we use it purely as a way to trigger a pull
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16)
api.subscribePushNotifications(
"Bearer ${account.accessToken}",
account.domain,
endpoint,
keyPair.pubkey,
auth,
buildSubscriptionData(context, account)
).onFailure { throwable ->
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
disableUnifiedPushNotificationsForAccount(context, account)
}.onSuccess {
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
account.pushPubKey = keyPair.pubkey
account.pushPrivKey = keyPair.privKey
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription
suspend fun updateUnifiedPushSubscription(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
withContext(Dispatchers.IO) {
api.updatePushNotificationSubscription(
"Bearer ${account.accessToken}",
account.domain,
buildSubscriptionData(context, account)
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
account.pushServerKey = it.serverKey
accountManager.saveAccount(account)
}
}
}
suspend fun unregisterUnifiedPushEndpoint(
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
withContext(Dispatchers.IO) {
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
.onFailure { throwable ->
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
}
.onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
// Clear the URL in database
account.unifiedPushUrl = ""
account.pushServerKey = ""
account.pushAuth = ""
account.pushPrivKey = ""
account.pushPubKey = ""
accountManager.saveAccount(account)
}
}
}

View File

@ -0,0 +1,311 @@
package com.keylesspalace.tusky.components.notifications
import android.app.NotificationManager
import com.keylesspalace.tusky.db.AccountManager
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.CryptoUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
import retrofit2.HttpException
import javax.inject.Inject
// TODO architecture-wise: the NotificationHelper should probably be a NotificationManager which either uses
// pull or push notifications (two detail implementations?).
// You can see current problems for example in the old NotificationPreferencesFragment.onCreatePreferences()
// which only would use pull notifications if the notifications option is enabled.
class PushNotificationManager @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val sharedPreferences: SharedPreferences,
private val context: Context
): Preference.SummaryProvider<Preference> {
companion object {
const val TAG = "PushNotificationManager"
private const val KEY_PUSH_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
}
// TODO? must be changed/extended when distributors are installed or uninstalled on-the-fly?
// Or there must be an "restart app fully" possibility.
private val distributors: List<String> = UnifiedPush.getDistributors(context)
private fun isUnifiedPushAvailable(): Boolean {
return distributors.isNotEmpty()
}
// TODO! there should be an actual decision (possibility) to say "I don't want to use push notifications for Tusky".
fun canEnablePushNotifications(): Boolean =
isUnifiedPushAvailable() && !anyAccountNeedsMigration()
fun hasPushNotificationsEnabled(account: AccountEntity): Boolean =
isUnifiedPushAvailable() && !account.unifiedDistributorName.isNullOrEmpty()
suspend fun enablePushNotifications() {
if (!canEnablePushNotifications()) {
return
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
accountManager.accounts.forEach {
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
if (shouldEnable) {
enableUnifiedPushNotificationsForAccount(it)
} else {
disableUnifiedPushNotificationsForAccount(it)
}
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(account: AccountEntity) {
// TODO/NOTE these api request(s) here take quite some time (100-1000ms each for GET for my 3 instances)
val currentSubscription = getActiveSubscription(account)
if (currentSubscription != null && hasActiveDistributor(account)) {
val alertData = buildAlertsMap(account)
if (alertData != currentSubscription.alerts) {
// Update the subscription to match notification settings
updateUnifiedPushSubscription(account)
}
} else {
// When changing the local UP distributor this is necessary first to enable the following callbacks (i. e. onNewEndpoint);
// make sure this is done in any inconsistent case (is not too often and doesn't hurt).
unregisterUnifiedPushEndpoint(account)
UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
// TODO? if this does not result in a call to registerUnifiedPushEndpoint, something has failed
}
}
private suspend fun getActiveSubscription(account: AccountEntity): NotificationSubscribeResult? {
mastodonApi.pushNotificationSubscription(
"Bearer ${account.accessToken}",
account.domain
).fold({
if (account.unifiedPushUrl.isNotEmpty() && it.endpoint != account.unifiedPushUrl) {
Log.w(TAG, "Server push endpoint does not match previously registered one: "+it.endpoint+" vs. "+account.unifiedPushUrl)
// TODO there should be a user information or at least an occurrence log entry
return null
// TODO / NOTE this case could also happen regularly if you use the same account on two different devices
// the server will only support (?) on subscription but you will need two for two devices (?)
}
return it
}, {
if (!(it is HttpException && it.code() == 404)) {
Log.e(TAG, "Cannot get push subscription for account " + account.id + ": " + it.message, it)
return null
}
// else this is alright; there is no subscription on server
return null
})
}
suspend fun disableUnifiedPushNotificationsForAccount(account: AccountEntity) {
if (account.unifiedDistributorName == null) {
return
}
unregisterUnifiedPushEndpoint(account)
// this probably does nothing (distributor to handle this is missing)
UnifiedPush.unregisterApp(context, account.id.toString())
}
private fun hasActiveDistributor(account: AccountEntity): Boolean {
if (account.unifiedDistributorName.isNullOrEmpty()) {
return false
}
val distributors = UnifiedPush.getDistributors(context)
return distributors.find { it == account.unifiedDistributorName } != null
}
private fun getDistributorUsedByApp(): String? {
return UnifiedPush.getDistributor(context).ifEmpty { null }
}
suspend fun disableAllNotifications() {
accountManager.accounts.forEach {
disableUnifiedPushNotificationsForAccount(it)
}
}
private fun buildAlertsMap(account: AccountEntity): Map<String, Boolean> =
buildMap {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
Notification.Type.visibleTypes.forEach {
put(it.presentation, NotificationHelper.filterNotification(notificationManager, account, it))
}
}
private fun buildAlertSubscriptionData(account: AccountEntity): Map<String, Boolean> =
buildAlertsMap(account).mapKeys { "data[alerts][${it.key}]" }
// Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(
account: AccountEntity,
endpoint: String
) = withContext(Dispatchers.IO) {
// Generate a prime256v1 key pair for WebPush
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
// standard which does not send needed information for decryption in the payload
// This makes it not directly compatible with UnifiedPush
// As of now, we use it purely as a way to trigger a pull
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16)
mastodonApi.subscribePushNotifications(
"Bearer ${account.accessToken}",
account.domain,
endpoint,
keyPair.pubkey,
auth,
buildAlertSubscriptionData(account)
).onFailure { throwable ->
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
disableUnifiedPushNotificationsForAccount(account)
}.onSuccess {
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
val distributor = UnifiedPush.getDistributor(context)
// TODO? none of these are used ever again (except distributor name and endpoint)
account.unifiedDistributorName = distributor
account.pushPubKey = keyPair.pubkey
account.pushPrivKey = keyPair.privKey
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription
suspend fun updateUnifiedPushSubscription(account: AccountEntity) {
withContext(Dispatchers.IO) {
val alertsData = buildAlertSubscriptionData(account)
mastodonApi.updatePushNotificationSubscription(
"Bearer ${account.accessToken}",
account.domain,
alertsData
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
account.pushServerKey = it.serverKey
accountManager.saveAccount(account)
}
}
}
suspend fun unregisterUnifiedPushEndpoint(account: AccountEntity) {
withContext(Dispatchers.IO) {
// NOTE this is also possible (successful) when there is no subscription present on the server.
mastodonApi.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
.onFailure { throwable ->
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
}
.onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
account.unifiedDistributorName = null
account.unifiedPushUrl = ""
account.pushServerKey = ""
account.pushAuth = ""
account.pushPrivKey = ""
account.pushPubKey = ""
accountManager.saveAccount(account)
}
}
}
// TODO reduce this "migration feature" here; the code should probably also always check for "push" in the
// authorization as a normal feature - it could be missing by intent?
private fun anyAccountNeedsMigration(): Boolean =
accountManager.accounts.any(::accountNeedsMigration)
private fun accountNeedsMigration(account: AccountEntity): Boolean =
!account.oauthScopes.contains("push")
fun currentAccountNeedsMigration(): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(
parent: View,
anchorView: View?
) {
// No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable()) return
if (!anyAccountNeedsMigration()) return
if (sharedPreferences.getBoolean(KEY_PUSH_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(anchorView)
.setAction(R.string.action_details) { showMigrationExplanationDialog() }
.show()
}
private fun showMigrationExplanationDialog() {
AlertDialog.Builder(context).apply {
// TODO what if another account needs migration? Only finally dismissing is possible?
if (currentAccountNeedsMigration()) {
setMessage(R.string.dialog_push_notification_migration)
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION))
}
} else {
setMessage(R.string.dialog_push_notification_migration_other_accounts)
}
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
// NOTE there is a corresponding preference in AccountPreferencesFragment (only depending on currentAccountNeedsMigration()).
sharedPreferences.edit().putBoolean(KEY_PUSH_MIGRATION_NOTICE_DISMISSED, true).apply()
dialog.dismiss()
}
show()
}
}
override fun provideSummary(preference: Preference): CharSequence? {
return when(val distributor = getDistributorUsedByApp()) {
"io.heckel.ntfy" -> "NTFY"
"org.unifiedpush.distributor.fcm" -> "UP-FCM"
"org.unifiedpush.distributor.nextpush " -> "NextPush"
else -> distributor
}
}
}

View File

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
@ -75,6 +75,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
@Inject
lateinit var pushNotificationManager: PushNotificationManager
private val iconSize by unsafeLazy {
resources.getDimensionPixelSize(
R.dimen.preference_icon_size
@ -151,7 +154,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
if (currentAccountNeedsMigration(accountManager)) {
if (pushNotificationManager.currentAccountNeedsMigration()) {
preference {
setTitle(R.string.title_migration_relogin)
setIcon(R.drawable.ic_logout)

View File

@ -19,6 +19,7 @@ import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Notification
@ -49,6 +50,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var localeManager: LocaleManager
@Inject
lateinit var pushNotificationManager: PushNotificationManager
private val iconSize by unsafeLazy {
resources.getDimensionPixelSize(
R.dimen.preference_icon_size
@ -305,6 +309,15 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
summaryProvider = ProxyPreferencesFragment.SummaryProvider
}
}
if (pushNotificationManager.canEnablePushNotifications()) {
preferenceCategory(R.string.pref_title_push_notifications) {
preference {
setTitle(R.string.pref_title_push_notifications_distributor)
summaryProvider = pushNotificationManager
}
}
}
}
}

View File

@ -92,6 +92,7 @@ data class AccountEntity(
// Scope cannot be changed without re-login, so store it in case
// the scope needs to be changed in the future
var oauthScopes: String = "",
var unifiedDistributorName: String? = null, // TODO! there can be only one distributor per context (app); save as setting?
var unifiedPushUrl: String = "",
var pushPubKey: String = "",
var pushPrivKey: String = "",

View File

@ -44,14 +44,15 @@ import java.io.File;
},
// Note: Starting with version 54, database versions in Tusky are always even.
// This is to reserve odd version numbers for use by forks.
version = 58,
version = 60,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@AutoMigration(from = 50, to = 51),
@AutoMigration(from = 51, to = 52),
@AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity
@AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 58, to = 60) // AccountEntity gets a 'unifiedDistributorName'
}
)
public abstract class AppDatabase extends RoomDatabase {

View File

@ -22,5 +22,6 @@ import com.squareup.moshi.JsonClass
data class NotificationSubscribeResult(
val id: Int,
val endpoint: String,
val alerts: Map<String, Boolean>,
@Json(name = "server_key") val serverKey: String
)

View File

@ -660,6 +660,12 @@ interface MastodonApi {
@Field("comment") note: String
): NetworkResult<Relationship>
@GET("api/v1/push/subscription")
suspend fun pushNotificationSubscription(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String
): NetworkResult<NotificationSubscribeResult>
@FormUrlEncoded
@POST("api/v1/push/subscription")
suspend fun subscribePushNotifications(

View File

@ -20,9 +20,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi
@ -32,11 +30,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
/**
* This listens for changed notification channel settings (from the Android system) and updates an account's push
* subscription if active.
*/
@DelicateCoroutinesApi
class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@ -44,10 +43,13 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@ApplicationScope
lateinit var externalScope: CoroutineScope
@Inject
lateinit var notificationManager: PushNotificationManager
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
if (Build.VERSION.SDK_INT < 28) return
if (!canEnablePushNotifications(context, accountManager)) return
if (!notificationManager.canEnablePushNotifications()) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -63,16 +65,11 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
} ?: return
accountManager.getAccountByIdentifier(gid)?.let { account ->
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// TODO how did the changed (system) setting end up in the account object here (for example in field AccountEntity:notificationsMentioned)?
if (notificationManager.hasPushNotificationsEnabled(account)) {
// Update UnifiedPush notification subscription
externalScope.launch {
updateUnifiedPushSubscription(
context,
mastodonApi,
accountManager,
account
)
}
externalScope.launch { notificationManager.updateUnifiedPushSubscription(account) }
}
}
}

View File

@ -18,10 +18,10 @@ package com.keylesspalace.tusky.receiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi
@ -43,7 +43,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
lateinit var accountManager: AccountManager
@Inject
lateinit var mastodonApi: MastodonApi
lateinit var pushNotificationManager: PushNotificationManager
@Inject
@ApplicationScope
@ -57,21 +57,32 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
override fun onMessage(context: Context, message: ByteArray, instance: String) {
AndroidInjection.inject(this, context)
Log.d(TAG, "New message received for account $instance")
val data = Data.Builder()
data.putLong(NotificationWorker.KEY_ACCOUNT_ID, instance.toLongOrNull() ?: 0)
val request = OneTimeWorkRequest
.Builder(NotificationWorker::class.java)
.setInputData(data.build())
.build()
val workManager = WorkManager.getInstance(context)
val request = OneTimeWorkRequest.from(NotificationWorker::class.java)
workManager.enqueue(request)
// Do we want a rate limiting here? I think, yes.
// At least it puts network load on as long as the push notifications are not shown directly.
// And after that it should still be a setting.
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
AndroidInjection.inject(this, context)
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
accountManager.getAccountById(instance.toLong())?.let {
externalScope.launch {
registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint)
}
externalScope.launch { pushNotificationManager.registerUnifiedPushEndpoint(it, endpoint) }
}
}
// TODO hm?
override fun onRegistrationFailed(context: Context, instance: String) = Unit
override fun onUnregistered(context: Context, instance: String) {
@ -79,7 +90,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
Log.d(TAG, "Endpoint unregistered for account $instance")
accountManager.getAccountById(instance.toLong())?.let {
// It's fine if the account does not exist anymore -- that means it has been logged out
externalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) }
externalScope.launch { pushNotificationManager.unregisterUnifiedPushEndpoint(it) }
}
}
}

View File

@ -3,7 +3,7 @@ package com.keylesspalace.tusky.usecase
import android.content.Context
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.components.notifications.PushNotificationManager
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
@ -16,7 +16,8 @@ class LogoutUsecase @Inject constructor(
private val db: AppDatabase,
private val accountManager: AccountManager,
private val draftHelper: DraftHelper,
private val shareShortcutHelper: ShareShortcutHelper
private val shareShortcutHelper: ShareShortcutHelper,
private val pushNotificationManager: PushNotificationManager
) {
/**
@ -39,16 +40,16 @@ class LogoutUsecase @Inject constructor(
}
// disable push notifications
disableUnifiedPushNotificationsForAccount(context, activeAccount)
pushNotificationManager.disableUnifiedPushNotificationsForAccount(activeAccount)
// clear notification channels
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context)
// disable pull notifications
if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) {
NotificationHelper.disablePullNotifications(context)
}
// clear notification channels
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context)
// remove account from local AccountManager
val otherAccountAvailable = accountManager.logActiveAccountOut() != null

View File

@ -40,7 +40,8 @@ class NotificationWorker(
)
override suspend fun doWork(): Result {
notificationsFetcher.fetchAndShow()
val accountId = inputData.getLong(KEY_ACCOUNT_ID, 0).takeIf { it != 0L }
notificationsFetcher.fetchAndShow(accountId)
return Result.success()
}
@ -56,4 +57,8 @@ class NotificationWorker(
return NotificationWorker(appContext, params, notificationsFetcher)
}
}
companion object {
const val KEY_ACCOUNT_ID = "accountId"
}
}

View File

@ -333,6 +333,9 @@
<string name="pref_summary_http_proxy_missing">&lt;not set></string>
<string name="pref_summary_http_proxy_invalid">&lt;invalid></string>
<string name="pref_title_push_notifications">Push notifications</string>
<string name="pref_title_push_notifications_distributor">Distributor</string>
<string name="pref_default_post_privacy">Default post privacy</string>
<string name="pref_default_post_language">Default posting language</string>
<string name="pref_default_media_sensitivity">Always mark media as sensitive</string>
@ -730,7 +733,7 @@
- Favorite/Boost/Follow notifications\n
- Favorite/Boost count on posts\n
- Follower/Post stats on profiles\n\n
Push-notifications will not be affected, but you can review your notification preferences manually.
Push notifications will not be affected, but you can review your notification preferences manually.
</string>
<string name="review_notifications">Review Notifications</string>
<string name="limit_notifications">Limit timeline notifications</string>