Merge 733fc87007
into 197a1f4eda
This commit is contained in:
commit
aa8d74a200
File diff suppressed because it is too large
Load Diff
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = "",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -333,6 +333,9 @@
|
|||
<string name="pref_summary_http_proxy_missing"><not set></string>
|
||||
<string name="pref_summary_http_proxy_invalid"><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>
|
||||
|
|
Loading…
Reference in New Issue