diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 97853a0392..b88125b5c0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index 5be9c039d3..534aef753c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -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" } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 26518eac06..d40e8967af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -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(12, TimeUnit.HOURS) .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 4c0372651d..1aa53147a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 9812addd36..46c8719b25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 06c6513cfe..232a62c605 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -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() - - val notificationsByType: Map> = notifications.groupBy { it.type } - notificationsByType.forEach { notificationsGroup: Map.Entry> -> - // 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 = 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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java deleted file mode 100644 index 6d2cfcb29d..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java +++ /dev/null @@ -1,852 +0,0 @@ -/* Copyright 2018 Jeremiasz Nelz - * 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 . */ - -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. - *

- * 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 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. - *

- * Notifications are sent to channels. Within each channel they are grouped and the group has a summary. - *

- * 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. - *

- * The group key is the same as the channel ID. - * - * @see Create a - * notification group - */ - public static NotificationManagerCompat.NotificationWithIdAndTag makeSummaryNotification(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type, @NonNull List 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 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 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 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 mentions = actionableStatus.getMentions(); - List 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 mentions = actionableStatus.getMentions(); - Set 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 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 notifications1, List notifications2) { - List 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 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 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; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt new file mode 100644 index 0000000000..ea3ba7715b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt @@ -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) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + if (notifications.isEmpty()) { + return + } + + val newNotifications = ArrayList() + + val notificationsByType: Map> = 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): 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 { + 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, notifications2: List): String? { + val names = java.util.ArrayList(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()) + + 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 = + 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" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt deleted file mode 100644 index 91ba6f7810..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt +++ /dev/null @@ -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 . */ - -@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 = - 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 = "" - ) - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt index 8f7e4ebadd..fa12a38f3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -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() + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt new file mode 100644 index 0000000000..65c53abd75 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt @@ -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 . */ + +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 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index fa906510ea..b5b40ea612 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -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) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 8b8b7d8bbe..f463efc6e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -1,4 +1,4 @@ -/* Copyright 2018 Jeremiasz Nelz +/* 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(NotificationHelper.KEY_VISIBILITY)!! - val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty() - val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty() + intent.getSerializableExtraCompat(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" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index f8a7c8d3f5..6ba44aeabd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -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" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 3ebba5971c..4d3d96cca4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index d3cf8159b7..3ecfec52e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt index be249bc3aa..b65a8d535b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -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 ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index 8426630a3c..5c03bdb04b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -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 ) diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 6874ab45f5..2b33067d55 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -14,7 +14,7 @@ import androidx.work.testing.WorkManagerTestInitHelper import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.accountlist.AccountListActivity -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.entity.Account @@ -100,12 +100,19 @@ class MainActivityTest { val notificationManager = context.getSystemService(NotificationManager::class.java) val shadowNotificationManager = shadowOf(notificationManager) - NotificationHelper.createNotificationChannelsForAccount(accountEntity, context) + val notificationService = NotificationService( + notificationManager, + mock { + on { areNotificationsEnabled() } doReturn true + }, + mock(), + context, + ) + + notificationService.createNotificationChannelsForAccount(accountEntity) runInBackground { - val notification = NotificationHelper.makeBaseNotification( - context, - notificationManager, + val notification = notificationService.createBaseNotification( Notification( type = type, id = "id", @@ -161,7 +168,8 @@ class MainActivityTest { api = api, eventHub = eventHub, accountManager = accountManager, - shareShortcutHelper = mock() + shareShortcutHelper = mock(), + notificationService = mock(), ) val testViewModelFactory = viewModelFactory { initializer { viewModel }