Improve push notifications (#4896)
Besides the refactoring these improvements: * Track last push distributor and reset settings and subscription on any incompatible change (ie. uninstall) * Only update (push) notification settings on server if needed * Allow to only fetch notifications for one account (the one for which a push message was received) This is (also) the revival of https://github.com/tuskyapp/Tusky/pull/3642 It's not really well tested so far. (Ie. with two or more accounts or two or more push providers.)
This commit is contained in:
parent
f95dbe82b0
commit
6450af6edb
14 changed files with 274 additions and 127 deletions
|
|
@ -27,6 +27,7 @@ import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
|||
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
|
@ -97,10 +98,10 @@ class MainViewModel @Inject constructor(
|
|||
|
||||
shareShortcutHelper.updateShortcuts()
|
||||
|
||||
setupNotifications()
|
||||
setupNotifications(activeAccount)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w(TAG, "Failed to fetch user info.", throwable)
|
||||
Log.e(TAG, "Failed to fetch user info.", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -160,15 +161,24 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setupNotifications() {
|
||||
notificationService.createNotificationChannelsForAccount(activeAccount)
|
||||
fun setupNotifications(account: AccountEntity? = null) {
|
||||
// TODO this is only called on full app (re) start; so changes in-between (push distributor uninstalled/subscription changed, or
|
||||
// notifications fully disabled) will get unnoticed; and also an app restart cannot be easily triggered by the user.
|
||||
|
||||
if (notificationService.areNotificationsEnabled()) {
|
||||
if (account != null) {
|
||||
// TODO it's quite odd to separate channel creation (for an account) from the "is enabled by channels" question below
|
||||
|
||||
notificationService.createNotificationChannelsForAccount(account)
|
||||
}
|
||||
|
||||
if (notificationService.areNotificationsEnabledBySystem()) {
|
||||
viewModelScope.launch {
|
||||
notificationService.enablePushNotificationsWithFallback()
|
||||
notificationService.setupNotifications(account)
|
||||
}
|
||||
} else {
|
||||
notificationService.disableAllNotifications()
|
||||
viewModelScope.launch {
|
||||
notificationService.disableAllNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,13 +68,19 @@ class TuskyApplication : Application(), Configuration.Provider {
|
|||
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
val workManager = WorkManager.getInstance(this)
|
||||
|
||||
// Migrate shared preference keys and defaults from version to version.
|
||||
val oldVersion = preferences.getInt(
|
||||
PrefKeys.SCHEMA_VERSION,
|
||||
NEW_INSTALL_SCHEMA_VERSION
|
||||
)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
// TODO SCHEMA_VERSION is outdated / not updated in code
|
||||
if (oldVersion < 2025021701) {
|
||||
// A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one
|
||||
workManager.cancelAllWorkByTag("pullNotifications")
|
||||
}
|
||||
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +99,7 @@ class TuskyApplication : Application(), Configuration.Provider {
|
|||
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
PruneCacheWorker.PERIODIC_WORK_TAG,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
pruneCacheWorker
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
|
|||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
val context = requireContext()
|
||||
makePreferenceScreen {
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_title_notifications_enabled)
|
||||
|
|
@ -49,7 +48,7 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
|
|||
isChecked = activeAccount.notificationsEnabled
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { copy(notificationsEnabled = newValue as Boolean) }
|
||||
if (notificationService.areNotificationsEnabled()) {
|
||||
if (notificationService.areNotificationsEnabledBySystem()) {
|
||||
notificationService.enablePullNotifications()
|
||||
} else {
|
||||
notificationService.disablePullNotifications()
|
||||
|
|
|
|||
|
|
@ -45,8 +45,12 @@ class NotificationFetcher @Inject constructor(
|
|||
private val eventHub: EventHub,
|
||||
private val notificationService: NotificationService,
|
||||
) {
|
||||
suspend fun fetchAndShow() {
|
||||
suspend fun fetchAndShow(accountId: Long?) {
|
||||
for (account in accountManager.accounts) {
|
||||
if (accountId != null && account.id != accountId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (account.notificationsEnabled) {
|
||||
try {
|
||||
val notifications = fetchNewNotifications(account)
|
||||
|
|
@ -137,7 +141,7 @@ class NotificationFetcher @Inject constructor(
|
|||
// Save the newest notification ID in the marker.
|
||||
notifications.firstOrNull()?.let {
|
||||
val newMarkerId = notifications.first().id
|
||||
Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId")
|
||||
Log.d(TAG, "Updating notification marker for ${account.fullName} to: $newMarkerId")
|
||||
mastodonApi.updateMarkersWithAuth(
|
||||
auth = authHeader,
|
||||
domain = account.domain,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import android.app.NotificationManager
|
|||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
|
|
@ -23,12 +24,14 @@ import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
|||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.bumptech.glide.Glide
|
||||
|
|
@ -42,9 +45,12 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
|
|||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.CryptoUtil
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
|
|
@ -56,24 +62,31 @@ import java.util.concurrent.ExecutionException
|
|||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import retrofit2.HttpException
|
||||
|
||||
@Singleton
|
||||
class NotificationService @Inject constructor(
|
||||
private val notificationManager: NotificationManager,
|
||||
private val accountManager: AccountManager,
|
||||
private val api: MastodonApi,
|
||||
private val preferences: SharedPreferences,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val applicationScope: CoroutineScope,
|
||||
) {
|
||||
private var workManager: WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
private var notificationId: Int = NOTIFICATION_ID_PRUNE_CACHE + 1
|
||||
|
||||
init {
|
||||
createWorkerNotificationChannel()
|
||||
}
|
||||
|
||||
fun areNotificationsEnabled(): Boolean {
|
||||
fun areNotificationsEnabledBySystem(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// on Android >= O, notifications are enabled, if at least one channel is enabled
|
||||
|
||||
|
|
@ -94,6 +107,35 @@ class NotificationService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setupNotifications(account: AccountEntity?) {
|
||||
resetPushWhenDistributorIsMissing()
|
||||
|
||||
if (arePushNotificationsAvailable()) {
|
||||
setupPushNotifications(account)
|
||||
}
|
||||
|
||||
// At least as a fallback and otherwise as main source when there are no push distributors installed:
|
||||
enablePullNotifications()
|
||||
}
|
||||
|
||||
fun enablePullNotifications() {
|
||||
val workRequest: PeriodicWorkRequest = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(NOTIFICATION_PULL_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest)
|
||||
|
||||
Log.d(TAG, "Enabled pull checks with ${PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60000} minutes interval.")
|
||||
}
|
||||
|
||||
fun createNotificationChannelsForAccount(account: AccountEntity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
data class ChannelData(
|
||||
|
|
@ -102,7 +144,6 @@ class NotificationService @Inject constructor(
|
|||
@StringRes val description: Int,
|
||||
)
|
||||
|
||||
// TODO REBLOG and esp. STATUS have very different names than the type itself.
|
||||
val channelData = arrayOf(
|
||||
ChannelData(
|
||||
getChannelId(account, Notification.Type.MENTION)!!,
|
||||
|
|
@ -175,44 +216,28 @@ class NotificationService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun deleteNotificationChannelsForAccount(account: AccountEntity) {
|
||||
private fun deleteNotificationChannelsForAccount(account: AccountEntity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannelGroup(account.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
fun enablePullNotifications() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG)
|
||||
|
||||
// Periodic work requests are supposed to start running soon after being enqueued. In
|
||||
// practice that may not be soon enough, so create and enqueue an expedited one-time
|
||||
// request to get new notifications immediately.
|
||||
val fetchNotifications: WorkRequest = OneTimeWorkRequest.Builder(NotificationWorker::class.java)
|
||||
private fun enqueueOneTimeWorker(account: AccountEntity?) {
|
||||
val oneTimeRequestBuilder = OneTimeWorkRequest.Builder(NotificationWorker::class.java)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
workManager.enqueue(fetchNotifications)
|
||||
|
||||
val workRequest: WorkRequest = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
.addTag(NOTIFICATION_PULL_TAG)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setInitialDelay(5, TimeUnit.MINUTES)
|
||||
.build()
|
||||
account?.let {
|
||||
val data = Data.Builder()
|
||||
data.putLong(NotificationWorker.KEY_ACCOUNT_ID, account.id)
|
||||
oneTimeRequestBuilder.setInputData(data.build())
|
||||
}
|
||||
|
||||
workManager.enqueue(workRequest)
|
||||
|
||||
Log.d(TAG, "Enabled pull checks with " + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval.")
|
||||
workManager.enqueue(oneTimeRequestBuilder.build())
|
||||
}
|
||||
|
||||
fun disablePullNotifications() {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG)
|
||||
workManager.cancelUniqueWork(NOTIFICATION_PULL_NAME)
|
||||
Log.d(TAG, "Disabled pull checks.")
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +290,7 @@ class NotificationService @Inject constructor(
|
|||
val summary = createSummaryNotification(account, type, notificationsForOneType) ?: continue
|
||||
|
||||
// NOTE Enqueue the summary first: Needed to avoid rate limit problems:
|
||||
// ie. single notification is enqueued but that later summary one is filtered and thus no grouping
|
||||
// ie. single notification is enqueued but later the summary one is filtered and thus no grouping
|
||||
// takes place.
|
||||
newNotifications.add(summary)
|
||||
|
||||
|
|
@ -388,7 +413,7 @@ class NotificationService @Inject constructor(
|
|||
/**
|
||||
* Create a notification that summarises the other notifications in this group.
|
||||
*
|
||||
* NOTE: We always create a summary notification (even for activeNotificationsForType.size() == 1):
|
||||
* NOTE: We always create a summary notification (even for only one notification of that type):
|
||||
* - No need to especially track the grouping
|
||||
* - No need to change an existing single notification when there arrives another one of its group
|
||||
* - Only the summary one will get announced
|
||||
|
|
@ -737,78 +762,162 @@ class NotificationService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun disableAllNotifications() {
|
||||
suspend fun disableAllNotifications() {
|
||||
disablePushNotificationsForAllAccounts()
|
||||
disablePullNotifications()
|
||||
}
|
||||
|
||||
suspend fun disableNotificationsForAccount(account: AccountEntity) {
|
||||
disablePushNotificationsForAccount(account)
|
||||
|
||||
deleteNotificationChannelsForAccount(account)
|
||||
|
||||
if (!areNotificationsEnabledBySystem()) {
|
||||
// TODO this is sort of a hack, it means: are there now no active accounts?
|
||||
|
||||
disablePullNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Push notification section
|
||||
//
|
||||
|
||||
fun isUnifiedPushAvailable(): Boolean =
|
||||
fun arePushNotificationsAvailable(): Boolean =
|
||||
UnifiedPush.getDistributors(context).isNotEmpty()
|
||||
|
||||
suspend fun enablePushNotificationsWithFallback() {
|
||||
if (!isUnifiedPushAvailable()) {
|
||||
// No distributors
|
||||
enablePullNotifications()
|
||||
return
|
||||
private suspend fun setupPushNotifications(account: AccountEntity?) {
|
||||
val relevantAccounts: List<AccountEntity> = if (account != null) {
|
||||
listOf(account)
|
||||
} else {
|
||||
accountManager.accounts
|
||||
}
|
||||
|
||||
accountManager.accounts.forEach {
|
||||
relevantAccounts.forEach {
|
||||
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
|
||||
notificationManager.getNotificationChannelGroup(it.identifier)?.isBlocked == false
|
||||
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
|
||||
|
||||
if (shouldEnable) {
|
||||
enableUnifiedPushNotificationsForAccount(it)
|
||||
setupPushNotificationsForAccount(it)
|
||||
Log.d(TAG, "Enabled push notifications for account ${it.id}.")
|
||||
} else {
|
||||
disableUnifiedPushNotificationsForAccount(it)
|
||||
disablePushNotificationsForAccount(it)
|
||||
Log.d(TAG, "Disabled push notifications for account ${it.id}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enableUnifiedPushNotificationsForAccount(account: AccountEntity) {
|
||||
if (account.isPushNotificationsEnabled()) {
|
||||
// Already registered, update the subscription to match notification settings
|
||||
updateUnifiedPushSubscription(account)
|
||||
private suspend fun setupPushNotificationsForAccount(account: AccountEntity) {
|
||||
val currentSubscription = getActiveSubscription(account)
|
||||
|
||||
if (currentSubscription != null) {
|
||||
val alertData = buildAlertsMap(account)
|
||||
|
||||
if (alertData != currentSubscription.alerts) {
|
||||
// Update the subscription to match notification settings
|
||||
updatePushSubscription(account)
|
||||
} else {
|
||||
Log.d(TAG, "Nothing to be done. Current push subscription matches for account ${account.id}.")
|
||||
}
|
||||
} else {
|
||||
UnifiedPush.registerAppWithDialog(
|
||||
context,
|
||||
account.id.toString(),
|
||||
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
|
||||
)
|
||||
Log.d(TAG, "Trying to create a UnifiedPush subscription for account ${account.id}")
|
||||
|
||||
// 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).
|
||||
unregisterPushEndpoint(account)
|
||||
|
||||
UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
|
||||
// Will lead to call of registerPushEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushNotificationsForAllAccounts() {
|
||||
accountManager.accounts.forEach {
|
||||
disableUnifiedPushNotificationsForAccount(it)
|
||||
}
|
||||
}
|
||||
private fun resetPushWhenDistributorIsMissing() {
|
||||
val lastUsedPushProvider = preferences.getString(PrefKeys.LAST_USED_PUSH_PROVDER, null)
|
||||
// NOTE UnifiedPush.getSavedDistributor() cannot be used here as that is already null here if the
|
||||
// distributor was uninstalled.
|
||||
|
||||
fun disableUnifiedPushNotificationsForAccount(account: AccountEntity) {
|
||||
if (!account.isPushNotificationsEnabled()) {
|
||||
// Not registered
|
||||
if (lastUsedPushProvider.isNullOrEmpty() || UnifiedPush.getDistributors(context).contains(lastUsedPushProvider)) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Previous push provider ($lastUsedPushProvider) uninstalled. Resetting all accounts.")
|
||||
|
||||
val editor = preferences.edit()
|
||||
editor.remove(PrefKeys.LAST_USED_PUSH_PROVDER)
|
||||
editor.apply()
|
||||
|
||||
applicationScope.launch {
|
||||
accountManager.accounts.forEach {
|
||||
// reset all accounts, also does resetPushSettingsInAccount()
|
||||
unregisterPushEndpoint(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getActiveSubscription(account: AccountEntity): NotificationSubscribeResult? {
|
||||
api.pushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain
|
||||
).fold(
|
||||
onSuccess = {
|
||||
if (!account.matchesPushSubscription(it.endpoint)) {
|
||||
Log.w(TAG, "Server push endpoint does not match previously registered one: ${it.endpoint} vs. ${account.unifiedPushUrl}")
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return it
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
// this is alright; there is no subscription on the server
|
||||
return null
|
||||
}
|
||||
|
||||
Log.e(TAG, "Cannot get push subscription for account " + account.id + ": " + throwable.message, throwable)
|
||||
return null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun disablePushNotificationsForAllAccounts() {
|
||||
accountManager.accounts.forEach {
|
||||
disablePushNotificationsForAccount(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun disablePushNotificationsForAccount(account: AccountEntity) {
|
||||
if (!account.isPushNotificationsEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
unregisterPushEndpoint(account)
|
||||
|
||||
// this probably does nothing (distributor to handle this is missing)
|
||||
UnifiedPush.unregisterApp(context, account.id.toString())
|
||||
}
|
||||
|
||||
private fun buildSubscriptionData(account: AccountEntity): Map<String, Boolean> =
|
||||
fun fetchNotificationsOnPushMessage(account: AccountEntity) {
|
||||
// TODO should there be a rate limit here? Ie. we could be silent (can we?) for another notification in a short timeframe.
|
||||
|
||||
Log.d(TAG, "Fetching notifications because of push for account ${account.id}")
|
||||
|
||||
enqueueOneTimeWorker(account)
|
||||
}
|
||||
|
||||
private fun buildAlertsMap(account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
Notification.Type.visibleTypes.forEach {
|
||||
put(
|
||||
"data[alerts][${it.presentation}]",
|
||||
filterNotification(account, it)
|
||||
)
|
||||
put(it.presentation, filterNotification(account, it))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAlertSubscriptionData(account: AccountEntity): Map<String, Boolean> =
|
||||
buildAlertsMap(account).mapKeys { "data[alerts][${it.key}]" }
|
||||
|
||||
// Called by UnifiedPush callback in UnifiedPushBroadcastReceiver
|
||||
suspend fun registerUnifiedPushEndpoint(
|
||||
suspend fun registerPushEndpoint(
|
||||
account: AccountEntity,
|
||||
endpoint: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
|
|
@ -817,7 +926,6 @@ class NotificationService @Inject constructor(
|
|||
// 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
|
||||
// TODO that is still correct?
|
||||
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
|
||||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
|
|
@ -827,10 +935,10 @@ class NotificationService @Inject constructor(
|
|||
endpoint,
|
||||
keyPair.pubkey,
|
||||
auth,
|
||||
buildSubscriptionData(account)
|
||||
buildAlertSubscriptionData(account)
|
||||
).onFailure { throwable ->
|
||||
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
|
||||
disableUnifiedPushNotificationsForAccount(account)
|
||||
disablePushNotificationsForAccount(account)
|
||||
}.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
|
||||
|
||||
|
|
@ -843,26 +951,38 @@ class NotificationService @Inject constructor(
|
|||
unifiedPushUrl = endpoint
|
||||
)
|
||||
}
|
||||
|
||||
UnifiedPush.getAckDistributor(context)?.let {
|
||||
Log.d(TAG, "Saving distributor to preferences: $it")
|
||||
|
||||
val editor = preferences.edit()
|
||||
editor.putString(PrefKeys.LAST_USED_PUSH_PROVDER, it)
|
||||
editor.apply()
|
||||
|
||||
// TODO once this is selected it cannot be changed (except by wiping the application or uninstalling the provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription
|
||||
suspend fun updateUnifiedPushSubscription(account: AccountEntity) {
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription (also NotificationBlockStateBroadcastReceiver).
|
||||
suspend fun updatePushSubscription(account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.updatePushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.domain,
|
||||
buildSubscriptionData(account)
|
||||
buildAlertSubscriptionData(account)
|
||||
).onSuccess {
|
||||
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
|
||||
accountManager.updateAccount(account) {
|
||||
copy(pushServerKey = it.serverKey)
|
||||
}
|
||||
}.onFailure { throwable ->
|
||||
Log.e(TAG, "Could not update subscription ${throwable.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterUnifiedPushEndpoint(account: AccountEntity) {
|
||||
suspend fun unregisterPushEndpoint(account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure { throwable ->
|
||||
|
|
@ -870,20 +990,23 @@ class NotificationService @Inject constructor(
|
|||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
// Clear the URL in database
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = "",
|
||||
pushPrivKey = "",
|
||||
pushAuth = "",
|
||||
pushServerKey = "",
|
||||
unifiedPushUrl = ""
|
||||
)
|
||||
}
|
||||
resetPushSettingsInAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resetPushSettingsInAccount(account: AccountEntity) {
|
||||
accountManager.updateAccount(account) {
|
||||
copy(
|
||||
pushPubKey = "",
|
||||
pushPrivKey = "",
|
||||
pushAuth = "",
|
||||
pushServerKey = "",
|
||||
unifiedPushUrl = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NotificationService"
|
||||
|
||||
|
|
@ -905,6 +1028,6 @@ class NotificationService @Inject constructor(
|
|||
private const val EXTRA_ACCOUNT_NAME = BuildConfig.APPLICATION_ID + ".notification.extra.account_name"
|
||||
private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type"
|
||||
private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary"
|
||||
private const val NOTIFICATION_PULL_TAG = "pullNotifications"
|
||||
private const val NOTIFICATION_PULL_NAME = "pullNotifications"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,4 +130,8 @@ data class AccountEntity(
|
|||
fun isPushNotificationsEnabled(): Boolean {
|
||||
return unifiedPushUrl.isNotEmpty()
|
||||
}
|
||||
|
||||
fun matchesPushSubscription(endpoint: String): Boolean {
|
||||
return unifiedPushUrl == endpoint
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -666,6 +666,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(
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (Build.VERSION.SDK_INT < 28) return
|
||||
if (!notificationService.isUnifiedPushAvailable()) return
|
||||
if (!notificationService.arePushNotificationsAvailable()) return
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val gid = when (intent.action) {
|
||||
val accountIdentifier = when (intent.action) {
|
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> {
|
||||
val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID)
|
||||
nm.getNotificationChannel(channelId).group
|
||||
|
|
@ -61,10 +61,10 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
|
|||
else -> null
|
||||
} ?: return
|
||||
|
||||
accountManager.getAccountByIdentifier(gid)?.let { account ->
|
||||
accountManager.getAccountByIdentifier(accountIdentifier)?.let { account ->
|
||||
if (account.isPushNotificationsEnabled()) {
|
||||
externalScope.launch {
|
||||
notificationService.updateUnifiedPushSubscription(account)
|
||||
notificationService.updatePushSubscription(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@ package com.keylesspalace.tusky.receiver
|
|||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -43,19 +40,20 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
|
|||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var externalScope: CoroutineScope
|
||||
lateinit var applicationScope: CoroutineScope
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
Log.d(TAG, "New message received for account $instance")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val request = OneTimeWorkRequest.from(NotificationWorker::class.java)
|
||||
workManager.enqueue(request)
|
||||
Log.d(TAG, "New message received for account $instance: #${message.size}")
|
||||
val account = accountManager.getAccountById(instance.toLong())
|
||||
account?.let {
|
||||
notificationService.fetchNotificationsOnPushMessage(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
|
||||
accountManager.getAccountById(instance.toLong())?.let {
|
||||
externalScope.launch { notificationService.registerUnifiedPushEndpoint(it, endpoint) }
|
||||
applicationScope.launch { notificationService.registerPushEndpoint(it, endpoint) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,11 +63,12 @@ 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 { notificationService.unregisterUnifiedPushEndpoint(it) }
|
||||
// TODO its not: this is the Mastodon side and should be done (unregistered)
|
||||
applicationScope.launch { notificationService.unregisterPushEndpoint(it) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "UnifiedPush"
|
||||
const val TAG = "UnifiedPushBroadcastReceiver"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ enum class AppTheme(val value: String) {
|
|||
*
|
||||
* - Adding a new preference that does not change the interpretation of an existing preference
|
||||
*/
|
||||
const val SCHEMA_VERSION = 2023112001
|
||||
const val SCHEMA_VERSION = 2025021701
|
||||
|
||||
/** The schema version for fresh installs */
|
||||
const val NEW_INSTALL_SCHEMA_VERSION = 0
|
||||
|
|
@ -55,6 +55,8 @@ object PrefKeys {
|
|||
// each preference a key for it to work.
|
||||
|
||||
const val SCHEMA_VERSION: String = "schema_version"
|
||||
const val LAST_USED_PUSH_PROVDER = "lastUsedPushProvider"
|
||||
|
||||
const val APP_THEME = "appTheme"
|
||||
const val LANGUAGE = "language"
|
||||
const val STATUS_TEXT_SIZE = "statusTextSize"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.keylesspalace.tusky.usecase
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
|
|
@ -8,11 +7,9 @@ import com.keylesspalace.tusky.db.DatabaseCleaner
|
|||
import com.keylesspalace.tusky.db.entity.AccountEntity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.ShareShortcutHelper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutUsecase @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val api: MastodonApi,
|
||||
private val databaseCleaner: DatabaseCleaner,
|
||||
private val accountManager: AccountManager,
|
||||
|
|
@ -38,17 +35,7 @@ class LogoutUsecase @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
// disable push notifications
|
||||
notificationService.disableUnifiedPushNotificationsForAccount(account)
|
||||
|
||||
// disable pull notifications
|
||||
if (!notificationService.areNotificationsEnabled()) {
|
||||
// TODO this is working very wrong
|
||||
notificationService.disablePullNotifications()
|
||||
}
|
||||
|
||||
// clear notification channels
|
||||
notificationService.deleteNotificationChannelsForAccount(account)
|
||||
notificationService.disableNotificationsForAccount(account)
|
||||
|
||||
// remove account from local AccountManager
|
||||
val otherAccountAvailable = accountManager.remove(account) != null
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationServic
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/** Fetch and show new notifications. */
|
||||
@HiltWorker
|
||||
class NotificationWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
|
|
@ -42,7 +41,8 @@ class NotificationWorker @AssistedInject constructor(
|
|||
)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
notificationsFetcher.fetchAndShow()
|
||||
val accountId = inputData.getLong(KEY_ACCOUNT_ID, 0).takeIf { it != 0L }
|
||||
notificationsFetcher.fetchAndShow(accountId)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
|
@ -50,4 +50,8 @@ class NotificationWorker @AssistedInject constructor(
|
|||
NotificationService.NOTIFICATION_ID_FETCH_NOTIFICATION,
|
||||
notification
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KEY_ACCOUNT_ID = "accountId"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue