Refactor notifications (#4883)

Also fixes https://github.com/tuskyapp/Tusky/issues/4858.
But apart from that there should be no functional change.
This commit is contained in:
UlrichKu 2025-01-22 21:16:33 +01:00 committed by GitHub
commit 3a3e056572
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1072 additions and 1217 deletions

View file

@ -40,12 +40,12 @@ import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.MenuProvider
@ -76,6 +76,7 @@ import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.DraftsAlert
@ -138,6 +139,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var notificationService: NotificationService
@Inject
lateinit var cacheUpdater: CacheUpdater
@ -177,6 +181,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
private val requestNotificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
viewModel.setupNotifications()
}
}
@SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
// Newer Android versions don't need to install the compat Splash Screen
@ -198,6 +209,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
// will be redirected to LoginActivity by BaseActivity
activeAccount = accountManager.activeAccount ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
if (explodeAnimationWasRequested()) {
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
@ -291,17 +308,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback)
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this@MainActivity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
// "Post failed" dialog should display in this activity
draftsAlert.observeInContext(this@MainActivity, true)
}

View file

@ -25,9 +25,7 @@ import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications
import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
@ -52,7 +50,8 @@ class MainViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager,
private val shareShortcutHelper: ShareShortcutHelper
private val shareShortcutHelper: ShareShortcutHelper,
private val notificationService: NotificationService,
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
@ -98,15 +97,7 @@ class MainViewModel @Inject constructor(
shareShortcutHelper.updateShortcuts()
NotificationHelper.createNotificationChannelsForAccount(activeAccount, context)
if (NotificationHelper.areNotificationsEnabled(context, accountManager)) {
viewModelScope.launch {
enablePushNotificationsWithFallback(context, api, accountManager)
}
} else {
disableAllNotifications(context, accountManager)
}
setupNotifications()
},
{ throwable ->
Log.w(TAG, "Failed to fetch user info.", throwable)
@ -169,6 +160,18 @@ class MainViewModel @Inject constructor(
}
}
fun setupNotifications() {
notificationService.createNotificationChannelsForAccount(activeAccount)
if (notificationService.areNotificationsEnabled()) {
viewModelScope.launch {
notificationService.enablePushNotificationsWithFallback()
}
} else {
notificationService.disableAllNotifications()
}
}
companion object {
private const val TAG = "MainViewModel"
}

View file

@ -24,7 +24,6 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
import com.keylesspalace.tusky.settings.PrefKeys
@ -75,6 +74,7 @@ class TuskyApplication : Application(), Configuration.Provider {
NEW_INSTALL_SCHEMA_VERSION
)
if (oldVersion != SCHEMA_VERSION) {
// TODO SCHEMA_VERSION is outdated / not updated in code
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
@ -89,8 +89,6 @@ class TuskyApplication : Application(), Configuration.Provider {
localeManager.setLocale()
NotificationHelper.createWorkerNotificationChannel(this)
// Prune the database every ~ 12 hours when the device is idle.
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())

View file

@ -52,7 +52,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
import com.keylesspalace.tusky.entity.Notification
@ -97,6 +97,9 @@ class NotificationsFragment :
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var notificationService: NotificationService
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
private val viewModel: NotificationsViewModel by viewModels()
@ -259,7 +262,7 @@ class NotificationsFragment :
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
accountManager.activeAccount?.let { account ->
NotificationHelper.clearNotificationsForAccount(requireContext(), account)
notificationService.clearNotificationsForAccount(account)
}
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)

View file

@ -19,7 +19,7 @@ import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys
@ -36,6 +36,9 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var notificationService: NotificationService
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val activeAccount = accountManager.activeAccount ?: return
val context = requireContext()
@ -47,10 +50,10 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isChecked = activeAccount.notificationsEnabled
setOnPreferenceChangeListener { _, newValue ->
updateAccount { copy(notificationsEnabled = newValue as Boolean) }
if (NotificationHelper.areNotificationsEnabled(context, accountManager)) {
NotificationHelper.enablePullNotifications(context)
if (notificationService.areNotificationsEnabled()) {
notificationService.enablePullNotifications()
} else {
NotificationHelper.disablePullNotifications(context)
notificationService.disablePullNotifications()
}
true
}

View file

@ -1,16 +1,9 @@
package com.keylesspalace.tusky.components.systemnotifications
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.filterNotification
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Marker
@ -18,7 +11,6 @@ import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.isLessThan
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
/** Models next/prev links from the "Links" header in an API response */
@ -50,65 +42,22 @@ data class Links(val next: String?, val prev: String?) {
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
@ApplicationContext private val context: Context,
private val eventHub: EventHub
private val eventHub: EventHub,
private val notificationService: NotificationService,
) {
suspend fun fetchAndShow() {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
for (account in accountManager.accounts) {
if (account.notificationsEnabled) {
try {
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
val notificationManagerCompat = NotificationManagerCompat.from(context)
// Create sorted list of new notifications
val notifications = fetchNewNotifications(account)
.filter { filterNotification(notificationManager, account, it) }
.filter { notificationService.filterNotification(account, it.type) }
.sortedWith(
compareBy({ it.id.length }, { it.id })
) // oldest notifications first
// TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification
// (and should therefore adhere to the notification config).
eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications))
val newNotifications = ArrayList<NotificationManagerCompat.NotificationWithIdAndTag>()
val notificationsByType: Map<Notification.Type, List<Notification>> = notifications.groupBy { it.type }
notificationsByType.forEach { notificationsGroup: Map.Entry<Notification.Type, List<Notification>> ->
// 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
// takes place.
newNotifications.add(
NotificationHelper.makeSummaryNotification(
context,
notificationManager,
account,
notificationsGroup.key,
notificationsGroup.value
)
)
notificationsGroup.value.forEach { notification ->
newNotifications.add(
NotificationHelper.make(
context,
notificationManager,
notification,
account
)
)
}
}
// NOTE having multiple summary notifications this here should still collapse them in only one occurrence
notificationManagerCompat.notify(newNotifications)
notificationService.show(account, notifications)
} catch (e: Exception) {
Log.e(TAG, "Error while fetching notifications", e)
}
@ -142,7 +91,7 @@ class NotificationFetcher @Inject constructor(
// - The Mastodon marker API (if the server supports it)
// - account.notificationMarkerId
// - account.lastNotificationId
Log.d(TAG, "getting notification marker for ${account.fullName}")
Log.d(TAG, "Getting notification marker for ${account.fullName}.")
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
val localMarkerId = account.notificationMarkerId
val markerId = if (remoteMarkerId.isLessThan(
@ -160,10 +109,10 @@ class NotificationFetcher @Inject constructor(
Log.d(TAG, " localMarkerId: $localMarkerId")
Log.d(TAG, " readingPosition: $readingPosition")
Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
Log.d(TAG, "Getting Notifications for ${account.fullName}, min_id: $minId.")
// Fetch all outstanding notifications
val notifications = buildList {
val notifications: List<Notification> = buildList {
while (minId != null) {
val response = mastodonApi.notificationsWithAuth(
authHeader,
@ -197,6 +146,8 @@ class NotificationFetcher @Inject constructor(
accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) }
}
Log.d(TAG, "Got ${notifications.size} Notifications.")
return notifications
}

View file

@ -1,852 +0,0 @@
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
* Copyright 2017 Andrew Dawson
*
* 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>. */
package com.keylesspalace.tusky.components.systemnotifications;
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
import androidx.work.Constraints;
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 com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.entity.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.worker.NotificationWorker;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class NotificationHelper {
/** ID of notification shown when fetching notifications */
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
/** ID of notification shown when pruning the cache */
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER";
public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME";
public static final String KEY_SERVER_NOTIFICATION_ID = "KEY_SERVER_NOTIFICATION_ID";
public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID";
public static final String KEY_VISIBILITY = "KEY_VISIBILITY";
public static final String KEY_SPOILER = "KEY_SPOILER";
public static final String KEY_MENTIONS = "KEY_MENTIONS";
/**
* notification channels used on Android O+
**/
public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST";
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
/**
* WorkManager Tag
*/
private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
/** Tag for the summary notification */
private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
/** The name of the account that caused the notification, for use in a summary */
private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
/** The notification's type (string representation of a Notification.Type) */
private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
/**
* Takes a given Mastodon notification and creates a new Android notification or updates the
* existing Android notification.
* <p>
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
* to the ID of the account that received the notification.
*
* @param context to access application preferences and services
* @param body a new Mastodon notification
* @param account the account for which the notification should be shown
* @return the new notification
*/
@NonNull
public static NotificationManagerCompat.NotificationWithIdAndTag make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account) {
return new NotificationManagerCompat.NotificationWithIdAndTag(
body.getId(),
(int)account.getId(),
NotificationHelper.makeBaseNotification(context, notificationManager, body, account)
);
}
@NonNull
public static android.app.Notification makeBaseNotification(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account) {
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
String mastodonNotificationId = body.getId();
int accountId = (int) account.getId();
// Check for an existing notification with this Mastodon Notification ID
android.app.Notification existingAndroidNotification = null;
StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
for (StatusBarNotification androidNotification : activeNotifications) {
if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
existingAndroidNotification = androidNotification.getNotification();
}
}
notificationId++;
// Create the notification -- either create a new one, or use the existing one.
NotificationCompat.Builder builder;
if (existingAndroidNotification == null) {
builder = newAndroidNotification(context, body, account);
} else {
builder = new NotificationCompat.Builder(context, existingAndroidNotification);
}
builder.setContentTitle(titleForType(context, body, account))
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
builder.setStyle(new NotificationCompat.BigTextStyle()
.bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
}
//load the avatar synchronously
Bitmap accountAvatar;
try {
FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap()
.load(body.getAccount().getAvatar())
.transform(new RoundedCorners(20))
.submit();
accountAvatar = target.get();
} catch (ExecutionException | InterruptedException e) {
Log.d(TAG, "error loading account avatar", e);
accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default);
}
builder.setLargeIcon(accountAvatar);
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.getType() == Notification.Type.MENTION) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply),
quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut),
composeIntent)
.setShowsUserInterface(true)
.build();
builder.addAction(composeAction);
}
builder.setSubText(account.getFullName());
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
builder.setOnlyAlertOnce(true);
Bundle extras = new Bundle();
// Add the sending account's name, so it can be used when summarising this notification
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name());
builder.addExtras(extras);
// Only ever alert for the summary notification
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
return builder.build();
}
/**
* Creates the summary notifications for a notification type.
* <p>
* Notifications are sent to channels. Within each channel they are grouped and the group has a summary.
* <p>
* Tusky uses N notification channels for each account, each channel corresponds to a type
* of notification (follow, reblog, mention, etc). Each channel also has a
* summary notifications along with its regular notifications.
* <p>
* The group key is the same as the channel ID.
*
* @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
* notification group</a>
*/
public static NotificationManagerCompat.NotificationWithIdAndTag makeSummaryNotification(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type, @NonNull List<Notification> additionalNotifications) {
int accountId = (int) account.getId();
String typeChannelId = getChannelId(account, type);
if (typeChannelId == null) {
return null;
}
// Create a notification that summarises the other notifications in this group
//
// NOTE: We always create a summary notification (even for activeNotificationsForType.size() == 1):
// - 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
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, type);
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
pendingIntentFlags(false));
List<StatusBarNotification> activeNotifications = getActiveNotifications(notificationManager.getActiveNotifications(), accountId, typeChannelId);
int notificationCount = activeNotifications.size() + additionalNotifications.size();
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount);
String text = joinNames(context, activeNotifications, additionalNotifications);
NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, typeChannelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summaryResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
.setContentTitle(title)
.setContentText(text)
.setSubText(account.getFullName())
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroup(typeChannelId)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
;
setSoundVibrationLight(account, summaryBuilder);
String summaryTag = GROUP_SUMMARY_TAG + "." + typeChannelId;
return new NotificationManagerCompat.NotificationWithIdAndTag(summaryTag, accountId, summaryBuilder.build());
}
private static List<StatusBarNotification> getActiveNotifications(StatusBarNotification[] allNotifications, int accountId, String typeChannelId) {
// Return all active notifications, ignoring notifications that:
// - belong to a different account
// - belong to a different type
// - are summary notifications
List<StatusBarNotification> activeNotificationsForType = new ArrayList<>();
for (StatusBarNotification sn : allNotifications) {
if (sn.getId() != accountId)
continue;
String channelId = sn.getNotification().getGroup();
if (!channelId.equals(typeChannelId))
continue;
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
if (summaryTag.equals(sn.getTag()))
continue;
activeNotificationsForType.add(sn);
}
return activeNotificationsForType;
}
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
pendingIntentFlags(false));
String channelId = getChannelId(account, body);
assert channelId != null;
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(eventResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setGroup(channelId)
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
setSoundVibrationLight(account, builder);
return builder;
}
private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(REPLY_ACTION)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
.putExtra(KEY_SERVER_NOTIFICATION_ID, body.getId())
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
replyIntent,
pendingIntentFlags(true));
}
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = parseAsMastodonHtml(status.getContent()).toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
String mentionedUsername = mention.getUsername();
if (!mentionedUsername.equals(account.getUsername())) {
mentionedUsernames.add(mention.getUsername());
}
}
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
// make sure a new instance of MainActivity is started and old ones get destroyed
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return PendingIntent.getActivity(context.getApplicationContext(),
notificationId,
composeIntent,
pendingIntentFlags(false));
}
/**
* Creates a notification channel for notifications for background work that should not
* disturb the user.
*
* @param context context
*/
public static void createWorkerNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(
CHANNEL_BACKGROUND_TASKS,
context.getString(R.string.notification_listenable_worker_name),
NotificationManager.IMPORTANCE_NONE
);
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
channel.enableLights(false);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
}
/**
* Creates a notification for a background worker.
*
* @param context context
* @param titleResource String resource to use as the notification's title
* @return the notification
*/
@NonNull
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
String title = context.getString(titleResource);
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
.setContentTitle(title)
.setTicker(title)
.setSmallIcon(R.drawable.ic_notify)
.setOngoing(true)
.build();
}
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
String[] channelIds = new String[]{
CHANNEL_MENTION + account.getIdentifier(),
CHANNEL_FOLLOW + account.getIdentifier(),
CHANNEL_FOLLOW_REQUEST + account.getIdentifier(),
CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(),
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(),
CHANNEL_UPDATES + account.getIdentifier(),
CHANNEL_REPORT + account.getIdentifier(),
};
int[] channelNames = {
R.string.notification_mention_name,
R.string.notification_follow_name,
R.string.notification_follow_request_name,
R.string.notification_boost_name,
R.string.notification_favourite_name,
R.string.notification_poll_name,
R.string.notification_subscription_name,
R.string.notification_sign_up_name,
R.string.notification_update_name,
R.string.notification_report_name,
};
int[] channelDescriptions = {
R.string.notification_mention_descriptions,
R.string.notification_follow_description,
R.string.notification_follow_request_description,
R.string.notification_boost_description,
R.string.notification_favourite_description,
R.string.notification_poll_description,
R.string.notification_subscription_description,
R.string.notification_sign_up_description,
R.string.notification_update_description,
R.string.notification_report_description,
};
List<NotificationChannel> channels = new ArrayList<>(6);
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
notificationManager.createNotificationChannelGroup(channelGroup);
for (int i = 0; i < channelIds.length; i++) {
String id = channelIds[i];
String name = context.getString(channelNames[i]);
String description = context.getString(channelDescriptions[i]);
int importance = NotificationManager.IMPORTANCE_DEFAULT;
NotificationChannel channel = new NotificationChannel(id, name, importance);
channel.setDescription(description);
channel.enableLights(true);
channel.setLightColor(0xFF2B90D9);
channel.enableVibration(true);
channel.setShowBadge(true);
channel.setGroup(account.getIdentifier());
channels.add(channel);
}
notificationManager.createNotificationChannels(channels);
}
}
public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
}
}
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
Log.d(TAG, "NotificationsEnabled");
return true;
}
}
}
Log.d(TAG, "NotificationsDisabled");
return false;
} else {
// on Android < O, notifications are enabled, if at least one account has notification enabled
return accountManager.areNotificationsEnabled();
}
}
public static void enablePullNotifications(@NonNull Context context) {
WorkManager 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.
WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build();
workManager.enqueue(fetchNotifications);
WorkRequest workRequest = new PeriodicWorkRequest.Builder(
NotificationWorker.class,
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
)
.addTag(NOTIFICATION_PULL_TAG)
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInitialDelay(5, TimeUnit.MINUTES)
.build();
workManager.enqueue(workRequest);
Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
}
public static void disablePullNotifications(@NonNull Context context) {
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
Log.d(TAG, "disabled notification checks");
}
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
int accountId = (int) account.getId();
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
if (accountId == androidNotification.getId()) {
notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
}
}
}
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) {
return filterNotification(notificationManager, account, notification.getType());
}
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
String channelId = getChannelId(account, type);
if(channelId == null) {
// unknown notificationtype
return false;
}
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
switch (type) {
case MENTION:
return account.getNotificationsMentioned();
case STATUS:
return account.getNotificationsSubscriptions();
case FOLLOW:
return account.getNotificationsFollowed();
case FOLLOW_REQUEST:
return account.getNotificationsFollowRequested();
case REBLOG:
return account.getNotificationsReblogged();
case FAVOURITE:
return account.getNotificationsFavorited();
case POLL:
return account.getNotificationsPolls();
case SIGN_UP:
return account.getNotificationsSignUps();
case UPDATE:
return account.getNotificationsUpdates();
case REPORT:
return account.getNotificationsReports();
default:
return false;
}
}
@Nullable
private static String getChannelId(AccountEntity account, Notification notification) {
return getChannelId(account, notification.getType());
}
@Nullable
private static String getChannelId(AccountEntity account, Notification.Type type) {
switch (type) {
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case STATUS:
return CHANNEL_SUBSCRIPTIONS + account.getIdentifier();
case FOLLOW:
return CHANNEL_FOLLOW + account.getIdentifier();
case FOLLOW_REQUEST:
return CHANNEL_FOLLOW_REQUEST + account.getIdentifier();
case REBLOG:
return CHANNEL_BOOST + account.getIdentifier();
case FAVOURITE:
return CHANNEL_FAVOURITE + account.getIdentifier();
case POLL:
return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier();
case UPDATE:
return CHANNEL_UPDATES + account.getIdentifier();
case REPORT:
return CHANNEL_REPORT + account.getIdentifier();
default:
return null;
}
}
private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return; //do nothing on Android O or newer, the system uses the channel settings anyway
}
if (account.getNotificationSound()) {
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
}
if (account.getNotificationVibration()) {
builder.setVibrate(new long[]{500, 500});
}
if (account.getNotificationLight()) {
builder.setLights(0xFF2B90D9, 300, 1000);
}
}
private static String joinNames(Context context, List<StatusBarNotification> notifications1, List<Notification> notifications2) {
List<String> names = new ArrayList<>(notifications1.size() + notifications2.size());
for (StatusBarNotification notification: notifications1) {
names.add(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));
}
for (Notification notification : notifications2) {
names.add(notification.getAccount().getName());
}
return joinNames(context, names);
}
@Nullable
private static String joinNames(Context context, List<String> names) {
if (names.size() > 3) {
int length = names.size();
return context.getString(R.string.notification_summary_large,
StringUtils.unicodeWrap(names.get(length - 1)),
StringUtils.unicodeWrap(names.get(length - 2)),
StringUtils.unicodeWrap(names.get(length - 3)),
length - 3
);
} else if (names.size() == 3) {
return context.getString(R.string.notification_summary_medium,
StringUtils.unicodeWrap(names.get(2)),
StringUtils.unicodeWrap(names.get(1)),
StringUtils.unicodeWrap(names.get(0))
);
} else if (names.size() == 2) {
return context.getString(R.string.notification_summary_small,
StringUtils.unicodeWrap(names.get(1)),
StringUtils.unicodeWrap(names.get(0))
);
}
return null;
}
@Nullable
private static String titleForType(Context context, Notification notification, AccountEntity account) {
String accountName = StringUtils.unicodeWrap(notification.getAccount().getName());
switch (notification.getType()) {
case MENTION:
return context.getString(R.string.notification_mention_format, accountName);
case STATUS:
return context.getString(R.string.notification_subscription_format, accountName);
case FOLLOW:
return context.getString(R.string.notification_follow_format, accountName);
case FOLLOW_REQUEST:
return context.getString(R.string.notification_follow_request_format, accountName);
case FAVOURITE:
return context.getString(R.string.notification_favourite_format, accountName);
case REBLOG:
return context.getString(R.string.notification_reblog_format, accountName);
case POLL:
if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) {
return context.getString(R.string.poll_ended_created);
} else {
return context.getString(R.string.poll_ended_voted);
}
case SIGN_UP:
return context.getString(R.string.notification_sign_up_format, accountName);
case UPDATE:
return context.getString(R.string.notification_update_format, accountName);
case REPORT:
return context.getString(R.string.notification_report_format, account.getDomain());
}
return null;
}
private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
switch (notification.getType()) {
case FOLLOW:
case FOLLOW_REQUEST:
case SIGN_UP:
return "@" + notification.getAccount().getUsername();
case MENTION:
case FAVOURITE:
case REBLOG:
case STATUS:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText();
} else {
return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
}
case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText();
} else {
StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
builder.append('\n');
Poll poll = notification.getStatus().getPoll();
List<PollOption> options = poll.getOptions();
for(int i = 0; i < options.size(); ++i) {
PollOption option = options.get(i);
builder.append(buildDescription(option.getTitle(),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
poll.getOwnVotes().contains(i),
context));
builder.append('\n');
}
return builder.toString();
}
case REPORT:
return context.getString(
R.string.notification_header_report_format,
StringUtils.unicodeWrap(notification.getAccount().getName()),
StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
);
}
return null;
}
public static int pendingIntentFlags(boolean mutable) {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
}
}
}

View file

@ -0,0 +1,910 @@
package com.keylesspalace.tusky.components.systemnotifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.service.notification.StatusBarNotification
import android.text.TextUtils
import android.util.Log
import androidx.annotation.StringRes
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
import androidx.core.app.RemoteInput
import androidx.core.app.TaskStackBuilder
import androidx.work.Constraints
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.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.MainActivity.Companion.composeIntent
import com.keylesspalace.tusky.MainActivity.Companion.openNotificationIntent
import com.keylesspalace.tusky.R
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.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
import com.keylesspalace.tusky.util.CryptoUtil
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.viewdata.buildDescription
import com.keylesspalace.tusky.viewdata.calculatePercent
import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
@Singleton
class NotificationService @Inject constructor(
private val notificationManager: NotificationManager,
private val accountManager: AccountManager,
private val api: MastodonApi,
@ApplicationContext private val context: Context,
) {
private var notificationId: Int = NOTIFICATION_ID_PRUNE_CACHE + 1
init {
createWorkerNotificationChannel()
}
fun areNotificationsEnabled(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// on Android >= O, notifications are enabled, if at least one channel is enabled
if (notificationManager.areNotificationsEnabled()) {
for (channel in notificationManager.notificationChannels) {
if (channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE) {
Log.d(TAG, "Notifications enabled for app by the system.")
return true
}
}
}
Log.d(TAG, "Notifications disabled for app by the system.")
return false
} else {
// on Android < O, notifications are enabled, if at least one account has notification enabled
return accountManager.areNotificationsEnabled()
}
}
fun createNotificationChannelsForAccount(account: AccountEntity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
data class ChannelData(
val id: String,
@StringRes val name: Int,
@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)!!,
R.string.notification_mention_name,
R.string.notification_mention_descriptions,
),
ChannelData(
getChannelId(account, Notification.Type.FOLLOW)!!,
R.string.notification_follow_name,
R.string.notification_follow_description,
),
ChannelData(
getChannelId(account, Notification.Type.FOLLOW_REQUEST)!!,
R.string.notification_follow_request_name,
R.string.notification_follow_request_description,
),
ChannelData(
getChannelId(account, Notification.Type.REBLOG)!!,
R.string.notification_boost_name,
R.string.notification_boost_description,
),
ChannelData(
getChannelId(account, Notification.Type.FAVOURITE)!!,
R.string.notification_favourite_name,
R.string.notification_favourite_description,
),
ChannelData(
getChannelId(account, Notification.Type.POLL)!!,
R.string.notification_poll_name,
R.string.notification_poll_description,
),
ChannelData(
getChannelId(account, Notification.Type.STATUS)!!,
R.string.notification_subscription_name,
R.string.notification_subscription_description,
),
ChannelData(
getChannelId(account, Notification.Type.SIGN_UP)!!,
R.string.notification_sign_up_name,
R.string.notification_sign_up_description,
),
ChannelData(
getChannelId(account, Notification.Type.UPDATE)!!,
R.string.notification_update_name,
R.string.notification_update_description,
),
ChannelData(
getChannelId(account, Notification.Type.REPORT)!!,
R.string.notification_report_name,
R.string.notification_report_description,
),
)
// TODO enumerate all keys of Notification.Type and check if one is missing here?
val channelGroup = NotificationChannelGroup(account.identifier, account.fullName)
notificationManager.createNotificationChannelGroup(channelGroup)
val channels = channelData.map {
NotificationChannel(it.id, context.getString(it.name), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = context.getString(it.description)
enableLights(true)
lightColor = -0xd46f27
enableVibration(true)
setShowBadge(true)
group = account.identifier
}
}
notificationManager.createNotificationChannels(channels)
}
}
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)
.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()
workManager.enqueue(workRequest)
Log.d(TAG, "Enabled pull checks with " + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval.")
}
fun disablePullNotifications() {
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG)
Log.d(TAG, "Disabled pull checks.")
}
fun clearNotificationsForAccount(account: AccountEntity) {
for (androidNotification in notificationManager.activeNotifications) {
if (account.id.toInt() == androidNotification.id) {
notificationManager.cancel(androidNotification.tag, androidNotification.id)
}
}
}
fun filterNotification(account: AccountEntity, type: Notification.Type): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = getChannelId(account, type)
?: // unknown notificationtype
return false
val channel = notificationManager.getNotificationChannel(channelId)
return channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE
}
return when (type) {
Notification.Type.MENTION -> account.notificationsMentioned
Notification.Type.STATUS -> account.notificationsSubscriptions
Notification.Type.FOLLOW -> account.notificationsFollowed
Notification.Type.FOLLOW_REQUEST -> account.notificationsFollowRequested
Notification.Type.REBLOG -> account.notificationsReblogged
Notification.Type.FAVOURITE -> account.notificationsFavorited
Notification.Type.POLL -> account.notificationsPolls
Notification.Type.SIGN_UP -> account.notificationsSignUps
Notification.Type.UPDATE -> account.notificationsUpdates
Notification.Type.REPORT -> account.notificationsReports
else -> false
}
}
fun show(account: AccountEntity, notifications: List<Notification>) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
if (notifications.isEmpty()) {
return
}
val newNotifications = ArrayList<NotificationWithIdAndTag>()
val notificationsByType: Map<Notification.Type, List<Notification>> = notifications.groupBy { it.type }
for ((type, notificationsForOneType) in notificationsByType) {
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
// takes place.
newNotifications.add(summary)
for (notification in notificationsForOneType) {
val single = createNotification(notification, account) ?: continue
newNotifications.add(single)
}
}
val notificationManagerCompat = NotificationManagerCompat.from(context)
// NOTE having multiple summary notifications: this here should still collapse them in only one occurrence
notificationManagerCompat.notify(newNotifications)
}
private fun createNotification(apiNotification: Notification, account: AccountEntity): NotificationWithIdAndTag? {
val baseNotification = createBaseNotification(apiNotification, account) ?: return null
return NotificationWithIdAndTag(
apiNotification.id,
account.id.toInt(),
baseNotification
)
}
// Only public for one test...
fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? {
val channelId = getChannelId(account, apiNotification.type) ?: return null
val body = apiNotification.rewriteToStatusTypeIfNeeded(account.accountId)
// Check for an existing notification matching this account and api notification
var existingAndroidNotification: android.app.Notification? = null
val activeNotifications = notificationManager.activeNotifications
for (androidNotification in activeNotifications) {
if (body.id == androidNotification.tag && account.id.toInt() == androidNotification.id) {
existingAndroidNotification = androidNotification.notification
}
}
notificationId++
val builder = if (existingAndroidNotification == null) {
getNotificationBuilder(body.type, account, channelId)
} else {
NotificationCompat.Builder(context, existingAndroidNotification)
}
builder
.setContentTitle(titleForType(body, account))
.setContentText(bodyForType(body, account.alwaysOpenSpoiler))
if (body.type == Notification.Type.MENTION || body.type == Notification.Type.POLL) {
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(bodyForType(body, account.alwaysOpenSpoiler))
)
}
val accountAvatar = try {
Glide.with(context)
.asBitmap()
.load(body.account.avatar)
.transform(RoundedCorners(20))
.submit()
.get()
} catch (e: ExecutionException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
} catch (e: InterruptedException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
}
builder.setLargeIcon(accountAvatar)
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.type == Notification.Type.MENTION) {
val replyRemoteInput = RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build()
val quickReplyPendingIntent = getStatusReplyIntent(body, account, notificationId)
val quickReplyAction =
NotificationCompat.Action.Builder(
R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply),
quickReplyPendingIntent
)
.addRemoteInput(replyRemoteInput)
.build()
builder.addAction(quickReplyAction)
val composeIntent = getStatusComposeIntent(body, account, notificationId)
val composeAction =
NotificationCompat.Action.Builder(
R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut),
composeIntent
)
.setShowsUserInterface(true)
.build()
builder.addAction(composeAction)
}
builder.addExtras(
Bundle().apply {
// Add the sending account's name, so it can be used also later when summarising this notification
putString(EXTRA_ACCOUNT_NAME, body.account.name)
putString(EXTRA_NOTIFICATION_TYPE, body.type.name)
}
)
return builder.build()
}
/**
* Create a notification that summarises the other notifications in this group.
*
* NOTE: We always create a summary notification (even for activeNotificationsForType.size() == 1):
* - 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
*/
private fun createSummaryNotification(account: AccountEntity, type: Notification.Type, additionalNotifications: List<Notification>): NotificationWithIdAndTag? {
val typeChannelId = getChannelId(account, type) ?: return null
val summaryStackBuilder = TaskStackBuilder.create(context)
summaryStackBuilder.addParentStack(MainActivity::class.java)
val summaryResultIntent = openNotificationIntent(context, account.id, type)
summaryStackBuilder.addNextIntent(summaryResultIntent)
val summaryResultPendingIntent = summaryStackBuilder.getPendingIntent(
(notificationId + account.id * 10000).toInt(),
pendingIntentFlags(false)
)
val activeNotifications = getActiveNotifications(account.id, typeChannelId)
val notificationCount = activeNotifications.size + additionalNotifications.size
val title = context.resources.getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount)
val text = joinNames(activeNotifications, additionalNotifications)
val summaryBuilder = NotificationCompat.Builder(context, typeChannelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summaryResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setAutoCancel(true)
.setContentTitle(title)
.setContentText(text)
.setShortcutId(account.id.toString())
.setSubText(account.fullName)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroup(typeChannelId)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
setSoundVibrationLight(account, summaryBuilder)
val summaryTag = "$GROUP_SUMMARY_TAG.$typeChannelId"
return NotificationWithIdAndTag(summaryTag, account.id.toInt(), summaryBuilder.build())
}
fun createWorkerNotification(@StringRes titleResource: Int): android.app.Notification {
val title = context.getString(titleResource)
return NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
.setContentTitle(title)
.setTicker(title)
.setSmallIcon(R.drawable.ic_notify)
.setOngoing(true)
.build()
}
private fun getChannelId(account: AccountEntity, type: Notification.Type): String? {
return when (type) {
Notification.Type.MENTION -> CHANNEL_MENTION + account.identifier
Notification.Type.STATUS -> "CHANNEL_SUBSCRIPTIONS" + account.identifier
Notification.Type.FOLLOW -> "CHANNEL_FOLLOW" + account.identifier
Notification.Type.FOLLOW_REQUEST -> "CHANNEL_FOLLOW_REQUEST" + account.identifier
Notification.Type.REBLOG -> "CHANNEL_BOOST" + account.identifier
Notification.Type.FAVOURITE -> "CHANNEL_FAVOURITE" + account.identifier
Notification.Type.POLL -> "CHANNEL_POLL" + account.identifier
Notification.Type.SIGN_UP -> "CHANNEL_SIGN_UP" + account.identifier
Notification.Type.UPDATE -> "CHANNEL_UPDATES" + account.identifier
Notification.Type.REPORT -> "CHANNEL_REPORT" + account.identifier
else -> null
}
}
/**
* Return all active notifications, ignoring notifications that:
* - belong to a different account
* - belong to a different type
* - are summary notifications
*/
private fun getActiveNotifications(accountId: Long, typeChannelId: String): List<StatusBarNotification> {
return notificationManager.activeNotifications.filter {
val channelId = it.notification.group
it.id == accountId.toInt() && channelId == typeChannelId && it.tag != "$GROUP_SUMMARY_TAG.$channelId"
}
}
private fun getNotificationBuilder(notificationType: Notification.Type, account: AccountEntity, channelId: String): NotificationCompat.Builder {
val eventResultIntent = openNotificationIntent(context, account.id, notificationType)
val eventStackBuilder = TaskStackBuilder.create(context)
eventStackBuilder.addParentStack(MainActivity::class.java)
eventStackBuilder.addNextIntent(eventResultIntent)
val eventResultPendingIntent = eventStackBuilder.getPendingIntent(
account.id.toInt(),
pendingIntentFlags(false)
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(eventResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setAutoCancel(true)
.setShortcutId(account.id.toString())
.setSubText(account.fullName)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setOnlyAlertOnce(true)
.setGroup(channelId)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Only ever alert for the summary notification
setSoundVibrationLight(account, builder)
return builder
}
private fun titleForType(notification: Notification, account: AccountEntity): String? {
if (notification.status == null) {
return null
}
val accountName = notification.account.name.unicodeWrap()
when (notification.type) {
Notification.Type.MENTION -> return context.getString(R.string.notification_mention_format, accountName)
Notification.Type.STATUS -> return context.getString(R.string.notification_subscription_format, accountName)
Notification.Type.FOLLOW -> return context.getString(R.string.notification_follow_format, accountName)
Notification.Type.FOLLOW_REQUEST -> return context.getString(R.string.notification_follow_request_format, accountName)
Notification.Type.FAVOURITE -> return context.getString(R.string.notification_favourite_format, accountName)
Notification.Type.REBLOG -> return context.getString(R.string.notification_reblog_format, accountName)
Notification.Type.POLL -> return if (notification.status.account.id == account.accountId) {
context.getString(R.string.poll_ended_created)
} else {
context.getString(R.string.poll_ended_voted)
}
Notification.Type.SIGN_UP -> return context.getString(R.string.notification_sign_up_format, accountName)
Notification.Type.UPDATE -> return context.getString(R.string.notification_update_format, accountName)
Notification.Type.REPORT -> return context.getString(R.string.notification_report_format, account.domain)
Notification.Type.UNKNOWN -> return null
}
}
private fun bodyForType(notification: Notification, alwaysOpenSpoiler: Boolean): String? {
if (notification.status == null) {
return null
}
when (notification.type) {
Notification.Type.FOLLOW, Notification.Type.FOLLOW_REQUEST, Notification.Type.SIGN_UP -> return "@" + notification.account.username
Notification.Type.MENTION, Notification.Type.FAVOURITE, Notification.Type.REBLOG, Notification.Type.STATUS -> return if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
notification.status.spoilerText
} else {
notification.status.content.parseAsMastodonHtml().toString()
}
Notification.Type.POLL -> if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
return notification.status.spoilerText
} else {
val poll = notification.status.poll ?: return null
val builder = StringBuilder(notification.status.content.parseAsMastodonHtml())
builder.append('\n')
poll.options.forEachIndexed { i, option ->
builder.append(
buildDescription(
option.title,
calculatePercent(option.votesCount, poll.votersCount, poll.votesCount),
poll.ownVotes.contains(i),
context
)
)
builder.append('\n')
}
return builder.toString()
}
Notification.Type.REPORT -> return context.getString(
R.string.notification_header_report_format,
notification.account.name.unicodeWrap(),
notification.report!!.targetAccount.name.unicodeWrap()
)
else -> return null
}
}
private fun createWorkerNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(
CHANNEL_BACKGROUND_TASKS,
context.getString(R.string.notification_listenable_worker_name),
NotificationManager.IMPORTANCE_NONE
)
channel.description = context.getString(R.string.notification_listenable_worker_description)
channel.enableLights(false)
channel.enableVibration(false)
channel.setShowBadge(false)
notificationManager.createNotificationChannel(channel)
}
private fun setSoundVibrationLight(account: AccountEntity, builder: NotificationCompat.Builder) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return // Do nothing on Android O or newer, the system uses only the channel settings
}
builder.setDefaults(0)
if (account.notificationSound) {
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI)
}
if (account.notificationVibration) {
builder.setVibrate(longArrayOf(500, 500))
}
if (account.notificationLight) {
builder.setLights(-0xd46f27, 300, 1000)
}
}
private fun joinNames(notifications1: List<StatusBarNotification>, notifications2: List<Notification>): String? {
val names = java.util.ArrayList<String>(notifications1.size + notifications2.size)
for (notification in notifications1) {
val author = notification.notification.extras.getString(EXTRA_ACCOUNT_NAME) ?: continue
names.add(author)
}
for (noti in notifications2) {
names.add(noti.account.name)
}
if (names.size > 3) {
val length = names.size
return context.getString(
R.string.notification_summary_large,
names[length - 1].unicodeWrap(),
names[length - 2].unicodeWrap(),
names[length - 3].unicodeWrap(),
length - 3
)
} else if (names.size == 3) {
return context.getString(
R.string.notification_summary_medium,
names[2].unicodeWrap(),
names[1].unicodeWrap(),
names[0].unicodeWrap()
)
} else if (names.size == 2) {
return context.getString(
R.string.notification_summary_small,
names[1].unicodeWrap(),
names[0].unicodeWrap()
)
}
return null
}
private fun getStatusReplyIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent {
val status = checkNotNull(apiNotification.status)
val inReplyToId = status.id
val actionableStatus = status.actionableStatus
val replyVisibility = actionableStatus.visibility
val contentWarning = actionableStatus.spoilerText
val mentions = actionableStatus.mentions
val mentionedUsernames = buildSet {
add(actionableStatus.account.username)
for (mention in mentions) {
add(mention.username)
}
remove(account.username)
}
val replyIntent = Intent(context, SendStatusBroadcastReceiver::class.java)
.setAction(REPLY_ACTION)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.id)
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.identifier)
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.fullName)
.putExtra(KEY_SERVER_NOTIFICATION_ID, apiNotification.id)
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toTypedArray<String?>())
return PendingIntent.getBroadcast(
context.applicationContext,
requestCode,
replyIntent,
pendingIntentFlags(true)
)
}
private fun getStatusComposeIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent {
val status = checkNotNull(apiNotification.status)
val citedLocalAuthor = status.account.localUsername
val citedText = status.content.parseAsMastodonHtml().toString()
val inReplyToId = status.id
val actionableStatus = status.actionableStatus
val replyVisibility = actionableStatus.visibility
val contentWarning = actionableStatus.spoilerText
val mentions = actionableStatus.mentions
val mentionedUsernames = buildSet {
add(actionableStatus.account.username)
for (mention in mentions) {
add(mention.username)
}
remove(account.username)
}
val composeOptions = ComposeOptions()
composeOptions.inReplyToId = inReplyToId
composeOptions.replyVisibility = replyVisibility
composeOptions.contentWarning = contentWarning
composeOptions.replyingStatusAuthor = citedLocalAuthor
composeOptions.replyingStatusContent = citedText
composeOptions.mentionedUsernames = mentionedUsernames
composeOptions.modifiedInitialState = true
composeOptions.language = actionableStatus.language
composeOptions.kind = ComposeActivity.ComposeKind.NEW
val composeIntent = composeIntent(context, composeOptions, account.id, apiNotification.id, account.id.toInt())
// make sure a new instance of MainActivity is started and old ones get destroyed
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
return PendingIntent.getActivity(
context.applicationContext,
requestCode,
composeIntent,
pendingIntentFlags(false)
)
}
private fun pendingIntentFlags(mutable: Boolean): Int {
return if (mutable) {
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
} else {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
}
fun disableAllNotifications() {
disablePushNotificationsForAllAccounts()
disablePullNotifications()
}
//
// Push notification section
//
fun isUnifiedPushAvailable(): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
suspend fun enablePushNotificationsWithFallback() {
if (!isUnifiedPushAvailable()) {
// No distributors
enablePullNotifications()
return
}
accountManager.accounts.forEach {
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
notificationManager.getNotificationChannelGroup(it.identifier)?.isBlocked == false
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
if (shouldEnable) {
enableUnifiedPushNotificationsForAccount(it)
} else {
disableUnifiedPushNotificationsForAccount(it)
}
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(account: AccountEntity) {
if (account.isPushNotificationsEnabled()) {
// Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(account)
} else {
UnifiedPush.registerAppWithDialog(
context,
account.id.toString(),
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
)
}
}
private fun disablePushNotificationsForAllAccounts() {
accountManager.accounts.forEach {
disableUnifiedPushNotificationsForAccount(it)
}
}
fun disableUnifiedPushNotificationsForAccount(account: AccountEntity) {
if (!account.isPushNotificationsEnabled()) {
// Not registered
return
}
UnifiedPush.unregisterApp(context, account.id.toString())
}
private fun buildSubscriptionData(account: AccountEntity): Map<String, Boolean> =
buildMap {
Notification.Type.visibleTypes.forEach {
put(
"data[alerts][${it.presentation}]",
filterNotification(account, it)
)
}
}
// Called by UnifiedPush callback in UnifiedPushBroadcastReceiver
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
// TODO that is still correct?
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(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}")
accountManager.updateAccount(account) {
copy(
pushPubKey = keyPair.pubkey,
pushPrivKey = keyPair.privKey,
pushAuth = auth,
pushServerKey = it.serverKey,
unifiedPushUrl = endpoint
)
}
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription
suspend fun updateUnifiedPushSubscription(account: AccountEntity) {
withContext(Dispatchers.IO) {
api.updatePushNotificationSubscription(
"Bearer ${account.accessToken}",
account.domain,
buildSubscriptionData(account)
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
accountManager.updateAccount(account) {
copy(pushServerKey = it.serverKey)
}
}
}
}
suspend fun unregisterUnifiedPushEndpoint(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
accountManager.updateAccount(account) {
copy(
pushPubKey = "",
pushPrivKey = "",
pushAuth = "",
pushServerKey = "",
unifiedPushUrl = ""
)
}
}
}
}
companion object {
const val TAG = "NotificationService"
const val CHANNEL_MENTION: String = "CHANNEL_MENTION"
const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID"
const val KEY_MENTIONS: String = "KEY_MENTIONS"
const val KEY_REPLY: String = "KEY_REPLY"
const val KEY_SENDER_ACCOUNT_FULL_NAME: String = "KEY_SENDER_ACCOUNT_FULL_NAME"
const val KEY_SENDER_ACCOUNT_ID: String = "KEY_SENDER_ACCOUNT_ID"
const val KEY_SENDER_ACCOUNT_IDENTIFIER: String = "KEY_SENDER_ACCOUNT_IDENTIFIER"
const val KEY_SERVER_NOTIFICATION_ID: String = "KEY_SERVER_NOTIFICATION_ID"
const val KEY_SPOILER: String = "KEY_SPOILER"
const val KEY_VISIBILITY: String = "KEY_VISIBILITY"
const val NOTIFICATION_ID_FETCH_NOTIFICATION: Int = 0
const val NOTIFICATION_ID_PRUNE_CACHE: Int = 1
const val REPLY_ACTION: String = "REPLY_ACTION"
private const val CHANNEL_BACKGROUND_TASKS: String = "CHANNEL_BACKGROUND_TASKS"
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"
}
}

View file

@ -1,206 +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.systemnotifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
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 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()
fun isUnifiedPushAvailable(context: Context): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
suspend fun enablePushNotificationsWithFallback(
context: Context,
api: MastodonApi,
accountManager: AccountManager
) {
if (!isUnifiedPushAvailable(context)) {
// 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}")
accountManager.updateAccount(account) {
copy(
pushPubKey = keyPair.pubkey,
pushPrivKey = keyPair.privKey,
pushAuth = auth,
pushServerKey = it.serverKey,
unifiedPushUrl = endpoint
)
}
}
}
// 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}")
accountManager.updateAccount(account) {
copy(pushServerKey = it.serverKey)
}
}
}
}
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
accountManager.updateAccount(account) {
copy(
pushPubKey = "",
pushPrivKey = "",
pushAuth = "",
pushServerKey = "",
unifiedPushUrl = ""
)
}
}
}
}

View file

@ -120,10 +120,13 @@ data class AccountEntity(
val isShowHomeReplies: Boolean = true,
val isShowHomeSelfBoosts: Boolean = true
) {
val identifier: String
get() = "$domain:$accountId"
val fullName: String
get() = "@$username@$domain"
fun isPushNotificationsEnabled(): Boolean {
return unifiedPushUrl.isNotEmpty()
}
}

View file

@ -0,0 +1,33 @@
/* Copyright 2025 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>. */
package com.keylesspalace.tusky.di
import android.app.NotificationManager
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object NotificationManagerModule {
@Provides
fun providesNotificationManager(@ApplicationContext appContext: Context): NotificationManager {
return appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
}

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.systemnotifications.isUnifiedPushAvailable
import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount
import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription
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
@ -39,13 +37,16 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var notificationService: NotificationService
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
if (Build.VERSION.SDK_INT < 28) return
if (!isUnifiedPushAvailable(context)) return
if (!notificationService.isUnifiedPushAvailable()) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -61,15 +62,9 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
} ?: return
accountManager.getAccountByIdentifier(gid)?.let { account ->
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Update UnifiedPush notification subscription
if (account.isPushNotificationsEnabled()) {
externalScope.launch {
updateUnifiedPushSubscription(
context,
mastodonApi,
accountManager,
account
)
notificationService.updateUnifiedPushSubscription(account)
}
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
/* Copyright 2018 Tusky contributors
*
* This file is a part of Tusky.
*
@ -24,7 +24,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.service.SendStatusService
@ -34,8 +34,6 @@ import com.keylesspalace.tusky.util.randomAlphanumericString
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
private const val TAG = "SendStatusBR"
@AndroidEntryPoint
class SendStatusBroadcastReceiver : BroadcastReceiver() {
@ -44,20 +42,20 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == NotificationHelper.REPLY_ACTION) {
val serverNotificationId = intent.getStringExtra(NotificationHelper.KEY_SERVER_NOTIFICATION_ID)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
if (intent.action == NotificationService.REPLY_ACTION) {
val serverNotificationId = intent.getStringExtra(NotificationService.KEY_SERVER_NOTIFICATION_ID)
val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(
NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER
NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER
)
val senderFullName = intent.getStringExtra(
NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME
NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME
)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val citedStatusId = intent.getStringExtra(NotificationService.KEY_CITED_STATUS_ID)
val visibility =
intent.getSerializableExtraCompat<Status.Visibility>(NotificationHelper.KEY_VISIBILITY)!!
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty()
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty()
intent.getSerializableExtraCompat<Status.Visibility>(NotificationService.KEY_VISIBILITY)!!
val spoiler = intent.getStringExtra(NotificationService.KEY_SPOILER).orEmpty()
val mentions = intent.getStringArrayExtra(NotificationService.KEY_MENTIONS).orEmpty()
val account = accountManager.getAccountById(senderId)
@ -70,7 +68,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val notification = NotificationCompat.Builder(
context,
NotificationHelper.CHANNEL_MENTION + senderIdentifier
NotificationService.CHANNEL_MENTION + senderIdentifier
)
.setSmallIcon(R.drawable.ic_notify)
.setColor(context.getColor(R.color.tusky_blue))
@ -115,7 +113,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
// Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically
val notification = NotificationCompat.Builder(
context,
NotificationHelper.CHANNEL_MENTION + senderIdentifier
NotificationService.CHANNEL_MENTION + senderIdentifier
)
.setSmallIcon(R.drawable.ic_notify)
.setColor(context.getColor(R.color.notification_color))
@ -138,6 +136,10 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
private fun getReplyMessage(intent: Intent): CharSequence {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
return remoteInput?.getCharSequence(NotificationHelper.KEY_REPLY, "") ?: ""
return remoteInput?.getCharSequence(NotificationService.KEY_REPLY, "") ?: ""
}
companion object {
const val TAG = "SendStatusBroadcastReceiver"
}
}

View file

@ -19,8 +19,7 @@ import android.content.Context
import android.util.Log
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.systemnotifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.systemnotifications.unregisterUnifiedPushEndpoint
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
@ -33,16 +32,15 @@ import org.unifiedpush.android.connector.MessagingReceiver
@AndroidEntryPoint
class UnifiedPushBroadcastReceiver : MessagingReceiver() {
companion object {
const val TAG = "UnifiedPush"
}
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var notificationService: NotificationService
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
@ -57,9 +55,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
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 {
registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint)
}
externalScope.launch { notificationService.registerUnifiedPushEndpoint(it, endpoint) }
}
}
@ -69,7 +65,11 @@ 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 { notificationService.unregisterUnifiedPushEndpoint(it) }
}
}
companion object {
const val TAG = "UnifiedPush"
}
}

View file

@ -41,7 +41,6 @@ import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.compose.UploadEvent
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.MediaAttribute
@ -413,7 +412,7 @@ class SendStatusService : Service() {
this,
statusId,
intent,
NotificationHelper.pendingIntentFlags(false)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@ -429,7 +428,7 @@ class SendStatusService : Service() {
this,
statusId,
intent,
NotificationHelper.pendingIntentFlags(false)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)

View file

@ -2,8 +2,7 @@ package com.keylesspalace.tusky.usecase
import android.content.Context
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.DatabaseCleaner
import com.keylesspalace.tusky.db.entity.AccountEntity
@ -18,7 +17,8 @@ class LogoutUsecase @Inject constructor(
private val databaseCleaner: DatabaseCleaner,
private val accountManager: AccountManager,
private val draftHelper: DraftHelper,
private val shareShortcutHelper: ShareShortcutHelper
private val shareShortcutHelper: ShareShortcutHelper,
private val notificationService: NotificationService,
) {
/**
@ -39,15 +39,16 @@ class LogoutUsecase @Inject constructor(
}
// disable push notifications
disableUnifiedPushNotificationsForAccount(context, account)
notificationService.disableUnifiedPushNotificationsForAccount(account)
// disable pull notifications
if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) {
NotificationHelper.disablePullNotifications(context)
if (!notificationService.areNotificationsEnabled()) {
// TODO this is working very wrong
notificationService.disablePullNotifications()
}
// clear notification channels
NotificationHelper.deleteNotificationChannelsForAccount(account, context)
notificationService.deleteNotificationChannelsForAccount(account)
// remove account from local AccountManager
val otherAccountAvailable = accountManager.remove(account) != null

View file

@ -25,8 +25,7 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationFetcher
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -35,10 +34,10 @@ import dagger.assisted.AssistedInject
class NotificationWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
private val notificationsFetcher: NotificationFetcher,
notificationService: NotificationService,
) : CoroutineWorker(appContext, params) {
val notification: Notification = NotificationHelper.createWorkerNotification(
applicationContext,
val notification: Notification = notificationService.createWorkerNotification(
R.string.notification_notification_worker
)
@ -48,7 +47,7 @@ class NotificationWorker @AssistedInject constructor(
}
override suspend fun getForegroundInfo() = ForegroundInfo(
NOTIFICATION_ID_FETCH_NOTIFICATION,
NotificationService.NOTIFICATION_ID_FETCH_NOTIFICATION,
notification
)
}

View file

@ -25,8 +25,7 @@ import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.DatabaseCleaner
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
@ -39,10 +38,10 @@ class PruneCacheWorker @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted workerParams: WorkerParameters,
private val databaseCleaner: DatabaseCleaner,
private val accountManager: AccountManager
private val accountManager: AccountManager,
val notificationService: NotificationService,
) : CoroutineWorker(appContext, workerParams) {
val notification: Notification = NotificationHelper.createWorkerNotification(
applicationContext,
val notification: Notification = notificationService.createWorkerNotification(
R.string.notification_prune_cache
)
@ -58,7 +57,7 @@ class PruneCacheWorker @AssistedInject constructor(
}
override suspend fun getForegroundInfo() = ForegroundInfo(
NOTIFICATION_ID_PRUNE_CACHE,
NotificationService.NOTIFICATION_ID_PRUNE_CACHE,
notification
)